Initial Dever 0.0.1

This commit is contained in:
Codex committed 2026-11-03 03:32:07 +00:00
commit 81233d098c
1479 files changed
+163782

No files matched your search

@@ -0,0 +1,208 @@
use super::*;
#[test]
fn generated_uuid_is_available_when_entries_only_return_counts() {
let directory = Directory::new();
fixture_defaults(&directory.0);
let source = directory.0.join("module/demo/account");
std::fs::write(
source.join("app.dever"),
r#"seed() (total: Int) {
total = model.createMany([{}, {}])
}
"#,
)
.unwrap();
std::fs::write(
source.join("api.dever"),
r#"cmd seed = app.seed
"#,
)
.unwrap();
let project = Project::load(&directory.0).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("counts"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
r#"{"database":{"default":{"type":"sqlite","path":"counts.db"}}}"#,
)
.unwrap();
let output = execute(&binary, "demo.account.seed", "{}");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"],
2
);
}
#[test]
fn database_defaults_choice_and_generated_uuid_survive_native_roundtrips() {
let directory = Directory::new();
verify_defaults(
&directory,
serde_json::json!({"type":"sqlite","path":"data/defaults.db","max_connections":1,"max_page_size":20}),
);
}
#[cfg(feature = "postgres")]
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn postgres_dever_defaults_choice_and_generated_uuid_survive_native_roundtrips() {
let directory = Directory::new();
let schema = super::postgres::Schema::new(
&directory.0,
"dever_native_defaults",
"CREATE SCHEMA dever_native_defaults",
"DROP SCHEMA dever_native_defaults CASCADE",
);
verify_defaults(&directory, schema.connection.clone());
}
fn verify_defaults(directory: &Directory, connection: serde_json::Value) {
fixture_defaults(&directory.0);
let project = Project::load(&directory.0).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("defaults"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
serde_json::to_vec(&serde_json::json!({"database":{"default":connection}})).unwrap(),
)
.unwrap();
let run = |name: &str, input: &str| {
let output = execute(&binary, &format!("demo.account.{name}"), input);
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"].clone()
};
let first = run("create", "{}");
assert_eq!(first["name"], "O'Reilly");
assert_eq!(
dever_runtime::value::decimal::Decimal::parse(first["amount"].as_str().unwrap()).unwrap(),
dever_runtime::value::decimal::Decimal::parse("12.30").unwrap()
);
assert_eq!(first["active"], true);
assert_eq!(first["enabled"], true);
assert_eq!(first["note"], "default note");
assert_eq!(first["day"], "2000-02-29");
assert_eq!(first["time"], "23:59:59.999");
assert_eq!(first["elapsed"], -1);
assert_eq!(first["document"], serde_json::json!({"enabled":true}));
assert_eq!(first["empty"], serde_json::Value::Null);
let uuid = first["uid"].as_str().unwrap();
assert_eq!(&uuid[14..15], "7");
assert!(matches!(&uuid[19..20], "8" | "9" | "a" | "b"));
assert_eq!(run("get", r#"{"key":1}"#), first);
let second = run("override", r#"{"value":null}"#);
assert!(second["note"].is_null());
assert_eq!(second["active"], false);
assert_ne!(first["uid"], second["uid"]);
assert_eq!(run("get", r#"{"key":2}"#), second);
let failed = execute(
&binary,
"demo.account.explode",
r#"{"value":9223372036854775807}"#,
);
assert!(!failed.status.success());
assert!(String::from_utf8_lossy(&failed.stderr).contains("integer arithmetic fault"));
assert_eq!(run("count", "{}"), 2);
assert_eq!(run("option", "{}"), "启用");
}
pub(super) fn fixture_defaults(root: &Path) {
let root = root.join("module/demo/account");
std::fs::create_dir_all(&root).unwrap();
let fields = [
("uid", "Uuid", "generated unique"),
("name", "Text(1, 20)", "default \"O'Reilly\""),
("amount", "Decimal(5, 2)", "default 12.30"),
("status", "Status", "default Status.Active"),
("enabled", "Bool", "default true"),
("note", "Text?", "default \"default note\""),
("day", "Date", "default \"2000-02-29\""),
("time", "Time", "default \"23:59:59.999\""),
("elapsed", "Duration", "default -1"),
("document", "Json", "default \"{\\\"enabled\\\":true}\""),
("empty", "Int?", "default null"),
];
let fields = fields
.iter()
.map(|(name, kind, metadata)| format!("{name}: {kind} {metadata}\n"))
.collect::<String>();
std::fs::write(root.join("model.dever"), format!("type Status {{ Active = \"启用\"\nDisabled = \"禁用\" }}\n\ntype Account {{\n{fields}}}\n")).unwrap();
let outputs = "id: model.id, uid: Uuid, name: Text, amount: Decimal, active: Bool, enabled: Bool, note: Text?, day: Date, time: Time, elapsed: Duration, document: Json, empty: Int?";
let view = outputs
.split(", ")
.map(|output| {
let field = output.split_once(':').unwrap().0;
if field == "active" {
"active = row.status == model.Status.Active\n".into()
} else {
format!("{field} = row.{field}\n")
}
})
.collect::<String>();
let methods = [
(
"create",
"",
outputs,
format!("row = model.create({{}})\n{view}"),
),
(
"get",
"key: model.id",
outputs,
format!("row = model.get(key)\n{view}"),
),
(
"override",
"value: Text?",
outputs,
format!(
"row = model.create({{ note = value, status = model.Status.Disabled }})\n{view}"
),
),
(
"explode",
"value: Int",
"id: model.id, result: Int",
"row = model.create({})\nid = row.id\nresult = value + 1".into(),
),
("count", "", "total: Int", "total = model.count()".into()),
(
"option",
"",
"label: Text?",
"choices = model.Status.options\nlabel = get(choices, model.Status.Active)".into(),
),
];
let mut app = String::new();
let mut api = String::new();
for (name, inputs, outputs, body) in methods {
app.push_str(&format!("{name}({inputs}) ({outputs}) {{\n{body}\n}}\n"));
api.push_str(&format!("cmd {name} = app.{name}\n"));
}
std::fs::write(root.join("app.dever"), app).unwrap();
std::fs::write(root.join("api.dever"), api).unwrap();
}
@@ -0,0 +1,116 @@
use super::*;
#[test]
fn database_migrations_preserve_data_and_reject_loss_or_changed_history() {
let directory = Directory::new();
defaults::fixture_defaults(&directory.0);
let source = directory.0.join("module/demo/account");
let original_model = std::fs::read_to_string(source.join("model.dever"))
.unwrap()
.replace(
"type Account {\n",
"type Account {\nlegacy: Text default \"old\"\n",
);
std::fs::write(source.join("model.dever"), &original_model).unwrap();
let original = compile(&directory.0, "original");
settings(&directory.0, "data/account.db");
let run = |binary: &Path, name: &str, input: &str| {
let result = execute(binary, &format!("demo.account.{name}"), input);
assert!(
result.status.success(),
"{name}: {}",
String::from_utf8_lossy(&result.stderr)
);
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"].clone()
};
let first = run(&original, "create", "{}");
assert_eq!(first["name"], "O'Reilly");
let target = original_model
.replace("legacy: Text default \"old\"\n", "")
.replace("name: Text(1, 20)", "display_name: Text(1, 8)")
.replace(
"default \"O'Reilly\"",
"default \"O'Reilly\" from name unique",
)
.replace("Decimal(5, 2)", "Decimal(7, 3)")
+ UPGRADE;
std::fs::write(source.join("model.dever"), &target).unwrap();
let app = std::fs::read_to_string(source.join("app.dever"))
.unwrap()
.replace("row.name", "row.display_name");
std::fs::write(source.join("app.dever"), app).unwrap();
let upgraded = compile(&directory.0, "upgraded");
let next = run(&upgraded, "get", r#"{"key":1}"#);
assert_eq!(next["name"], "Final");
assert_eq!(next["uid"], first["uid"]);
assert_eq!(next["amount"], first["amount"]);
assert_eq!(run(&upgraded, "count", "{}"), 2);
assert_eq!(run(&upgraded, "get", r#"{"key":2}"#)["name"], "Seed");
assert_eq!(run(&upgraded, "count", "{}"), 2);
assert!(
!execute(&original, "demo.account.count", "{}")
.status
.success()
);
std::fs::write(
source.join("model.dever"),
target.replace("[\"Final\",\"Editor\"]", "[\"Changed\",\"Editor\"]"),
)
.unwrap();
let changed = compile(&directory.0, "changed");
let rejected = execute(&changed, "demo.account.count", "{}");
assert!(!rejected.status.success());
assert!(
String::from_utf8_lossy(&rejected.stderr)
.contains("applied migration content or order changed")
);
assert_eq!(run(&upgraded, "get", r#"{"key":1}"#), next);
let narrow = target
.replace("Text(1, 8)", "Text(1, 4)")
.replace("default \"O'Reilly\"", "default \"Ok\"");
std::fs::write(source.join("model.dever"), narrow).unwrap();
let narrowed = compile(&directory.0, "narrowed");
assert!(
!execute(&narrowed, "demo.account.count", "{}")
.status
.success()
);
assert_eq!(run(&upgraded, "get", r#"{"key":1}"#), next);
assert_eq!(run(&upgraded, "count", "{}"), 2);
settings(&directory.0, "data/fresh.db");
// 空库的最终表没有历史 name/legacy;执行旧数据 before 会失败,因此验证确实跳过。
assert_eq!(run(&upgraded, "count", "{}"), 1);
assert_eq!(run(&upgraded, "get", r#"{"key":1}"#)["name"], "Seed");
}
fn compile(root: &Path, name: &str) -> std::path::PathBuf {
let project = Project::load(root).unwrap();
let program = project.check().unwrap();
build::build(&program, &project.sources, "main", &root.join(name)).unwrap()
}
fn settings(root: &Path, path: &str) {
std::fs::create_dir_all(root.join("config")).unwrap();
std::fs::write(root.join("config/setting.json"),format!(r#"{{"database":{{"default":{{"type":"sqlite","path":"{path}","max_connections":1,"max_page_size":20}}}}}}"#)).unwrap();
}
const UPGRADE: &str = r#"index(display_name, created_at)
migrate normalize {
drop legacy
before {
sqlite = "UPDATE demo_account_model SET name = ?1 WHERE name = ?2"
postgres = "UPDATE demo_account_model SET name = $1 WHERE name = $2"
parameters = ["Editor","O'Reilly"]
}
after {
sqlite = "UPDATE demo_account_model SET display_name = ?1 WHERE display_name = ?2"
postgres = "UPDATE demo_account_model SET display_name = $1 WHERE display_name = $2"
parameters = ["Final","Editor"]
}
}
seed {
{ display_name = "Seed", amount = 1 }
}
"#;
@@ -0,0 +1,177 @@
use super::*;
use dever_runtime::{
database::{Database, Options, Sql},
task::{Limits, Runtime},
};
// 验收只读取显式测试连接,并独占一个新 schema;失败也清理本次创建的对象。
pub(in crate::application) struct Schema {
database: Database,
runtime: Runtime,
drop_sql: &'static str,
pub connection: serde_json::Value,
}
impl Drop for Schema {
fn drop(&mut self) {
self.runtime
.run(async |scope| {
self.database
.executor()
.await
.unwrap()
.initialize(scope, Sql::same(self.drop_sql))
.await
.unwrap();
})
.unwrap();
self.database.close();
}
}
impl Schema {
pub fn new(
directory: &Path,
name: &'static str,
create_sql: &'static str,
drop_sql: &'static str,
) -> Self {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let settings: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join("config/setting.json")).unwrap())
.unwrap();
let mut connection = settings["database"]["postgres_test"].clone();
let options: Options = serde_json::from_value(connection.clone()).unwrap();
assert_eq!(options.driver, "postgres");
let runtime = Runtime::new(Limits::default()).unwrap();
let database = runtime
.run(async |scope| {
let database = Database::open(directory, options, scope).await.unwrap();
database
.executor()
.await
.unwrap()
.initialize(scope, Sql::same(create_sql))
.await
.unwrap();
database
})
.unwrap();
let mut schema = Schema {
database,
runtime,
drop_sql,
connection: serde_json::Value::Null,
};
let mut url = url::Url::parse(connection["url"].as_str().unwrap()).unwrap();
assert!(
!url.query_pairs().any(|(key, _)| key == "options"),
"test connection must not override search_path"
);
url.query_pairs_mut()
.append_pair("options", &format!("-csearch_path={name}"));
connection["url"] = serde_json::Value::String(url.into());
schema.connection = connection;
schema
}
}
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn postgres_dever_queries_roundtrip_and_rollback() {
let directory = Directory::new();
let schema = Schema::new(
&directory.0,
"dever_native_queries",
"CREATE SCHEMA dever_native_queries",
"DROP SCHEMA dever_native_queries CASCADE",
);
let project = Project::load(&fixture().with_file_name("database")).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("postgres"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
serde_json::to_vec(&serde_json::json!({"database":{"default":schema.connection}})).unwrap(),
)
.unwrap();
let run = |name: &str, input: serde_json::Value| {
let output = execute(&binary, &format!("demo.account.{name}"), &input.to_string());
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"].clone()
};
let first = run("create", serde_json::json!({"name":"雪_%' OR 1=1 --"}));
assert_eq!(&first["uid"].as_str().unwrap()[14..15], "7");
assert_eq!(run("get", serde_json::json!({"id":first["id"]})), first);
assert_eq!(
run("probe", serde_json::json!({"needle":"_%'"}))["contained"],
1
);
assert_eq!(
run("probe", serde_json::json!({"needle":""}))["prefixed"],
1
);
assert_eq!(
run("bulk", serde_json::json!({"first":"one","second":"two"})),
2
);
let recent = run("recent", serde_json::json!({}));
let items = recent["items"].as_array().unwrap();
assert_eq!(items.len(), 3);
assert_eq!(items[0]["rank"], 9);
assert_eq!(items[1]["rank"], 7);
let unique: std::collections::HashSet<_> = items
.iter()
.map(|row| row["uid"].as_str().unwrap())
.collect();
assert_eq!(unique.len(), 3);
let created = run(
"save",
serde_json::json!({"name":"upsert","initial":"first","replacement":"ignored"}),
);
let updated = run(
"save",
serde_json::json!({"name":"upsert","initial":"ignored","replacement":"changed"}),
);
assert_eq!(updated["id"], created["id"]);
assert_eq!(updated["uid"], created["uid"]);
assert_eq!(updated["created_at"], created["created_at"]);
assert_eq!(updated["note"], "changed");
assert_eq!(run("retain", serde_json::json!({"name":"upsert"})), updated);
let page = run("scan", serde_json::json!({"after":null,"size":2}));
assert_eq!(page["has_more"], true);
let next = run("scan", serde_json::json!({"after":page["next"],"size":2}));
assert_eq!(next["has_more"], false);
assert_eq!(next["items"].as_array().unwrap().len(), 2);
assert_ne!(page["items"][0]["id"], next["items"][0]["id"]);
let count = run("count", serde_json::json!({}));
for (command, input) in [
(
"bulk",
serde_json::json!({"first":"rollback","second":"one"}),
),
(
"failed_bulk",
serde_json::json!({"first":"rollback1","second":"rollback2","value":i64::MAX}),
),
("create", serde_json::json!({"name":"embedded\0nul"})),
] {
let failed = execute(
&binary,
&format!("demo.account.{command}"),
&input.to_string(),
);
assert!(!failed.status.success(), "{command}");
assert_eq!(run("count", serde_json::json!({})), count);
}
}
@@ -0,0 +1,246 @@
use super::*;
#[test]
fn conditional_writes_compare_and_modify_in_one_statement() {
let directory = Directory::new();
let project = Project::load(&fixture().with_file_name("database")).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("conditional"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
r#"{"database":{"default":{"type":"sqlite","path":"conditional.db"}}}"#,
)
.unwrap();
let run = |name: &str, input: serde_json::Value| {
let output = execute(&binary, &format!("demo.account.{name}"), &input.to_string());
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"].clone()
};
let first = run("create", serde_json::json!({"name":"original"}));
run("create", serde_json::json!({"name":"other"}));
let input =
serde_json::json!({"id":first["id"],"expected":"original","note":null,"next":"updated"});
assert_eq!(run("compare_name", input.clone()), 1);
assert_eq!(run("compare_name", input), 0);
let updated = run("get", serde_json::json!({"id":first["id"]}));
assert_eq!(updated["name"], "updated");
assert_eq!(updated["note"], "changed");
let invalid = serde_json::json!({"id":first["id"],"expected":"updated","note":"changed","next":"x".repeat(65)});
assert!(
!execute(&binary, "demo.account.compare_name", &invalid.to_string())
.status
.success()
);
assert_eq!(run("get", serde_json::json!({"id":first["id"]})), updated);
assert_eq!(
run("remove_matching", serde_json::json!({"rank":7,"note":null})),
1
);
assert_eq!(
run("remove_matching", serde_json::json!({"rank":7,"note":null})),
0
);
assert_eq!(run("get", serde_json::json!({"id":first["id"]})), updated);
assert_eq!(
run(
"remove_matching",
serde_json::json!({"rank":7,"note":"changed"})
),
1
);
assert_eq!(run("count", serde_json::json!({})), 0);
}
#[test]
fn static_query_predicates_match_literal_text_and_inclusive_ranges() {
let directory = Directory::new();
let project = Project::load(&fixture().with_file_name("database")).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("queries"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
r#"{"database":{"default":{"type":"sqlite","path":"query.db"}}}"#,
)
.unwrap();
let run = |name: &str, input: serde_json::Value| {
let output = execute(&binary, &format!("demo.account.{name}"), &input.to_string());
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"].clone()
};
let names = [
"ab",
"Ab",
"a\0b",
"ab\0",
"前雪尾",
"雪_%' OR 1=1 --",
"excluded",
];
let created: Vec<_> = names
.into_iter()
.map(|name| run("create", serde_json::json!({"name":name})))
.collect();
let recent = run("recent", serde_json::json!({}));
assert_eq!(
recent["items"],
serde_json::json!(created.iter().rev().collect::<Vec<_>>())
);
assert_eq!(recent["has_more"], false);
assert!(recent["next"].is_null());
let mut ordered: Vec<_> = created
.iter()
.filter(|row| row["name"] != "excluded")
.cloned()
.collect();
ordered.sort_by(|left, right| {
left["created_at"]
.as_str()
.cmp(&right["created_at"].as_str())
.then_with(|| right["name"].as_str().cmp(&left["name"].as_str()))
.then_with(|| right["id"].as_i64().cmp(&left["id"].as_i64()))
});
for size in [1, 2, 4, 20] {
let mut after = serde_json::Value::Null;
let mut seen = Vec::new();
loop {
let page = run("scan", serde_json::json!({"after":after,"size":size}));
let items = page["items"].as_array().unwrap();
assert!(items.len() <= size);
seen.extend(items.iter().cloned());
assert!(seen.len() <= ordered.len(), "cursor must advance");
if page["has_more"] == false {
assert!(page["next"].is_null());
break;
}
assert_eq!(page["next"], *items.last().unwrap());
after = page["next"].clone();
}
assert_eq!(seen, ordered);
}
for size in [0, -1, 101] {
assert!(
!execute(
&binary,
"demo.account.scan",
&serde_json::json!({"after":null,"size":size}).to_string()
)
.status
.success()
);
}
for needle in [
"",
"a",
"A",
"ab",
"\0",
"a\0",
"b\0",
"雪",
"%",
"_",
"' OR 1=1 --",
"missing",
] {
let actual = run("probe", serde_json::json!({"needle":needle}));
assert_eq!(
actual,
serde_json::json!({
"contained":names.iter().filter(|name|name.contains(needle)).count(),
"prefixed":names.iter().filter(|name|name.starts_with(needle)).count(),
"suffixed":names.iter().filter(|name|name.ends_with(needle)).count(),
"notes":0
}),
"needle {needle:?}"
);
}
assert_eq!(
run("select", serde_json::json!({"first":"ab","second":"Ab"})),
2
);
assert_eq!(
run("select", serde_json::json!({"first":"ab","second":"ab"})),
1
);
for (lower, upper) in [("Ab", "ab"), ("ab", "ab"), ("ab", "Ab"), ("", "雪")] {
let expected = names
.iter()
.filter(|name| **name >= lower && **name <= upper && **name != "excluded")
.count();
assert_eq!(
run("range", serde_json::json!({"lower":lower,"upper":upper})),
expected
);
}
assert_eq!(
run(
"bulk",
serde_json::json!({"first":"bulk one","second":"bulk two"})
),
2
);
let bulk = run("recent", serde_json::json!({}));
assert_eq!(bulk["items"][0]["rank"], 9);
assert_eq!(bulk["items"][1]["rank"], 7);
let mut unique = std::collections::HashSet::new();
for row in bulk["items"].as_array().unwrap() {
let uid = row["uid"].as_str().unwrap();
assert_eq!(&uid[14..15], "7");
assert!(unique.insert(uid));
}
let before = run("count", serde_json::json!({}));
for (command, input) in [
(
"bulk",
serde_json::json!({"first":"must rollback","second":"ab"}),
),
(
"failed_bulk",
serde_json::json!({"first":"rollback one","second":"rollback two","value":i64::MAX}),
),
] {
let output = execute(
&binary,
&format!("demo.account.{command}"),
&input.to_string(),
);
assert!(!output.status.success(), "{command}");
assert_eq!(run("count", serde_json::json!({})), before);
}
let created = run(
"save",
serde_json::json!({"name":"upsert","initial":"first","replacement":"unused"}),
);
assert_eq!(created["note"], "first");
let updated = run(
"save",
serde_json::json!({"name":"upsert","initial":"ignored","replacement":"changed"}),
);
assert_eq!(updated["id"], created["id"]);
assert_eq!(updated["created_at"], created["created_at"]);
assert_eq!(updated["uid"], created["uid"]);
assert_eq!(updated["note"], "changed");
assert_eq!(run("retain", serde_json::json!({"name":"upsert"})), updated);
}
@@ -0,0 +1,100 @@
use super::*;
fn acceptance(connection: serde_json::Value, name: &str) {
let directory = Directory::new();
let project = Project::load(&fixture().with_file_name("database-read")).unwrap();
let program = project.check().unwrap();
let binary = build::build(&program, &project.sources, "main", &directory.0.join(name)).unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
serde_json::json!({"database":{"default":connection}}).to_string(),
)
.unwrap();
for (command, input, expected) in [
(
"lookup",
serde_json::json!({"value":"alpha"}),
serde_json::json!("alpha"),
),
(
"lookup",
serde_json::json!({"value":"雪' OR 1=1 --"}),
serde_json::json!("雪' OR 1=1 --"),
),
(
"lookup",
serde_json::json!({"value":"unknown"}),
serde_json::json!("missing"),
),
(
"names",
serde_json::json!({"limit":2}),
serde_json::json!([{"name":"alpha"},{"name":"beta"}]),
),
("count", serde_json::json!({}), serde_json::json!(3)),
("early", serde_json::json!({}), serde_json::json!(4)),
("closed", serde_json::json!({}), serde_json::json!(3)),
] {
for _ in 0..3 {
let result = execute(
&binary,
&format!("demo.account.{command}"),
&input.to_string(),
);
assert!(
result.status.success(),
"{command}: {}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"],
expected
);
}
}
for (command, input) in [("names", "{\"limit\":3}"), ("broken", "{}")] {
let result = execute(&binary, &format!("demo.account.{command}"), input);
assert!(!result.status.success());
assert!(
String::from_utf8_lossy(&result.stderr).contains("database operation failed"),
"{command}: {}",
String::from_utf8_lossy(&result.stderr)
);
}
let result = execute(&binary, "demo.account.probe", "{}");
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"],
"InvalidData"
);
}
#[test]
fn typed_sql_and_row_stream_run_with_one_sqlite_connection() {
acceptance(
serde_json::json!({"type":"sqlite","path":"read.db","max_connections":1,"max_page_size":2}),
"read-sqlite",
);
}
#[cfg(feature = "postgres")]
#[test]
#[ignore = "requires isolated database.postgres_test"]
fn typed_sql_and_row_stream_run_with_one_postgres_connection() {
let directory = Directory::new();
let schema = super::postgres::Schema::new(
&directory.0,
"dever_native_read",
"CREATE SCHEMA dever_native_read",
"DROP SCHEMA dever_native_read CASCADE",
);
let mut connection = schema.connection.clone();
connection["max_connections"] = 1.into();
connection["max_page_size"] = 2.into();
acceptance(connection, "read-postgres");
}
@@ -0,0 +1,75 @@
use super::*;
fn acceptance(connection: serde_json::Value, name: &str) {
let directory = Directory::new();
let project = Project::load(&fixture().with_file_name("database-relation")).unwrap();
let program = project.check().unwrap();
let binary = build::build(&program, &project.sources, "main", &directory.0.join(name)).unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
serde_json::json!({"database":{"default":connection}}).to_string(),
)
.unwrap();
let run = |command: &str| {
let result = execute(&binary, &format!("demo.people.{command}"), "{}");
assert!(
result.status.success(),
"{command}: {}",
String::from_utf8_lossy(&result.stderr)
);
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"].clone()
};
assert_eq!(
run("users"),
serde_json::json!(["Ada:Team:2", "Ben:none:1"])
);
assert_eq!(
run("plain"),
serde_json::json!(["Ada:unloaded:-1", "Ben:unloaded:-1"])
);
assert_eq!(run("first"), "Ada:Team:2");
let cursor = run("cursor");
assert_eq!(cursor["names"], serde_json::json!(["Ada:Team:2"]));
assert_eq!(cursor["has_more"], true);
assert_eq!(cursor["next"], "Ada:Team:2");
let mut restrictive = connection;
restrictive["max_page_size"] = 1.into();
std::fs::write(
directory.0.join("config/setting.json"),
serde_json::json!({"database":{"default":restrictive}}).to_string(),
)
.unwrap();
let result = execute(&binary, "demo.people.first", "{}");
assert!(!result.status.success());
assert!(
String::from_utf8_lossy(&result.stderr).contains("database operation failed"),
"{}",
String::from_utf8_lossy(&result.stderr)
);
}
#[test]
fn related_values_join_and_group_with_one_sqlite_connection() {
acceptance(
serde_json::json!({"type":"sqlite","path":"related.db","max_connections":1,"max_page_size":2}),
"related-sqlite",
);
}
#[cfg(feature = "postgres")]
#[test]
#[ignore = "requires isolated database.postgres_test"]
fn related_values_join_and_group_with_one_postgres_connection() {
let directory = Directory::new();
let schema = super::postgres::Schema::new(
&directory.0,
"dever_native_relation",
"CREATE SCHEMA dever_native_relation",
"DROP SCHEMA dever_native_relation CASCADE",
);
let mut connection = schema.connection.clone();
connection["max_connections"] = 1.into();
connection["max_page_size"] = 2.into();
acceptance(connection, "related-postgres");
}
@@ -0,0 +1,258 @@
use super::*;
#[test]
fn database_scalar_source_preserves_exact_values_and_nullable_updates() {
use dever_runtime::value::decimal::Decimal;
let directory = Directory::new();
scalar_fixture(&directory.0);
let project = Project::load(&directory.0).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("scalars"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(directory.0.join("config/setting.json"), r#"{"database":{"default":{"type":"sqlite","path":"data/scalars.db","max_connections":1,"max_page_size":20}}}"#).unwrap();
let run = |name: &str, input: serde_json::Value| {
let output = execute(&binary, &format!("demo.account.{name}"), &input.to_string());
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"].clone()
};
let base = serde_json::json!({
"amount":"1.0000", "price":"999.99", "uid":"00112233-4455-6677-8899-aabbccddeeff",
"day":"0000-01-01", "time":"23:59:59.999", "elapsed":i64::MIN,
"document":{"items":[true,null,{"name":"雪"}]},
"optional_uid":"ffffffff-ffff-ffff-ffff-ffffffffffff", "optional_day":"9999-12-31",
"optional_time":"00:00:00", "optional_elapsed":i64::MAX, "optional_document":{"value":42}
});
let created = run("create", base.clone());
assert_eq!(created["id"], 1);
assert_eq!(created["amount"], "1");
for field in [
"price",
"uid",
"day",
"time",
"elapsed",
"document",
"optional_uid",
"optional_day",
"optional_time",
"optional_elapsed",
"optional_document",
] {
assert_eq!(created[field], base[field], "{field}");
}
assert_eq!(run("get", serde_json::json!({"id":1})), created);
for field in [
"price",
"optional_uid",
"optional_day",
"optional_time",
"optional_elapsed",
"optional_document",
] {
let mut changed = base.clone();
changed["id"] = 1.into();
changed[field] = serde_json::Value::Null;
assert!(run("update", changed)[field].is_null(), "{field}");
}
for (field, value) in [
("amount", "0.00001"),
("amount", "1000000000000000000000000000000"),
("price", "1000"),
("price", "0.001"),
("day", "2025-02-29"),
("time", "24:00:00"),
("uid", "00112233445566778899aabbccddeeff"),
] {
let mut invalid = base.clone();
invalid[field] = value.into();
invalid["uid"] = if field == "uid" {
value
} else {
"11112233-4455-6677-8899-aabbccddeeff"
}
.into();
let failed = execute(&binary, "demo.account.create", &invalid.to_string());
assert!(!failed.status.success(), "accepted {field}: {value}");
assert_eq!(run("count", serde_json::json!({})), 1);
}
let mut changed = base.clone();
changed["id"] = 1.into();
changed["amount"] = "-999999999999999999999999999999.9999".into();
let minimum = run("update", changed.clone());
assert_eq!(minimum["amount"], changed["amount"]);
for (index, amount) in [
"-10",
"-0.0001",
"0",
"0.0001",
"10",
"999999999999999999999999999999.9999",
]
.iter()
.enumerate()
{
let mut value = base.clone();
value["amount"] = (*amount).into();
value["uid"] = format!("00000000-0000-0000-0000-{:012x}", index).into();
value["price"] = serde_json::Value::Null;
let row = run("create", value);
assert_eq!(
Decimal::parse(row["amount"].as_str().unwrap()).unwrap(),
Decimal::parse(amount).unwrap()
);
assert!(row["price"].is_null());
}
let page = run("search", serde_json::json!({"low":"-10"}));
let actual: Vec<_> = page["items"]
.as_array()
.unwrap()
.iter()
.map(|row| Decimal::parse(row["amount"].as_str().unwrap()).unwrap())
.collect();
assert_eq!(
actual,
[
"-10",
"-0.0001",
"0",
"0.0001",
"10",
"999999999999999999999999999999.9999"
]
.map(|text| Decimal::parse(text).unwrap())
);
assert_eq!(
run("price", serde_json::json!({"price":"999.990"}))["total"],
1
);
assert_eq!(run("price", serde_json::json!({}))["total"], 6);
let reused = run(
"document",
serde_json::json!({"id":1,"document":base["document"]}),
);
assert_eq!(reused["document"], base["document"]);
assert_eq!(reused["optional_document"], base["document"]);
assert_eq!(run("get", serde_json::json!({"id":1})), reused);
let raw =
r#"{"id":1,"document":{"precise":123456789012345678901234567890.0010,"tiny":1e-1000}}"#;
let output = execute(&binary, "demo.account.document", raw);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let output = String::from_utf8(output.stdout).unwrap();
assert!(output.contains("123456789012345678901234567890.0010"));
assert!(output.contains("1e-1000"));
let output = execute(&binary, "demo.account.get", r#"{"id":1}"#);
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("123456789012345678901234567890.0010")
);
}
fn scalar_fixture(root: &Path) {
let fields = [
("amount", "Decimal(34, 4)", "Decimal"),
("price", "Decimal(5, 2)?", "Decimal?"),
("uid", "Uuid", "Uuid"),
("day", "Date", "Date"),
("time", "Time", "Time"),
("elapsed", "Duration", "Duration"),
("document", "Json", "Json"),
("optional_uid", "Uuid?", "Uuid?"),
("optional_day", "Date?", "Date?"),
("optional_time", "Time?", "Time?"),
("optional_elapsed", "Duration?", "Duration?"),
("optional_document", "Json?", "Json?"),
];
let root = root.join("module/demo/account");
std::fs::create_dir_all(&root).unwrap();
let columns = fields
.iter()
.map(|(name, kind, _)| {
format!(
"{name}: {kind}{}\n",
if *name == "uid" { " unique" } else { "" }
)
})
.collect::<String>();
std::fs::write(
root.join("model.dever"),
format!("type Account {{\n{columns}}}\n"),
)
.unwrap();
let input = fields
.iter()
.map(|(name, _, kind)| format!("{name}: {kind}"))
.collect::<Vec<_>>()
.join(", ");
let values = fields
.iter()
.map(|(name, _, _)| format!("{name} = {name}"))
.collect::<Vec<_>>()
.join(", ");
let methods = [
(
"create",
input.clone(),
"record: model.Account",
format!("record = model.create({{ {values} }})"),
),
(
"update",
format!("id: model.id, {input}"),
"record: model.Account",
format!("record = model.update(id, {{ {values} }})"),
),
(
"get",
"id: model.id".into(),
"record: model.Account",
"record = model.get(id)".into(),
),
(
"count",
"".into(),
"total: Int",
"total = model.count()".into(),
),
(
"search",
"low: Decimal".into(),
"page: model.Page",
"page = model.list({ where = amount >= low, order = [amount.asc] })".into(),
),
(
"price",
"price: Decimal?".into(),
"page: model.Page",
"page = model.list({ where = price == price })".into(),
),
(
"document",
"id: model.id, document: Json".into(),
"record: model.Account",
"record = model.update(id, { document = document, optional_document = document })\nrecord.document = document".into(),
),
];
let mut app = String::new();
let mut api = String::new();
for (name, inputs, output, body) in methods {
app.push_str(&format!("{name}({inputs}) ({output}) {{\n{body}\n}}\n"));
api.push_str(&format!("cmd {name} = app.{name}\n"));
}
std::fs::write(root.join("app.dever"), app).unwrap();
std::fs::write(root.join("api.dever"), api).unwrap();
}
@@ -0,0 +1,166 @@
use super::*;
#[test]
fn text_fields_normalize_writes_without_changing_queries_or_raw_content() {
let directory = Directory::new();
let source = directory.0.join("module/demo/account");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(
source.join("model.dever"),
r#"type Account {
name: Text(1, 8) lower unique
note: Text? upper
body: Text raw
label: Text default " 默认 "
}
"#,
)
.unwrap();
std::fs::write(source.join("app.dever"), r#"create(name: Text, note: Text?, body: Text) (row: model.Account) {
row = model.create({name = name, note = note, body = body})
}
update(id: model.id, name: Text) (row: model.Account) {
row = model.update(id, {name = name})
}
replace(expected: Text, next: Text) (changed: Int) {
changed = model.update({where = name == expected}, {name = next})
}
lookup(name: Text) (row: model.Account?) {
row = model.first({where = name == name})
}
many() (total: Int) {
total = model.createMany([{name = " B ", note = " ß ", body = " b "}, {name = " C ", note = null, body = " c "}])
}
merge(name: Text, body: Text) (row: model.Account) {
row = model.upsert({name = name}, {note = null, body = body}, {body = body})
}
guard(value: Bool) (ok: Bool) {
dever.api.require(value, dever.api.Error.Forbidden)
ok = contains(["a", "b"], "a") and not contains([], "c")
}
decode(value: Json) (items: List<Text>) {
items = dever.json.decode(value)
}
type Entry {
label: Text
values: List<Int>
}
type Envelope {
entries: List<Entry>
optional: Text?
}
decodeRecord(value: Json) (row: Envelope) {
row = dever.json.decode(value)
}
"#).unwrap();
std::fs::write(source.join("api.dever"), "cmd create = app.create\ncmd update = app.update\ncmd replace = app.replace\ncmd lookup = app.lookup\ncmd many = app.many\ncmd merge = app.merge\ncmd guard = app.guard\ncmd decode = app.decode\ncmd decode_record = app.decodeRecord\n").unwrap();
let project = Project::load(&directory.0).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("text"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
r#"{"database":{"default":{"type":"sqlite","path":"text.db"}}}"#,
)
.unwrap();
let run = |name: &str, input: serde_json::Value| {
let output = execute(&binary, &format!("demo.account.{name}"), &input.to_string());
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"].clone()
};
let row = run(
"create",
serde_json::json!({"name":"\u{2003}Ä\t", "note":" ß ", "body":" \n code\n "}),
);
assert_eq!(row["name"], "ä");
assert_eq!(row["note"], "SS");
assert_eq!(row["body"], " \n code\n ");
assert_eq!(row["label"], "默认");
assert!(run("lookup", serde_json::json!({"name":" ä "})).is_null());
assert_eq!(run("lookup", serde_json::json!({"name":"ä"})), row);
assert_eq!(
run(
"replace",
serde_json::json!({"expected":" ä ","next":" D "})
),
0
);
assert_eq!(
run("replace", serde_json::json!({"expected":"ä","next":" D "})),
1
);
assert_eq!(
run("update", serde_json::json!({"id":row["id"],"name":" E "}))["name"],
"e"
);
assert_eq!(run("many", serde_json::json!({})), 2);
let merged = run("merge", serde_json::json!({"name":" B ","body":" exact "}));
assert_eq!(merged["name"], "b");
assert_eq!(merged["body"], " exact ");
assert!(run("lookup", serde_json::json!({"name":"c"}))["note"].is_null());
for name in [" ", "ABCDEFGHI", " E "] {
assert!(
!execute(
&binary,
"demo.account.create",
&serde_json::json!({"name":name,"note":null,"body":""}).to_string()
)
.status
.success()
);
}
assert_eq!(run("guard", serde_json::json!({"value":true})), true);
assert_eq!(
run("decode", serde_json::json!({"value":["雪", " x "]})),
serde_json::json!(["雪", " x "])
);
assert!(
!execute(&binary, "demo.account.decode", r#"{"value":[1]}"#)
.status
.success()
);
assert!(
!execute(&binary, "demo.account.guard", r#"{"value":false}"#)
.status
.success()
);
let record = serde_json::json!({"entries":[{"label":" x ","values":[1,2]}],"optional":null});
assert_eq!(
run("decode_record", serde_json::json!({"value":record})),
record
);
for record in [
r#"{"entries":[],"extra":1}"#,
r#"{"entries":[{"label":"x","values":[true]}]}"#,
r#"{"optional":null}"#,
] {
let input = format!("{{\"value\":{record}}}");
assert!(
!execute(&binary, "demo.account.decode_record", &input)
.status
.success(),
"{record}"
);
}
}
#[test]
fn owned_trim_preserves_allocation_and_unicode_boundaries() {
for input in ["", "abc", " ", "\u{2003}雪\t", " a\n b ", "\0 x \0"] {
let value = String::from(input);
let allocation = value.as_ptr();
let trimmed = dever_runtime::value::text::trim_owned(value);
assert_eq!(trimmed, input.trim());
assert_eq!(trimmed.as_ptr(), allocation);
}
}