Initial Dever 0.0.1
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user