381 lines
11 KiB
Rust
381 lines
11 KiB
Rust
use std::ffi::OsString;
|
|
use std::fs;
|
|
use std::process::Command;
|
|
|
|
use dever_core::source::SourceMap;
|
|
|
|
const ORGANIZATION: &str = r#"
|
|
type Organization {
|
|
name: Text(1, 64)
|
|
}
|
|
|
|
relation users = app.people.model.user.organization_id
|
|
"#;
|
|
|
|
const USER: &str = r#"
|
|
type User {
|
|
name: Text(1, 64)
|
|
organization_id: app.people.model.organization.id?
|
|
manager_id: app.people.model.user.id?
|
|
}
|
|
|
|
relation posts = app.people.model.post.author_id
|
|
relation other_posts = app.people.model.post.author_id
|
|
relation unloaded_posts = app.people.model.post.author_id
|
|
"#;
|
|
|
|
const POST: &str = r#"
|
|
type Post {
|
|
author_id: app.people.model.user.id
|
|
title: Text(1, 64)
|
|
}
|
|
"#;
|
|
|
|
const APP: &str = r#"
|
|
organization_name(value: Related.Unloaded<app.people.model.organization.Organization?>()) (name: Text) {
|
|
name = "unloaded"
|
|
}
|
|
|
|
organization_name(value: Related.Loaded<app.people.model.organization.Organization?>(organization)) (name: Text) {
|
|
name = nullable_organization_name(organization)
|
|
}
|
|
|
|
nullable_organization_name(organization: null) (name: Text) {
|
|
name = "none"
|
|
}
|
|
|
|
nullable_organization_name(organization: app.people.model.organization.Organization) (name: Text) {
|
|
name = organization.name
|
|
}
|
|
|
|
manager_name(value: Related.Unloaded<app.people.model.user.User?>()) (name: Text) {
|
|
name = "unloaded"
|
|
}
|
|
|
|
manager_name(value: Related.Loaded<app.people.model.user.User?>(manager)) (name: Text) {
|
|
name = nullable_manager_name(manager)
|
|
}
|
|
|
|
nullable_manager_name(manager: null) (name: Text) {
|
|
name = "none"
|
|
}
|
|
|
|
nullable_manager_name(manager: app.people.model.user.User) (name: Text) {
|
|
name = manager.name
|
|
}
|
|
|
|
post_count(value: Related.Unloaded<List<app.people.model.post.Post>>()) (count: Int) {
|
|
count = -1
|
|
}
|
|
|
|
post_count(value: Related.Loaded<List<app.people.model.post.Post>>(posts)) (count: Int) {
|
|
count = length(posts)
|
|
}
|
|
|
|
show(user: app.people.model.user.User) () {
|
|
blocking(dever.io.println(organization_name(user.organization)))
|
|
blocking(dever.io.println(manager_name(user.manager)))
|
|
blocking(dever.io.println(int.to_text(post_count(user.posts))))
|
|
blocking(dever.io.println(int.to_text(post_count(user.other_posts))))
|
|
blocking(dever.io.println(int.to_text(post_count(user.unloaded_posts))))
|
|
}
|
|
|
|
users() (page: app.people.model.user.Page) {
|
|
page = model.user.list({
|
|
order = id.asc
|
|
size = 10
|
|
with = [organization, manager, posts, other_posts]
|
|
})
|
|
}
|
|
|
|
transaction transaction_users() (page: app.people.model.user.Page) {
|
|
page = users()
|
|
}
|
|
|
|
related_first() (item: app.people.model.user.User?) {
|
|
item = model.user.first({
|
|
order = id.asc
|
|
with = [organization, manager, posts]
|
|
})
|
|
}
|
|
|
|
related_cursor() (page: app.people.model.user.Cursor) {
|
|
page = model.user.cursor({
|
|
order = id.asc
|
|
size = 10
|
|
with = [organization, manager, posts]
|
|
})
|
|
}
|
|
|
|
main() () {
|
|
organization = model.organization.create({ name = "Acme" })
|
|
first_user = model.user.create({
|
|
name = "First"
|
|
organization_id = organization.id
|
|
})
|
|
second_user = model.user.create({
|
|
name = "Second"
|
|
manager_id = first_user.id
|
|
})
|
|
first_post = model.post.create({
|
|
author_id = first_user.id
|
|
title = "One"
|
|
})
|
|
second_post = model.post.create({
|
|
author_id = first_user.id
|
|
title = "Two"
|
|
})
|
|
|
|
unloaded = model.user.get(first_user.id)
|
|
blocking(dever.io.println(organization_name(unloaded.organization)))
|
|
|
|
page = transaction_users()
|
|
each(show, page.items)
|
|
}
|
|
"#;
|
|
|
|
fn sources(app: &str) -> SourceMap {
|
|
let mut sources = SourceMap::default();
|
|
sources.add("app/people/model/organization.dever", ORGANIZATION);
|
|
sources.add("app/people/model/user.dever", USER);
|
|
sources.add("app/people/model/post.dever", POST);
|
|
sources.add("app/people/app.dever", app);
|
|
sources
|
|
}
|
|
|
|
fn checked(app: &str) -> (SourceMap, dever_core::hir::Program) {
|
|
let sources = sources(app);
|
|
let program = dever_core::check(&sources).unwrap_or_else(|errors| {
|
|
panic!(
|
|
"{}",
|
|
errors
|
|
.iter()
|
|
.map(|error| error.render(&sources))
|
|
.collect::<String>()
|
|
)
|
|
});
|
|
(sources, program)
|
|
}
|
|
|
|
fn errors(app: &str) -> String {
|
|
let sources = sources(app);
|
|
dever_core::check(&sources)
|
|
.unwrap_err()
|
|
.iter()
|
|
.map(|error| error.render(&sources))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn logical_cross_scope_relations_cannot_be_loaded_implicitly() {
|
|
let mut sources = SourceMap::default();
|
|
sources.add(
|
|
"platform/tenant/model.dever",
|
|
"global type Tenant { name: Text }\n",
|
|
);
|
|
sources.add(
|
|
"news/article/model.dever",
|
|
"type Article { tenant_id: platform.tenant.model.id\n title: Text }\n",
|
|
);
|
|
sources.add(
|
|
"news/article/app.dever",
|
|
"list() (page: model.Page) { page = model.list({ with = [tenant] }) }\n",
|
|
);
|
|
let errors = dever_core::check(&sources).unwrap_err();
|
|
let rendered = errors
|
|
.iter()
|
|
.map(|error| error.render(&sources))
|
|
.collect::<String>();
|
|
assert!(
|
|
rendered.contains("logical cross-scope relation 'tenant' cannot be loaded"),
|
|
"{rendered}",
|
|
);
|
|
}
|
|
|
|
fn configure(native: &dever_core::native::NativeProgram) {
|
|
let root = native.executable().parent().unwrap();
|
|
fs::create_dir_all(root.join("config")).unwrap();
|
|
fs::write(
|
|
root.join("config/setting.json"),
|
|
r#"{
|
|
"database": {
|
|
"default": {
|
|
"type": "sqlite",
|
|
"path": "data/db/app.db",
|
|
"max_connections": 1,
|
|
"max_page_size": 10
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn with_is_static_and_generates_join_and_one_batched_to_many_shape() {
|
|
let source_map = sources(APP);
|
|
let formatted = dever_core::format::format(&source_map.files()[3]).unwrap();
|
|
assert!(formatted.contains("Related.Loaded<app.people.model.user.User?>"));
|
|
checked(&formatted);
|
|
|
|
let (sources, program) = checked(APP);
|
|
let snapshot = program.model_snapshot();
|
|
assert!(!snapshot.contains("field organization "), "{snapshot}");
|
|
assert!(!snapshot.contains("field posts "), "{snapshot}");
|
|
let api = program.api_snapshot();
|
|
assert!(
|
|
api.contains("organization: Related<app.people.model.organization.Organization?>"),
|
|
"{api}"
|
|
);
|
|
assert!(
|
|
api.contains("posts: Related<List<app.people.model.post.Post>>"),
|
|
"{api}"
|
|
);
|
|
|
|
let native = dever_core::native::emit(&program, &sources, "app.people.users").unwrap();
|
|
assert!(
|
|
native.contains("LEFT JOIN \\\"organization\\\""),
|
|
"{native}"
|
|
);
|
|
assert!(
|
|
native.contains("LEFT JOIN \\\"user\\\" AS \\\"_dever_relation_1\\\""),
|
|
"{native}"
|
|
);
|
|
assert!(
|
|
native
|
|
.contains("ROW_NUMBER() OVER (PARTITION BY \\\"author_id\\\" ORDER BY \\\"id\\\" ASC)"),
|
|
"{native}"
|
|
);
|
|
assert!(native.contains(".chunks(998)"), "{native}");
|
|
assert_eq!(
|
|
native.matches("database.query_owned(").count(),
|
|
2,
|
|
"{native}"
|
|
);
|
|
assert!(
|
|
native.contains(
|
|
"sqlite: \"SELECT COUNT(*) FROM \\\"user\\\"\", postgres: \"SELECT COUNT(*) FROM \\\"user\\\"\""
|
|
),
|
|
"{native}"
|
|
);
|
|
|
|
let first = dever_core::native::emit(&program, &sources, "app.people.related_first").unwrap();
|
|
assert!(first.contains("LEFT JOIN \\\"organization\\\""), "{first}");
|
|
assert_eq!(first.matches("database.query_owned(").count(), 1, "{first}");
|
|
|
|
let cursor = dever_core::native::emit(&program, &sources, "app.people.related_cursor").unwrap();
|
|
assert!(
|
|
cursor.contains("LEFT JOIN \\\"organization\\\""),
|
|
"{cursor}"
|
|
);
|
|
assert_eq!(
|
|
cursor.matches("database.query_owned(").count(),
|
|
1,
|
|
"{cursor}"
|
|
);
|
|
assert!(!cursor.contains("COUNT(*)"), "{cursor}");
|
|
}
|
|
|
|
#[test]
|
|
fn relation_batch_sql_reserves_one_parameter_for_the_per_parent_limit() {
|
|
let sql = dever_runtime::database::relation_sql(
|
|
"SELECT * FROM child WHERE parent_id IN (",
|
|
") AND rank <= ",
|
|
" ORDER BY parent_id, id",
|
|
998,
|
|
);
|
|
assert_eq!(sql.sqlite.matches('?').count(), 999, "{}", sql.sqlite);
|
|
assert!(
|
|
sql.sqlite.contains("?998) AND rank <= ?999"),
|
|
"{}",
|
|
sql.sqlite
|
|
);
|
|
assert_eq!(sql.postgres.matches('$').count(), 999, "{}", sql.postgres);
|
|
assert!(
|
|
sql.postgres.contains("$998) AND rank <= $999"),
|
|
"{}",
|
|
sql.postgres
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn with_rejects_unknown_duplicate_and_stream_relations() {
|
|
let unknown = APP.replace("with = [organization, manager, posts]", "with = [missing]");
|
|
assert!(errors(&unknown).contains("unknown Model relation 'missing'"));
|
|
|
|
let duplicate = APP.replace(
|
|
"with = [organization, manager, posts]",
|
|
"with = [organization, organization]",
|
|
);
|
|
assert!(errors(&duplicate).contains("with cannot repeat relation 'organization'"));
|
|
|
|
let stream = APP.replace("page = model.user.list({", "page = model.user.stream({");
|
|
assert!(errors(&stream).contains("unknown query option 'with'"));
|
|
}
|
|
|
|
#[test]
|
|
fn related_patterns_are_typed_exhaustive_and_do_not_relax_regular_cycles() {
|
|
let invalid_payload = APP.replace(
|
|
"main() () {",
|
|
"invalid(value: Related<Int>) () {\n}\n\nmain() () {",
|
|
);
|
|
assert!(
|
|
errors(&invalid_payload)
|
|
.contains("Related payload must be a Model, nullable Model or List<Model>")
|
|
);
|
|
let nullable_list = APP.replace(
|
|
"main() () {",
|
|
"invalid(value: Related<List<app.people.model.user.User>?>) () {\n}\n\nmain() () {",
|
|
);
|
|
assert!(
|
|
errors(&nullable_list)
|
|
.contains("Related payload must be a Model, nullable Model or List<Model>")
|
|
);
|
|
|
|
let missing_unloaded = APP.replace(
|
|
concat!(
|
|
"organization_name(value: Related.Unloaded<",
|
|
"app.people.model.organization.Organization?>()) (name: Text) {\n",
|
|
" name = \"unloaded\"\n}\n\n",
|
|
),
|
|
"",
|
|
);
|
|
assert!(errors(&missing_unloaded).contains("do not cover every input combination"));
|
|
|
|
let recursive = r#"
|
|
type Node {
|
|
child: Node?
|
|
}
|
|
"#;
|
|
assert!(errors(recursive).contains("recursive type definitions are not allowed"));
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_native_loads_requested_relations_and_exposes_related_matching() {
|
|
let (sources, program) = checked(APP);
|
|
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc"));
|
|
let native = dever_core::native::compile_application(
|
|
&program,
|
|
&sources,
|
|
"app.people.main",
|
|
&rustc,
|
|
dever_runtime::config::RuntimeProfile {
|
|
sqlite: true,
|
|
postgres: false,
|
|
},
|
|
)
|
|
.unwrap();
|
|
configure(&native);
|
|
let output = Command::new(native.executable()).output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert_eq!(
|
|
String::from_utf8(output.stdout).unwrap(),
|
|
"unloaded\nAcme\nnone\n2\n2\n-1\nnone\nFirst\n0\n0\n-1\n"
|
|
);
|
|
assert!(output.stderr.is_empty());
|
|
}
|