Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -0,0 +1,112 @@
|
||||
use super::{ERROR, packages};
|
||||
|
||||
#[test]
|
||||
fn text_policy_is_a_single_model_modifier_and_defaults_use_the_normalized_length() {
|
||||
use dever::compiler::model::persistence::{TextMode, Value};
|
||||
let program = packages(&[("demo.account.model", "type Account { name: Text(1, 2) lower default \" Ä \"\ncode: Text(2) upper default \" ß \"\nbody: Text raw default \" a \"\nnote: Text? }")]).unwrap();
|
||||
let columns = &program.models[0].columns;
|
||||
for (column, mode, expected) in [
|
||||
(2, TextMode::Lower, "ä"),
|
||||
(3, TextMode::Upper, "SS"),
|
||||
(4, TextMode::Raw, " a "),
|
||||
] {
|
||||
assert_eq!(columns[column].text, mode);
|
||||
assert!(matches!(&columns[column].default, Some(Value::Text(value)) if value == expected));
|
||||
}
|
||||
assert_eq!(columns[5].text, TextMode::Trim);
|
||||
for field in [
|
||||
"name: Text raw lower",
|
||||
"name: Text lower upper",
|
||||
"name: Text raw raw",
|
||||
"name: Int lower",
|
||||
"name: List<Text> raw",
|
||||
"name: Text(1, 2) default \" \"",
|
||||
"name: Text(1) upper default \"ß\"",
|
||||
"name: Text trim",
|
||||
] {
|
||||
assert!(
|
||||
packages(&[("demo.account.model", &format!("type Account {{ {field} }}"))]).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
assert!(packages(&[("demo.account.domain", "type Input { name: Text raw }")]).is_err());
|
||||
let seeded = packages(&[(
|
||||
"demo.account.model",
|
||||
"type Account { name: Text(1, 2) lower unique }\nseed { { name = \" Ä \" } }",
|
||||
)])
|
||||
.unwrap();
|
||||
assert!(matches!(&seeded.models[0].seeds[0].values[0].1, Value::Text(value) if value == "ä"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_choice_options_preserve_labels_and_order() {
|
||||
let program = packages(&[
|
||||
("demo.account.model", "type Status { Active = \"启用\"\nDisabled = \"禁用\" }\ntype Account { status: Status }"),
|
||||
("demo.account.app", "options() (values: Map<model.Status, Text>) { values = model.Status.options }"),
|
||||
]).unwrap();
|
||||
assert!(!program.functions[0].signature.effects.database);
|
||||
assert!(packages(&[("main", "type Status { Active }\npublic main() (values: Map<Text, Text>) { values = Status.options }")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_defaults_and_generated_columns_keep_static_types() {
|
||||
let program = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", "type Status { Active = \"启用\"\nDisabled = \"禁用\" }\ntype Account { uid: Uuid generated unique\nstatus: Status default Status.Active\namount: Decimal(5, 2) default -12.30\nnote: Text? default null\nname: Text(1, 8) default \"雪\" }"),
|
||||
("demo.account.app", "create() (id: model.id) { row = model.create({})\nid = row.id }"),
|
||||
("demo.account.api", "cmd create = app.create"),
|
||||
]).unwrap();
|
||||
let columns = &program.models[0].columns;
|
||||
assert!(columns[2].generated);
|
||||
assert_eq!(
|
||||
columns[3].choice.as_ref().unwrap(),
|
||||
&[
|
||||
("Active".into(), "启用".into()),
|
||||
("Disabled".into(), "禁用".into())
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
matches!(&columns[4].default, Some(dever::compiler::model::persistence::Value::Text(value)) if value == "098770")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_defaults_and_generated_writes_fail_before_database_access() {
|
||||
for field in [
|
||||
"value: Decimal(5, 2) default 0.001",
|
||||
"value: Decimal(5, 2) default 1000",
|
||||
"value: Text(2) default \"三字符\"",
|
||||
"value: Int default 1.1",
|
||||
"value: Int default 9223372036854775808",
|
||||
"value: Float default 1e999",
|
||||
"value: Bool default null",
|
||||
"value: Uuid default \"broken\"",
|
||||
"value: Date default \"2025-02-29\"",
|
||||
"value: Time default \"24:00:00\"",
|
||||
"value: Json default \"{\\\"x\\\":1,\\\"x\\\":2}\"",
|
||||
"value: Int generated",
|
||||
"value: Uuid? generated",
|
||||
"value: Uuid generated default \"00112233-4455-6677-8899-aabbccddeeff\"",
|
||||
] {
|
||||
let model = format!("type Account {{ {field} }}");
|
||||
let error = packages(&[("demo.account.model", &model)]).unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{field}: {error:?}");
|
||||
}
|
||||
for body in [
|
||||
"row = model.create({ uid = uid })",
|
||||
"row = model.update(id, { uid = uid })",
|
||||
"row = model.get(id)\nrow.uid = uid",
|
||||
] {
|
||||
let app = format!(
|
||||
"write(id: model.id, uid: Uuid) (value: model.id) {{ {body}\nvalue = row.id }}"
|
||||
);
|
||||
let error = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", "type Account { uid: Uuid generated }"),
|
||||
("demo.account.app", &app),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(matches!(error.code, "C014" | "C005"), "{body}: {error:?}");
|
||||
assert!(error.message.contains("read-only"), "{error:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use super::package::packages;
|
||||
use dever::{compiler::model::Program, syntax::diagnostic::Diagnostic};
|
||||
mod defaults;
|
||||
mod native;
|
||||
mod privacy;
|
||||
mod query;
|
||||
mod relation;
|
||||
mod scalar;
|
||||
mod schema;
|
||||
|
||||
pub(super) const ERROR: &str = "public type Error { error Pool(message: Text)\nerror PoolExhausted(message: Text)\nerror Connection(message: Text)\nerror Timeout(message: Text)\nerror Cancelled(message: Text)\nerror Database(message: Text)\nerror Constraint(message: Text)\nerror NotFound(message: Text)\nerror InvalidData(message: Text)\nerror Migration(message: Text) }";
|
||||
|
||||
fn checked(app: &str, api: &str) -> Result<Program, Diagnostic> {
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
(
|
||||
"demo.account.model",
|
||||
"type Account { name: Text(1, 64) unique\nnote: Text? }",
|
||||
),
|
||||
("demo.account.app", app),
|
||||
("demo.account.api", api),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_ids_queries_and_failures_keep_static_identity() {
|
||||
let program = checked(
|
||||
"get(id: model.id) (account: model.Account) { account = model.get(id) }",
|
||||
"cmd get = app.get",
|
||||
)
|
||||
.unwrap();
|
||||
let function = &program.functions[0].signature;
|
||||
assert!(function.effects.database);
|
||||
assert!(!function.effects.database_write);
|
||||
assert_eq!(function.failures.len(), 10);
|
||||
assert_eq!(program.models.len(), 1);
|
||||
assert!(program.models[0].columns[2].unique);
|
||||
assert_eq!(program.models[0].columns[2].limits, Some((1, 64)));
|
||||
assert_eq!(
|
||||
checked(
|
||||
"get(id: Int) (account: model.Account) { account = model.get(id) }",
|
||||
"cmd get = app.get"
|
||||
)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C005"
|
||||
);
|
||||
let app = "read(value: model.Account) (id: model.id) { value.id = value.id\nid = value.id }";
|
||||
assert_eq!(
|
||||
checked(app, "cmd read = app.read").unwrap_err().code,
|
||||
"C014"
|
||||
);
|
||||
assert_eq!(checked("create(name: Text) (account: model.Account) { account = model.create({ name = name }) }", "public get create = app.create").unwrap_err().code, "C014");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transactions_reject_unrelated_work_and_role_bypasses() {
|
||||
let crypto = "public type HashResult { Ready(value: Text)\nerror Failed(message: Text) }\npublic passwordHash(secret: Secret) (hash: Text) { hash = hashValue(dever.system.cryptoPasswordHash(secret)) }\nhashValue(result: HashResult.Ready(value)) (hash: Text) { hash = value }\nhashValue(result: HashResult.Failed(message)) (hash: Text) { fail(result) }";
|
||||
let blocked = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("dever.crypto", crypto),
|
||||
("demo.account.model", "type Account { name: Text }"),
|
||||
(
|
||||
"demo.account.app",
|
||||
"transaction create(password: Secret) (account: model.Account) { hash = blocking(dever.crypto.passwordHash(password))\naccount = model.create({ name = hash }) }",
|
||||
),
|
||||
("demo.account.api", "cmd create = app.create"),
|
||||
]);
|
||||
assert_eq!(blocked.unwrap_err().code, "C014");
|
||||
let staged = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("dever.crypto", crypto),
|
||||
("demo.account.model", "type Account { name: Text }"),
|
||||
("demo.account.app", "transaction persist(name: Text) (account: model.Account) { account = model.create({ name = name }) }\ncreate(password: Secret) (account: model.Account) { hash = blocking(dever.crypto.passwordHash(password))\naccount = persist(hash) }"),
|
||||
]).unwrap();
|
||||
let staged = &staged
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "create")
|
||||
.unwrap()
|
||||
.signature
|
||||
.effects;
|
||||
assert!(staged.password);
|
||||
assert!(!staged.unscoped_write);
|
||||
let app = "transaction create(name: Text) (account: model.Account) { account = model.create({ name = name }) }";
|
||||
assert!(checked(app, "long cmd create = app.create").is_ok());
|
||||
assert!(checked(app, "public long post create = app.create").is_ok());
|
||||
assert_eq!(checked("create(name: Text) (account: model.Account) { account = model.create({ name = name }) }", "long cmd create = app.create").unwrap_err().code, "C014");
|
||||
assert_eq!(checked("create(name: Text) (account: model.Account) { account = model.create({ name = name }) }", "public long post create = app.create").unwrap_err().code, "C014");
|
||||
assert!(
|
||||
checked(
|
||||
"read(value: model.Page) (page: model.Page) { value.page = 2\npage = value }",
|
||||
"cmd read = app.read"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
checked(
|
||||
"transaction empty(value: Int) (answer: Int) { answer = value }",
|
||||
"cmd empty = app.empty"
|
||||
)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
assert_eq!(checked("read() (total: Int) { total = model.count() }\ntransaction spawn() (total: Int) { work = run(read())\ntotal = wait(work) }", "cmd spawn = app.spawn").unwrap_err().code, "C014");
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", "type Account { name: Text }"),
|
||||
(
|
||||
"demo.account.domain",
|
||||
"read() (total: Int) { total = model.count() }"
|
||||
)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("main", "type Value { name: Text(64) }")])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("main", "type Value { name: Text unique }")])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_query_blocks_reject_dynamic_fields_and_invalid_options() {
|
||||
for query in [
|
||||
"{ where = missing == name }",
|
||||
"{ where = name }",
|
||||
"{ order = [missing.asc] }",
|
||||
"{ size = 1, size = 2 }",
|
||||
"{ size = name }",
|
||||
] {
|
||||
let app = format!("search(name: Text) (page: model.Page) {{ page = model.list({query}) }}");
|
||||
assert!(checked(&app, "cmd search = app.search").is_err(), "{query}");
|
||||
}
|
||||
assert_eq!(
|
||||
packages(&[(
|
||||
"demo.account.model",
|
||||
"database one\ndatabase two\ntype Account { name: Text }"
|
||||
)])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C002"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("demo.account.model", "type Account { name: Text(65, 64) }")])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
use super::{ERROR, packages};
|
||||
|
||||
fn checked(
|
||||
sqlite: &str,
|
||||
postgres: &str,
|
||||
entry: &str,
|
||||
) -> Result<dever::compiler::model::Program, dever::syntax::diagnostic::Diagnostic> {
|
||||
let model = format!(
|
||||
"type Account {{ name: Text }}\nsql lookup(name: Text) (row: app.Summary?) {{ sqlite = {sqlite:?}\npostgres = {postgres:?} }}"
|
||||
);
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", &model),
|
||||
(
|
||||
"demo.account.app",
|
||||
"type Summary { name: Text }\nread(name: Text) (value: Text) { row = model.lookup(name)\nvalue = text(row) }\ntext(row: Summary?) (value: Text) { value = \"present\" }",
|
||||
),
|
||||
("demo.account.api", entry),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sql_proves_only_owned_static_projections_readonly() {
|
||||
let sqlite = "SELECT name FROM demo_account_model WHERE name = ?1 ORDER BY id DESC";
|
||||
let postgres = sqlite.replace('?', "$");
|
||||
let program = checked(sqlite, &postgres, "public get read = app.read").unwrap();
|
||||
assert!(program.models[0].sql[0].readonly);
|
||||
let complex = "SELECT max(name) AS name FROM demo_account_model WHERE name = ?1";
|
||||
assert!(checked(complex, &complex.replace('?', "$"), "cmd read = app.read").is_ok());
|
||||
assert!(
|
||||
checked(
|
||||
complex,
|
||||
&complex.replace('?', "$"),
|
||||
"public get read = app.read"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
checked(
|
||||
sqlite,
|
||||
"SELECT name FROM other_table WHERE name = $1",
|
||||
"public get read = app.read"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sql_accepts_readonly_alias_count_and_parameterized_paging() {
|
||||
for sql in [
|
||||
"SELECT name AS label FROM demo_account_model WHERE name = ?1",
|
||||
"SELECT COUNT(*) AS total FROM demo_account_model WHERE name = ?1",
|
||||
"SELECT name AS label FROM demo_account_model ORDER BY id DESC LIMIT ?1 OFFSET ?1",
|
||||
] {
|
||||
assert!(
|
||||
checked(sql, &sql.replace('?', "$"), "public get read = app.read")
|
||||
.unwrap()
|
||||
.models[0]
|
||||
.sql[0]
|
||||
.readonly
|
||||
);
|
||||
}
|
||||
for sql in [
|
||||
"SELECT name AS label, unknown() FROM demo_account_model WHERE name = ?1",
|
||||
"SELECT count(unknown()) FROM demo_account_model WHERE name = ?1",
|
||||
"SELECT name FROM demo_account_model LIMIT ?1 OFFSET unknown()",
|
||||
] {
|
||||
assert!(
|
||||
checked(sql, &sql.replace('?', "$"), "public get read = app.read").is_err(),
|
||||
"{sql}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sql_rejects_binding_and_statement_escape() {
|
||||
for sql in [
|
||||
"SELECT name FROM demo_account_model WHERE name = ?2",
|
||||
"SELECT name FROM demo_account_model WHERE name = :name",
|
||||
"SELECT name FROM demo_account_model WHERE name = ?1; DELETE FROM demo_account_model",
|
||||
"SELECT name FROM _dever_role WHERE name = ?1",
|
||||
"SELECT name FROM \"_dever_role\" WHERE name = ?1",
|
||||
"SELECT name FROM demo_account_model WHERE name = '?1'",
|
||||
"SELECT name FROM demo_account_model WHERE name = ?1 /*",
|
||||
"SELECT name FROM demo_account_model, other_table WHERE name = ?1",
|
||||
"SELECT name FROM demo_account_model.other_table WHERE name = ?1",
|
||||
"SELECT name FROM demo_account_model JOIN other_table ON name = ?1",
|
||||
] {
|
||||
assert!(
|
||||
checked(sql, &sql.replace('?', "$"), "cmd read = app.read").is_err(),
|
||||
"{sql}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sql_accepts_same_domain_app_view_and_rejects_constrained_results() {
|
||||
let model = "type Account { name: Text }\nsql lookup(name: Text) (row: app.Summary?) { sqlite = \"SELECT name FROM demo_account_model WHERE name = ?1\"\npostgres = \"SELECT name FROM demo_account_model WHERE name = $1\" }";
|
||||
let app = "type Summary { name: Text }\nread(name: Text) (row: Summary?) { row = model.lookup(name) }";
|
||||
assert!(
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", model),
|
||||
("demo.account.app", app),
|
||||
("demo.account.api", "public get read = app.read")
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
for record in [
|
||||
"type Extra { value: Int }",
|
||||
"type Extra { value: Secret }",
|
||||
"type Extra { value: List<Int> }",
|
||||
] {
|
||||
let model = format!("type Account {{ name: Text }}\n{record}");
|
||||
assert!(packages(&[("demo.account.model", &model)]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sql_records_stay_private_and_all_joined_models_are_dependencies() {
|
||||
let model = "type Account { name: Text }\ntype Summary { name: Text }\nsql joined() (row: Summary) { sqlite = \"SELECT demo_account_model.name FROM demo_account_model, demo_profile_model\"\npostgres = \"SELECT demo_account_model.name FROM demo_account_model JOIN demo_profile_model ON true\" }";
|
||||
let app = "read() (name: Text) { row = model.joined()\nname = row.name }";
|
||||
let mut program = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", model),
|
||||
("demo.profile.model", "type Profile { name: Text }"),
|
||||
("demo.account.app", app),
|
||||
("demo.account.api", "cmd read = app.read"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(program.models[0].sql[0].dependencies, [0, 1]);
|
||||
program.commands.clear();
|
||||
assert!(
|
||||
program
|
||||
.functions
|
||||
.iter()
|
||||
.all(|function| !function.signature.transaction)
|
||||
);
|
||||
let generated = dever::compiler::emit::generate(&program, &[], &Default::default());
|
||||
assert!(generated.contains("registry.check_transactions(&[&[0,1]])"));
|
||||
let leaked = "read() (row: model.Summary) { row = model.joined() }";
|
||||
assert!(
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", model),
|
||||
("demo.profile.model", "type Profile { name: Text }"),
|
||||
("demo.account.app", leaked),
|
||||
("demo.account.api", "cmd read = app.read")
|
||||
])
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("private type")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sql_reuses_model_columns_but_rejects_unproved_projection_bounds() {
|
||||
let model = "type Account { name: Text(8) }\nsql snapshot() (row: Account) { sqlite = \"SELECT id, created_at, name FROM demo_account_model\"\npostgres = \"SELECT id, created_at, name FROM demo_account_model\" }";
|
||||
assert!(
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", model),
|
||||
(
|
||||
"demo.account.app",
|
||||
"read() (name: Text) { row = model.snapshot()\nname = row.name }"
|
||||
),
|
||||
("demo.account.api", "cmd read = app.read")
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
for field in ["name: Text(8)", "name: Text unique", "name: Text index"] {
|
||||
let model = format!(
|
||||
"type Account {{ name: Text }}\ntype Summary {{ {field} }}\nsql summary() (row: Summary) {{ sqlite = \"SELECT name FROM demo_account_model\"\npostgres = \"SELECT name FROM demo_account_model\" }}"
|
||||
);
|
||||
assert!(
|
||||
packages(&[("demo.account.model", &model)]).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_sql_fields_are_identifiers_and_do_not_create_fake_table_dependencies() {
|
||||
let sql = "SELECT \"from\" FROM \"demo_account_model\" WHERE \"from\" = ?1";
|
||||
let model = format!(
|
||||
"type Account {{ from: Text }}\nsql lookup(value: Text) (row: app.View) {{ sqlite = {sql:?}\npostgres = {:?} }}",
|
||||
sql.replace('?', "$")
|
||||
);
|
||||
let program = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", &model),
|
||||
(
|
||||
"demo.account.app",
|
||||
"type View { from: Text }\nread(value: Text) (row: View) { row = model.lookup(value) }",
|
||||
),
|
||||
("demo.account.api", "public get read = app.read"),
|
||||
])
|
||||
.unwrap();
|
||||
assert!(program.models[0].sql[0].readonly);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use super::{ERROR, packages};
|
||||
|
||||
fn check(app: &str) -> Result<super::Program, super::Diagnostic> {
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
(
|
||||
"demo.account.model",
|
||||
"type Account { name: Text\nprivate token: Text }",
|
||||
),
|
||||
("demo.account.app", app),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_model_snapshots_cannot_cross_app_signatures() {
|
||||
for kind in [
|
||||
"model.Account",
|
||||
"model.Account?",
|
||||
"List<model.Account>",
|
||||
"model.Page",
|
||||
"Map<Text, model.Account>",
|
||||
"MapEntry<Text, model.Account>",
|
||||
"AsyncStream<model.Account>",
|
||||
"Related<model.Account>",
|
||||
] {
|
||||
let body = format!("read(value: {kind}) (answer: {kind}) {{ answer = value }}");
|
||||
let error = check(&body).unwrap_err();
|
||||
assert_eq!(error.code, "C006", "{kind}: {error:?}");
|
||||
assert!(error.message.contains("private Model"), "{error:?}");
|
||||
}
|
||||
let error = check("read(id: model.id) (account: model.Account) { account = model.get(id) }")
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C006");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_models_cannot_hide_in_app_records_or_errors() {
|
||||
for body in [
|
||||
"type Wrapped { private value: model.Account }\nread(value: Wrapped) (answer: Wrapped) { answer = value }",
|
||||
"type Problem { error Denied(account: model.Account) }\nread(id: model.id) () { account = model.get(id)\nfail(Problem.Denied(account)) }",
|
||||
"read(action: handler(value: model.Account) ()) () {}",
|
||||
] {
|
||||
let error = check(body).unwrap_err();
|
||||
assert_eq!(error.code, "C006", "{error:?}");
|
||||
assert!(error.message.contains("private Model"), "{error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_public_records_remain_legal_but_private_fields_stay_off_wire() {
|
||||
let opaque = "type Session { private token: Text\nlabel: Text }\nread(value: Session) (answer: Session) { answer = value }";
|
||||
assert!(check(opaque).is_ok());
|
||||
let error = packages(&[
|
||||
("demo.account.app", opaque),
|
||||
("demo.account.api", "cmd read = app.read"),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C006");
|
||||
assert!(error.message.contains("private fields"), "{error:?}");
|
||||
assert!(check("type View { name: Text }\nread(id: model.id) (view: View) { account = model.get(id)\nview = View { name = account.name } }").is_ok());
|
||||
let hidden = [
|
||||
("demo.account.domain", "type State { value: Text }"),
|
||||
(
|
||||
"demo.account.app",
|
||||
"type Session { private state: domain.State\nlabel: Text }",
|
||||
),
|
||||
];
|
||||
let mut files = hidden.to_vec();
|
||||
files.push((
|
||||
"demo.other.app",
|
||||
"pass(value: demo.account.Session) (answer: demo.account.Session) { answer = value }",
|
||||
));
|
||||
assert!(packages(&files).is_ok());
|
||||
files.pop();
|
||||
files.push((
|
||||
"demo.other.app",
|
||||
"read(value: demo.account.Session) (answer: Text) { answer = value.state.value }",
|
||||
));
|
||||
assert_eq!(packages(&files).unwrap_err().code, "C006");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_model_data_propagates_through_related_cycles_without_blocking_ids() {
|
||||
let models = [
|
||||
("dever.database", ERROR),
|
||||
(
|
||||
"demo.people.model.team",
|
||||
"type Team { name: Text }\nrelation users = demo.people.model.user.team_id",
|
||||
),
|
||||
(
|
||||
"demo.people.model.user",
|
||||
"type User { private token: Text\nteam_id: model.team.id }",
|
||||
),
|
||||
];
|
||||
for kind in [
|
||||
"model.team.Team",
|
||||
"Related<model.team.Team>",
|
||||
"List<model.team.Team?>",
|
||||
] {
|
||||
let app = format!("pass(value: {kind}) (answer: {kind}) {{ answer = value }}");
|
||||
let mut files = models.to_vec();
|
||||
files.push(("demo.people.app", &app));
|
||||
let error = packages(&files).unwrap_err();
|
||||
assert_eq!(error.code, "C006", "{error:?}");
|
||||
assert!(error.message.contains("private Model"), "{error:?}");
|
||||
}
|
||||
let mut files = models.to_vec();
|
||||
files.push((
|
||||
"demo.people.app",
|
||||
"pass(value: model.user.id) (answer: model.user.id) { answer = value }",
|
||||
));
|
||||
assert!(packages(&files).is_ok());
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use super::checked;
|
||||
|
||||
#[test]
|
||||
fn conditional_writes_require_static_predicates_and_write_entries() {
|
||||
let update = "change(name: Text) (count: Int) { count = model.update({ where = name == name }, { note = name }) }";
|
||||
assert!(checked(update, "cmd change = app.change").is_ok());
|
||||
assert_eq!(
|
||||
checked(update, "public get change = app.change")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
for expression in [
|
||||
"model.update({}, { note = name })",
|
||||
"model.update({ where = name == name, order = [id.asc] }, { note = name })",
|
||||
"model.update({ where = name == name }, {})",
|
||||
"model.update({ where = name == name }, { note = name, note = name })",
|
||||
"model.delete({})",
|
||||
"model.delete({ where = name == name, size = 1 })",
|
||||
] {
|
||||
let source = format!("change(name: Text) (count: Int) {{ count = {expression} }}");
|
||||
assert!(
|
||||
checked(&source, "cmd change = app.change").is_err(),
|
||||
"{expression}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_checks_snapshot_type_order_and_options() {
|
||||
for query in ["", "{ after = null, size = 2, order = [name.asc] }"] {
|
||||
let app = format!("search() (page: model.Cursor) {{ page = model.cursor({query}) }}");
|
||||
assert!(checked(&app, "cmd search = app.search").is_ok(), "{query}");
|
||||
}
|
||||
for query in [
|
||||
"{ after = 1 }",
|
||||
"{ size = \"two\" }",
|
||||
"{ order = [note.asc] }",
|
||||
"{ order = [name.asc, name.desc] }",
|
||||
"{ page = 1 }",
|
||||
] {
|
||||
let app = format!("search() (page: model.Cursor) {{ page = model.cursor({query}) }}");
|
||||
assert!(checked(&app, "cmd search = app.search").is_err(), "{query}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_predicates_require_static_fields_and_typed_bounded_values() {
|
||||
let api = "cmd search = app.search";
|
||||
for condition in [
|
||||
"not contains(name, value) and in(name, [value, \"other\"])",
|
||||
"between(name, value, \"雪\") or startsWith(note, value)",
|
||||
"endsWith(name, value) and note == null",
|
||||
] {
|
||||
let app = format!(
|
||||
"search(value: Text) (total: Int) {{ total = model.count({{ where = {condition} }}) }}"
|
||||
);
|
||||
assert!(checked(&app, api).is_ok(), "{condition}");
|
||||
}
|
||||
for condition in [
|
||||
"in(name, [])",
|
||||
"in(name, value)",
|
||||
"in(name, [1])",
|
||||
"contains(name)",
|
||||
"contains(id, value)",
|
||||
"contains(\"field\", value)",
|
||||
"contains(missing, value)",
|
||||
"between(name, value)",
|
||||
"startsWith(name, 1)",
|
||||
"unknown(name, value)",
|
||||
] {
|
||||
let app = format!(
|
||||
"search(value: Text) (total: Int) {{ total = model.count({{ where = {condition} }}) }}"
|
||||
);
|
||||
assert!(checked(&app, api).is_err(), "accepted {condition}");
|
||||
}
|
||||
let items = vec!["value"; 101].join(",");
|
||||
let app = format!(
|
||||
"search(value: Text) (total: Int) {{ total = model.count({{ where = in(name, [{items}]) }}) }}"
|
||||
);
|
||||
assert!(checked(&app, api).is_err());
|
||||
assert!(
|
||||
checked(
|
||||
"search(value: Text) (answer: Bool) { answer = contains(value, value) }",
|
||||
api
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_and_upsert_keep_field_contracts_and_unique_keys() {
|
||||
for body in [
|
||||
"total = model.createMany([])",
|
||||
"total = model.createMany([{ name = \"one\" }, { name = \"two\", note = null }])",
|
||||
"row = model.upsert({ name = \"one\" }, {}, { note = \"updated\" })\ntotal = length(row.name)",
|
||||
] {
|
||||
let app = format!("write() (total: Int) {{ {body} }}");
|
||||
assert!(checked(&app, "cmd write = app.write").is_ok(), "{body}");
|
||||
assert!(checked(&app, "public get write = app.write").is_err());
|
||||
}
|
||||
for body in [
|
||||
"total = model.createMany([{}])",
|
||||
"total = model.createMany([{ name = \"one\", name = \"two\" }])",
|
||||
"total = model.createMany([{ id = 1, name = \"one\" }])",
|
||||
"total = model.createMany([{ name = 7 }])",
|
||||
"row = model.upsert({}, { name = \"one\" }, {})\ntotal = length(row.name)",
|
||||
"row = model.upsert({ note = \"key\" }, { name = \"one\" }, {})\ntotal = length(row.name)",
|
||||
"row = model.upsert({ name = \"key\" }, { name = \"one\" }, {})\ntotal = length(row.name)",
|
||||
"row = model.upsert({ name = \"key\" }, {}, { id = 2 })\ntotal = length(row.name)",
|
||||
] {
|
||||
let app = format!("write() (total: Int) {{ {body} }}");
|
||||
assert!(
|
||||
checked(&app, "cmd write = app.write").is_err(),
|
||||
"accepted {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use super::{ERROR, packages};
|
||||
|
||||
fn check(
|
||||
body: &str,
|
||||
) -> Result<dever::compiler::model::Program, dever::syntax::diagnostic::Diagnostic> {
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
(
|
||||
"demo.people.model.user",
|
||||
"type User { name: Text }\nrelation posts = demo.people.model.post.author_id",
|
||||
),
|
||||
(
|
||||
"demo.people.model.post",
|
||||
"type Post { author_id: model.user.id\ntitle: Text }",
|
||||
),
|
||||
("demo.people.app", body),
|
||||
("demo.people.api", "cmd read = app.read"),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relations_are_static_and_propagate_all_models() {
|
||||
let program = check(
|
||||
"read() (total: Int) { page = model.user.list({ with = [posts] })\ntotal = page.total }",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(program.models[0].relations.len(), 1);
|
||||
let operation = &program.functions[0].clauses[0].body[0];
|
||||
let dever::compiler::model::Statement::Assign { value, .. } = operation else {
|
||||
panic!()
|
||||
};
|
||||
let dever::compiler::model::Node::Database(query, _) = &value.kind else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(query.models().collect::<Vec<_>>(), vec![0, 1]);
|
||||
for option in [
|
||||
"with = [posts, posts]",
|
||||
"with = [posts.author]",
|
||||
"with = [missing]",
|
||||
] {
|
||||
assert!(
|
||||
check(&format!(
|
||||
"read() (page: model.user.Page) {{ page = model.user.list({{ {option} }}) }}"
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_cannot_be_used_inside_transactions_or_load_relations() {
|
||||
for body in [
|
||||
"transaction read() (total: Int) { rows = model.user.stream()\nclose(rows)\ntotal = 0 }",
|
||||
"read() (total: Int) { rows = model.user.stream({ with = [posts] })\nclose(rows)\ntotal = 0 }",
|
||||
] {
|
||||
assert!(check(body).is_err(), "{body}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use super::{ERROR, packages};
|
||||
|
||||
#[test]
|
||||
fn scalar_columns_keep_distinct_precision_and_query_bindings() {
|
||||
let program = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", "type Account { amount: Decimal(34, 4)\nfee: Decimal(5, 2)?\nuid: Uuid unique\nday: Date\ntime: Time\nelapsed: Duration\ndocument: Json }"),
|
||||
("demo.account.app", "search(amount: Decimal, fee: Decimal?) (page: model.Page) { page = model.list({ where = amount >= amount and fee == fee, order = [amount.asc] }) }"),
|
||||
("demo.account.api", "cmd search = app.search"),
|
||||
]).unwrap();
|
||||
assert_eq!(program.models[0].columns[2].decimal, Some((34, 4)));
|
||||
assert_eq!(program.models[0].columns[3].decimal, Some((5, 2)));
|
||||
assert_eq!(program.models[0].columns[2].limits, None);
|
||||
assert!(program.models[0].columns[4].unique);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_metadata_rejects_lossy_or_ambiguous_decimal_storage() {
|
||||
for kind in [
|
||||
"Decimal",
|
||||
"Decimal(5)",
|
||||
"Decimal(0, 0)",
|
||||
"Decimal(35, 4)",
|
||||
"Decimal(4, 5)",
|
||||
"Decimal(4, 2, 1)",
|
||||
] {
|
||||
let model = format!("type Account {{ amount: {kind} }}");
|
||||
let error = packages(&[("demo.account.model", &model)]).unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{kind}: {error:?}");
|
||||
}
|
||||
for kind in ["Decimal(1, 0)", "Decimal(34, 34)", "Decimal(9, 2)?"] {
|
||||
let model = format!("type Account {{ amount: {kind} }}");
|
||||
packages(&[("demo.account.model", &model)]).unwrap();
|
||||
}
|
||||
for kind in ["List<Int>", "Id", "Secret"] {
|
||||
let model = format!("type Account {{ value: {kind} }}");
|
||||
assert!(
|
||||
packages(&[("demo.account.model", &model)]).is_err(),
|
||||
"{kind}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use super::packages;
|
||||
|
||||
#[test]
|
||||
fn storage_declarations_do_not_reserve_existing_function_names() {
|
||||
for name in ["seed", "index", "unique", "migrate"] {
|
||||
let source = format!("public {name}() (answer: Int) {{ answer = 1 }}");
|
||||
packages(&[("main", &source)]).unwrap();
|
||||
let source = format!("public {name}(value: Int) (answer: Int) {{ answer = value }}");
|
||||
packages(&[("main", &source)]).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn model(extra: &str) -> String {
|
||||
format!(
|
||||
"type Account {{ email: Text(64) unique\nname: Text(16)\namount: Decimal(9, 2) default 0\nstatus: Int default 1 }}\n{extra}"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_checks_seed_unique_keys_and_composite_indexes() {
|
||||
let source = model(
|
||||
"index(status, created_at)\nunique(email, name)\nseed { { email = \"admin@sample.test\", name = \"Admin\" } }",
|
||||
);
|
||||
let program = packages(&[("demo.account.model", &source)]).unwrap();
|
||||
assert_eq!(program.models[0].indexes.len(), 2);
|
||||
assert_eq!(program.models[0].seeds.len(), 1);
|
||||
assert_eq!(program.models[0].seeds[0].key, [2]);
|
||||
for extra in [
|
||||
"index(missing)",
|
||||
"index(name, name)",
|
||||
"index(name)\nindex(name)",
|
||||
"seed { { name = \"Admin\" } }",
|
||||
"seed { { email = \"a\" } }",
|
||||
"seed { { email = \"a\", name = \"a\", amount = 0.001 } }",
|
||||
"seed { { email = \"a\", name = \"a\", id = 1 } }",
|
||||
"seed {}\nseed {}",
|
||||
"migrate same { drop legacy }\nmigrate same { drop old }",
|
||||
"migrate bad { drop name }",
|
||||
"migrate bad { drop created_at }",
|
||||
"migrate empty {}",
|
||||
] {
|
||||
let source = model(extra);
|
||||
let error = packages(&[("demo.account.model", &source)]).unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{extra}: {error:?}");
|
||||
}
|
||||
for source in [
|
||||
"type Account { first: Text from old\nsecond: Text from old }",
|
||||
"type Account { first: Text from second\nsecond: Text }",
|
||||
"type Account { first: Text from created_at }",
|
||||
] {
|
||||
assert_eq!(
|
||||
packages(&[("demo.account.model", source)])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_sql_tracks_boundaries_and_rejects_unsafe_or_lossy_inputs() {
|
||||
let statement = |sqlite: &str, postgres: &str, values: &str| {
|
||||
let extra = format!(
|
||||
"migrate normalize {{ before {{ sqlite = {}\npostgres = {}\nparameters = {values} }} }}",
|
||||
serde_json::to_string(sqlite).unwrap(),
|
||||
serde_json::to_string(postgres).unwrap()
|
||||
);
|
||||
packages(&[("demo.account.model", &model(&extra))])
|
||||
};
|
||||
statement("/* prefix */ UPDATE demo_account_model SET name = ?1 WHERE name = ?2 AND ';?90' = ';?90'; -- end", "/* nested /* ok */ */ UPDATE demo_account_model SET name = $1 WHERE name = $2 AND $text$;$90$text$ = $text$;$90$text$; -- end", "[\"new\",\"old\"]").unwrap();
|
||||
for sql in [
|
||||
"COMMIT",
|
||||
"SELECT ?1, ?2",
|
||||
"DROP TABLE account",
|
||||
"UPDATE account SET name = ?1; DELETE FROM account WHERE name = ?2",
|
||||
"UPDATE account SET name = ?3 WHERE name = ?2",
|
||||
"UPDATE account SET name = $1 WHERE name = $2",
|
||||
"UPDATE _dever_history SET name = ?1 WHERE name = ?2",
|
||||
"UPDATE sqlite_master SET name = ?1 WHERE name = ?2",
|
||||
"DELETE FROM main.'_dever_history' WHERE name IN (?1,?2)",
|
||||
"UPDATE account SET name = '未闭合",
|
||||
"UPDATE [未闭合 SET name = ?1 WHERE name = ?2",
|
||||
] {
|
||||
let error = statement(
|
||||
sql,
|
||||
"UPDATE account SET name = $1 WHERE name = $2",
|
||||
"[\"new\",\"old\"]",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{sql}: {error:?}");
|
||||
}
|
||||
for sql in [
|
||||
"UPDATE account SET name = $tag$未闭合",
|
||||
"UPDATE account SET name = $$未闭合",
|
||||
"UPDATE pg_catalog.pg_class SET name = $1",
|
||||
] {
|
||||
assert_eq!(
|
||||
statement("UPDATE account SET name = ?1", sql, "[\"new\"]")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
for value in ["1.25", "1e999", "9223372036854775808", "name", "1 + 2"] {
|
||||
assert_eq!(
|
||||
statement(
|
||||
"UPDATE account SET name = ?1",
|
||||
"UPDATE account SET name = $1",
|
||||
&format!("[{value}]")
|
||||
)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
statement(
|
||||
"UPDATE account SET name = ?1 WHERE status = ?2",
|
||||
"UPDATE account SET name = $1 WHERE status = $2",
|
||||
"[\"new\",1e0]",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user