use serde_json::{json, Map, Value};
use rust_store_core::dialect::{
introspect_to_schema_json, merge_schema, restore_rows_json, translate, Backend,
};
use rust_store_core::pipeline::{build_pipeline, parse_gql};
use rust_store_core::schema::Registry;
fn registry_with(schemas: &[Value]) -> Registry {
let mut r = Registry::new();
for s in schemas {
r.register(s).expect("schema 注册失败");
}
r
}
fn normalize(sql: &str) -> String {
let s = sql.replace(['`', '"'], "");
let mut out = String::with_capacity(s.len());
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '$' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() {
while i < chars.len() && chars[i].is_ascii_digit()
|| (i < chars.len() && chars[i] == '$')
{
i += 1;
}
out.push('?');
continue;
}
out.push(chars[i]);
i += 1;
}
out.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
const SCHEMAS: &str = r#"[
{
"name": "Post", "collection": "posts", "timestamps": false,
"fields": { "title": { "type": "string" }, "status": { "type": "string" }, "views": { "type": "int" } },
"relations": {}
},
{
"name": "Order", "collection": "orders", "timestamps": false,
"fields": { "code": { "type": "string" }, "amount": { "type": "float" } },
"relations": {
"items": { "model": "OrderItem", "type": "many", "localField": "_id", "foreignField": "orderId" }
}
},
{
"name": "OrderItem", "collection": "order_items", "timestamps": false,
"fields": { "orderId": { "type": "string" }, "sku": { "type": "string" }, "qty": { "type": "int" } },
"relations": {}
}
]"#;
fn schemas() -> Vec<Value> {
serde_json::from_str(SCHEMAS).expect("内联 schemas 解析失败")
}
fn assert_sql_parity(name: &str, cmd: &Value) {
let registry = registry_with(&schemas());
let mut base: Option<String> = None;
for backend in [Backend::Mysql, Backend::Postgres, Backend::Sqlite] {
let out = translate(backend, cmd, ®istry).expect(name);
let stmts = out
.get("stmts")
.and_then(|s| s.as_array())
.expect("stmts 数组");
assert_eq!(stmts.len(), 1, "[{}] 应恰一条语句", name);
let text = stmts[0]
.get("text")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
let norm = normalize(&text);
match &base {
None => base = Some(norm),
Some(b) => assert_eq!(&norm, b, "[{}] 跨后端 SQL 不一致", name),
}
}
}
#[test]
fn dialect_cross_backend_sql_parity() {
assert_sql_parity(
"find-simple",
&json!({ "kind": "find", "collection": "posts",
"filter": { "status": "draft" },
"projection": { "title": 1, "status": 1 } }),
);
assert_sql_parity(
"find-comparison",
&json!({ "kind": "find", "collection": "posts",
"filter": { "views": { "$gte": 10 }, "status": "draft" },
"projection": null }),
);
assert_sql_parity(
"count",
&json!({ "kind": "countDocuments", "collection": "posts",
"filter": { "status": "draft" } }),
);
assert_sql_parity(
"insert-one",
&json!({ "kind": "insertOne", "collection": "posts",
"doc": { "title": "hello", "status": "draft", "views": 5 } }),
);
assert_sql_parity(
"insert-many",
&json!({ "kind": "insertMany", "collection": "posts",
"docs": [
{ "title": "a", "status": "draft", "views": 1 },
{ "title": "b", "status": "draft", "views": 2 }
] }),
);
}
#[test]
fn dialect_physical_name_translation() {
let schemas: Vec<Value> = serde_json::from_str(
r#"[{
"name": "OrderItem", "collection": "order_items", "timestamps": false,
"fields": { "orderTotal": { "type": "number" }, "sku": { "type": "string" } },
"relations": {}
}]"#,
)
.expect("schemas 解析失败");
let registry = registry_with(&schemas);
let my = translate(
Backend::Mysql,
&json!({ "kind": "find", "collection": "order_items", "database": "shopdb",
"projection": { "orderTotal": 1 } }),
®istry,
)
.expect("mysql translate");
let my_text = my["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
my_text.contains("`order_total`") && my_text.contains("`shopdb`.`order_items`"),
"MySQL 应发射物理列名与 database 限定: {my_text}"
);
let pg = translate(
Backend::Postgres,
&json!({ "kind": "find", "collection": "order_items", "database": "shopdb",
"schema": "sales", "projection": { "orderTotal": 1 } }),
®istry,
)
.expect("pg translate");
let pg_text = pg["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
pg_text.contains("\"order_total\"") && pg_text.contains("\"sales\".\"order_items\""),
"PG 应发射物理列名与 schema 限定: {pg_text}"
);
assert!(
!pg_text.contains("shopdb"),
"PG 禁跨库限定(不得出现 database): {pg_text}"
);
let lite = translate(
Backend::Sqlite,
&json!({ "kind": "find", "collection": "order_items",
"projection": { "orderTotal": 1 } }),
®istry,
)
.expect("sqlite translate");
let lite_text = lite["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
lite_text.contains("\"order_total\""),
"SQLite 应发射物理列名: {lite_text}"
);
}
#[test]
fn dialect_aggregate_lookup_sql_parity() {
assert_sql_parity(
"aggregate-lookup",
&json!({ "kind": "aggregate", "collection": "orders", "pipeline": [
{ "$match": { "amount": { "$gt": 0 } } },
{ "$lookup": { "from": "order_items", "localField": "_id",
"foreignField": "orderId", "as": "items" } },
{ "$sort": { "code": 1 } }
] }),
);
}
#[test]
fn dialect_translate_supported_aggregate_has_no_unsupported() {
let registry = registry_with(&schemas());
let out = translate(
Backend::Sqlite,
&json!({ "kind": "aggregate", "collection": "orders", "pipeline": [
{ "$lookup": { "from": "order_items", "localField": "_id",
"foreignField": "orderId", "as": "items" } }
] }),
®istry,
)
.expect("translate");
assert_eq!(
out.get("unsupported")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(0),
"无子 limit 的 $lookup 不应产生 unsupported: {}",
out
);
}
#[test]
fn dialect_translate_child_limit_pushed_down_as_window() {
let registry = registry_with(&schemas());
let mut base: Option<String> = None;
for backend in [Backend::Mysql, Backend::Postgres, Backend::Sqlite] {
let out = translate(
backend,
&json!({ "kind": "aggregate", "collection": "orders", "pipeline": [
{ "$lookup": { "from": "order_items", "as": "items",
"let": { "rel__id": "$_id" },
"pipeline": [
{ "$match": { "$expr": { "$eq": ["$orderId", "$$rel__id"] } } },
{ "$sort": { "qty": -1 } },
{ "$limit": 3 }
] } }
] }),
®istry,
)
.expect("translate");
assert_eq!(
out.get("unsupported")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(0),
"[{:?}] 子 limit 应已下推(无 unsupported): {}",
backend,
out
);
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("ROW_NUMBER() OVER (PARTITION BY"),
"[{:?}] 应下推为窗口函数: {}",
backend,
text
);
assert!(
text.contains("JOIN"),
"[{:?}] 应下推子表 JOIN: {}",
backend,
text
);
let norm = normalize(text);
match &base {
None => base = Some(norm),
Some(b) => assert_eq!(&norm, b, "[{:?}] 跨后端窗口下推 SQL 不一致", backend),
}
}
}
#[test]
fn dialect_restore_rows_from_flat_lines() {
let registry = registry_with(&schemas());
let out = translate(
Backend::Sqlite,
&json!({ "kind": "aggregate", "collection": "orders", "pipeline": [
{ "$lookup": { "from": "order_items", "localField": "_id",
"foreignField": "orderId", "as": "items" } }
] }),
®istry,
)
.expect("translate");
let stmts = out.get("stmts").and_then(|s| s.as_array()).expect("stmts");
let shape = stmts[0].get("rowShape").cloned().expect("rowShape");
let rows = json!([
{ "_id": "A", "code": "A-1", "items_0_sku": "sku-a", "items_0_qty": 2 },
{ "_id": "A", "code": "A-1", "items_0_sku": "sku-b", "items_0_qty": 3 },
{ "_id": "B", "code": "B-1", "items_0_sku": null, "items_0_qty": null },
]);
let restored = restore_rows_json(&shape, &rows).expect("restore");
let arr = restored.as_array().expect("还原应为数组");
assert_eq!(arr.len(), 2, "应有两条订单: {}", restored);
let has_a1 = arr.iter().any(|d| {
d.get("items")
.and_then(|v| v.as_array())
.map(|a| a.len() == 2)
.unwrap_or(false)
});
assert!(has_a1, "A-1 应还原出 2 个 items: {}", restored);
let has_b1 = arr
.iter()
.any(|d| d.get("code").and_then(|v| v.as_str()) == Some("B-1"));
assert!(has_b1, "B-1 应被还原: {}", restored);
}
#[test]
fn dialect_restore_rows_roundtrip_supports_is_array() {
let shape = json!({
"columns": [
{ "alias": "_id", "path": ["_id"], "isArray": false, "subShape": null },
{ "alias": "code", "path": ["code"], "isArray": false, "subShape": null },
{ "alias": "it_0_sku","path": ["items","sku"],"isArray": true, "subShape": null }
]
});
let rows = json!([
{ "_id": "1", "code": "A", "it_0_sku": "x" },
{ "_id": "1", "code": "A", "it_0_sku": "y" },
]);
let restored = restore_rows_json(&shape, &rows).expect("restore");
let arr = restored.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(
arr[0]
.get("items")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0),
2
);
}
#[test]
fn dialect_introspection_to_schema_json() {
let rows = json!({
"tables": [ { "name": "posts" }, { "name": "order_items" } ],
"columns": [
{ "table": "posts", "name": "_id", "type": "TEXT", "notnull": 1, "pk": 1 },
{ "table": "posts", "name": "title", "type": "TEXT", "notnull": 0, "pk": 0 },
{ "table": "posts", "name": "views", "type": "INTEGER", "notnull": 0, "pk": 0 },
{ "table": "order_items", "name": "_id", "type": "TEXT", "notnull": 1, "pk": 1 },
{ "table": "order_items", "name": "order_id", "type": "TEXT", "notnull": 1, "pk": 0 },
{ "table": "order_items", "name": "sku", "type": "TEXT", "notnull": 0, "pk": 0 }
],
"fks": [
{ "table": "order_items", "column": "order_id", "refTable": "posts", "refColumn": "_id" }
],
"indexes": []
});
let schema = introspect_to_schema_json(&rows, &Backend::Sqlite).expect("introspect");
let arr = schema.as_array().expect("schema 数组");
let posts = arr
.iter()
.find(|d| d.get("name").and_then(|v| v.as_str()) == Some("posts"))
.expect("posts def");
let views = posts
.get("fields")
.and_then(|f| f.get("views").and_then(|v| v.get("type")))
.and_then(|v| v.as_str());
assert_eq!(views, Some("number"), "views 应为 number");
let oi = arr
.iter()
.find(|d| d.get("name").and_then(|v| v.as_str()) == Some("order_items"))
.expect("order_items def");
let rel = oi
.get("relations")
.and_then(|r| r.get("posts"))
.expect("posts 关系");
assert_eq!(rel.get("type").and_then(|v| v.as_str()), Some("one"));
let rels = posts
.get("relations")
.and_then(|r| r.as_object())
.expect("posts relations");
assert!(
rels.contains_key("order_items"),
"posts 应反向含 order_items 关系: {:#?}",
rels
);
}
#[test]
fn dialect_overlay_merge_compute_and_permission() {
let base = json!([
{ "name": "posts", "collection": "posts", "fields": { "title": { "type": "string" } }, "relations": {} }
]);
let overlay = json!([
{ "name": "posts",
"fields": { "views": { "type": "int", "default": 0 } },
"computes": { "slug": { "fn": true, "depends": ["title"] } },
"read": { "roles": ["admin", "editor"] } }
]);
let merged = merge_schema(&base, &overlay).expect("merge");
let def = merged.as_array().unwrap().first().unwrap();
let computes = def.get("computes").expect("computes 存在");
assert!(computes.get("slug").is_some());
let fields = def
.get("fields")
.and_then(|f| f.as_object())
.expect("fields");
assert!(fields.contains_key("title") && fields.contains_key("views"));
assert_eq!(
def.get("read")
.and_then(|r| r.get("roles").and_then(|v| v.as_array()).map(|a| a.len())),
Some(2)
);
}
#[test]
fn dialect_aggregate_skip_without_limit_uses_backend_idiom() {
let registry = registry_with(&schemas());
let cmd =
json!({ "kind": "aggregate", "collection": "posts", "pipeline": [ { "$skip": 10 } ] });
let pg = translate(Backend::Postgres, &cmd, ®istry).expect("pg translate");
let pg_text = pg["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
pg_text.contains(" OFFSET $1"),
"PG 应为裸 OFFSET: {pg_text}"
);
assert!(
!pg_text.contains("LIMIT"),
"PG 不得出现 LIMIT(更不得出现负 LIMIT): {pg_text}"
);
let my = translate(Backend::Mysql, &cmd, ®istry).expect("mysql translate");
let my_text = my["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
my_text.contains("LIMIT 18446744073709551615 OFFSET ?"),
"MySQL 应为大数 LIMIT 惯用法: {my_text}"
);
let lite = translate(Backend::Sqlite, &cmd, ®istry).expect("sqlite translate");
let lite_text = lite["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
lite_text.contains("LIMIT -1 OFFSET ?"),
"SQLite LIMIT -1 合法: {lite_text}"
);
}
#[test]
fn translate_regex_with_options_i_backend_semantics() {
let registry = registry_with(&schemas());
let cmd = json!({ "kind": "find", "collection": "posts",
"filter": { "title": { "$regex": "abc", "$options": "i" } } });
let pg = translate(Backend::Postgres, &cmd, ®istry).expect("pg translate");
let pg_text = pg["stmts"][0]["text"].as_str().unwrap_or("");
assert!(pg_text.contains("~*"), "PG 应翻译为 ~*: {pg_text}");
assert!(
pg.get("warnings")
.and_then(|w| w.as_array())
.map(|a| a.is_empty())
.unwrap_or(false),
"PG 'i' 可表达,不应有告警: {pg}"
);
let my = translate(Backend::Mysql, &cmd, ®istry).expect("mysql translate");
let my_text = my["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
my_text.contains("REGEXP_LIKE") && my_text.contains("'i'"),
"MySQL 应翻译为 REGEXP_LIKE(col, ?, 'i'): {my_text}"
);
let lite = translate(Backend::Sqlite, &cmd, ®istry).expect("sqlite translate");
let lite_text = lite["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
lite_text.contains("REGEXP"),
"SQLite 应保留 REGEXP 条件: {lite_text}"
);
let warns = lite
.get("warnings")
.and_then(|w| w.as_array())
.expect("warnings 数组");
assert_eq!(warns.len(), 1, "SQLite + 'i' 应恰一条告警: {lite}");
assert!(
warns[0].as_str().unwrap_or("").contains("i"),
"告警应指明无法表达的 flags: {warns:?}"
);
}
#[test]
fn translate_write_rejects_unexpressible_regex_options() {
let registry = registry_with(&schemas());
let cmd = json!({ "kind": "deleteMany", "collection": "posts",
"filter": { "title": { "$regex": "x", "$options": "i" } } });
let err = translate(Backend::Sqlite, &cmd, ®istry)
.expect_err("SQLite + $options 'i' 在写路径必须显式报错");
assert!(err.contains("$options"), "错误应指明 $options: {err}");
let pg = translate(Backend::Postgres, &cmd, ®istry).expect("pg translate");
let pg_text = pg["stmts"][0]["text"].as_str().unwrap_or("");
assert!(pg_text.contains("~*"), "PG 写路径应正常翻译 ~*: {pg_text}");
}
#[test]
fn dialect_introspection_non_id_pk_has_no_phantom_field() {
let rows = json!({
"tables": [ { "name": "users" } ],
"columns": [
{ "table": "users", "name": "uid", "type": "TEXT", "notnull": 1, "pk": 1 },
{ "table": "users", "name": "name", "type": "TEXT", "notnull": 0, "pk": 0 }
],
"fks": [],
"indexes": []
});
let schema = introspect_to_schema_json(&rows, &Backend::Sqlite).expect("introspect");
let def = schema
.as_array()
.and_then(|a| a.first())
.expect("users def");
let fields = def
.get("fields")
.and_then(|f| f.as_object())
.expect("fields 对象");
assert!(
!fields.contains_key("__pk_col"),
"不得产出 __pk_col 幻影字段: {fields:?}"
);
assert!(
fields.contains_key("_id"),
"主键仍应映射为 _id 字段: {fields:?}"
);
assert!(fields.contains_key("name"), "普通字段不受影响: {fields:?}");
}
#[test]
fn dialect_rejects_non_object_filter() {
let registry = registry_with(&schemas());
let err = translate(
Backend::Postgres,
&json!({ "kind": "deleteMany", "collection": "posts", "filter": "oops" }),
®istry,
)
.expect_err("非对象 filter 必须报错(不得静默全表删)");
assert!(err.contains("filter"), "错误应指明 filter: {err}");
for cmd in [
json!({ "kind": "find", "collection": "posts", "filter": 5 }),
json!({ "kind": "countDocuments", "collection": "posts", "filter": [1, 2] }),
json!({ "kind": "updateMany", "collection": "posts", "filter": true,
"update": { "$set": { "status": "x" } } }),
] {
translate(Backend::Postgres, &cmd, ®istry).expect_err("非对象 filter 必须报错");
}
let out = translate(
Backend::Postgres,
&json!({ "kind": "find", "collection": "posts", "filter": null }),
®istry,
)
.expect("null filter 应放行");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
!text.contains("WHERE"),
"null filter 不应产生 WHERE: {text}"
);
}
#[test]
fn dialect_filter_logical_group_keeps_sibling_conditions() {
let registry = registry_with(&schemas());
let cmd = json!({ "kind": "find", "collection": "posts",
"filter": { "status": "draft", "$and": [ { "views": { "$gt": 1 } } ] } });
let out = translate(Backend::Postgres, &cmd, ®istry).expect("translate");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("t.\"views\" > $1") && text.contains("t.\"status\" = $2"),
"兄弟条件 status 不得被丢弃: {text}"
);
assert_eq!(
out["stmts"][0]["params"].as_array().map(|a| a.len()),
Some(2),
"应有两个绑定参数: {out}"
);
let cmd = json!({ "kind": "find", "collection": "posts",
"filter": { "status": "draft",
"$or": [ { "views": { "$gt": 1 } }, { "views": { "$lt": 5 } } ] } });
let out = translate(Backend::Postgres, &cmd, ®istry).expect("translate");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("OR") && text.contains("t.\"status\""),
"$or 与兄弟字段应同时生效: {text}"
);
assert_eq!(
out["stmts"][0]["params"].as_array().map(|a| a.len()),
Some(3),
"应为三个绑定参数: {out}"
);
let cmd = json!({ "kind": "find", "collection": "posts",
"filter": { "status": "draft", "$nor": [ { "views": { "$gt": 1 } } ] } });
let out = translate(Backend::Postgres, &cmd, ®istry).expect("translate");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("NOT") && text.contains("t.\"status\""),
"$nor 与兄弟字段应同时生效: {text}"
);
}
#[test]
fn dialect_insert_many_merges_heterogeneous_columns() {
let registry = registry_with(&schemas());
let cmd = json!({ "kind": "insertMany", "collection": "posts",
"docs": [ { "title": "a" }, { "title": "b", "views": 9 } ] });
assert_sql_parity("insert-many-heterogeneous", &cmd);
let out = translate(Backend::Postgres, &cmd, ®istry).expect("translate");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("\"title\"") && text.contains("\"views\""),
"列集应为并集(含仅次篇文档才有的 views): {text}"
);
let params = out["stmts"][0]["params"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(
params.contains(&json!(9)),
"第二篇文档的 views=9 不得丢失: {params:?}"
);
}
#[test]
fn dialect_aggregate_sort_by_relation_uses_join_alias() {
let registry = registry_with(&schemas());
let out = translate(
Backend::Postgres,
&json!({ "kind": "aggregate", "collection": "orders", "pipeline": [
{ "$lookup": { "from": "order_items", "localField": "_id",
"foreignField": "orderId", "as": "items" } },
{ "$sort": { "items.qty": -1 } }
] }),
®istry,
)
.expect("translate");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("ORDER BY r0.\"qty\" DESC"),
"关系排序应映射到 JOIN 别名: {text}"
);
assert!(
!text.contains("items.qty"),
"不得产出无效列 t.\"items.qty\": {text}"
);
assert_eq!(
out["unsupported"].as_array().map(|a| a.len()),
Some(0),
"可下推的关系排序不应产生 unsupported: {out}"
);
let out = translate(
Backend::Postgres,
&json!({ "kind": "aggregate", "collection": "orders",
"pipeline": [ { "$sort": { "items.qty": -1 } } ] }),
®istry,
)
.expect("translate");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(!text.contains("items.qty"), "不得产出无效列: {text}");
let unsupported = out["unsupported"].as_array().cloned().unwrap_or_default();
assert_eq!(unsupported.len(), 1, "应标记 1 条 unsupported: {out}");
assert_eq!(
unsupported[0].get("code").and_then(|v| v.as_str()),
Some("sortField"),
"code 应为 sortField: {out}"
);
assert!(
!out["warnings"]
.as_array()
.cloned()
.unwrap_or_default()
.is_empty(),
"应同时给出 warning: {out}"
);
let out = translate(
Backend::Postgres,
&json!({ "kind": "aggregate", "collection": "orders",
"pipeline": [ { "$sort": { "amount": -1 } } ] }),
®istry,
)
.expect("translate");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("ORDER BY t.\"amount\" DESC"),
"标量排序应保持: {text}"
);
}
#[test]
fn dialect_aggregate_group_sql_parity() {
let registry = registry_with(&schemas());
let cmd = json!({ "kind": "aggregate", "collection": "posts", "pipeline": [
{ "$match": { "status": "draft" } },
{ "$group": { "_id": "$status", "n": { "$sum": 1 }, "total": { "$sum": "$views" } } },
{ "$project": { "_id": 0, "n": 1, "status": "$_id", "total": 1 } }
] });
for backend in [Backend::Mysql, Backend::Postgres, Backend::Sqlite] {
let out = translate(backend, &cmd, ®istry).expect("group translate");
assert_eq!(
out["unsupported"].as_array().map(|a| a.len()),
Some(0),
"[{backend:?}] 可下推的分组聚合不应产生 unsupported: {out}"
);
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("COUNT(*)"),
"[{backend:?}] 应为 COUNT(*): {text}"
);
assert!(
text.contains("GROUP BY t.\"status\"") || text.contains("GROUP BY t.`status`"),
"[{backend:?}] 应按分组键 GROUP BY: {text}"
);
assert!(
text.contains("SUM("),
"[{backend:?}] 应为 SUM 聚合列: {text}"
);
}
assert_sql_parity("aggregate-group", &cmd);
let out = translate(Backend::Sqlite, &cmd, ®istry).expect("group translate");
let shape = out["stmts"][0]["rowShape"].clone();
let rows = json!([
{ "b0": "draft", "a0": 3, "a1": 42 },
{ "b0": "published", "a0": 7, "a1": null },
]);
let restored = restore_rows_json(&shape, &rows).expect("restore");
let arr = restored.as_array().expect("数组");
assert_eq!(arr.len(), 2, "分组结果每行一篇文档: {restored}");
assert_eq!(arr[0].get("status"), Some(&json!("draft")));
assert_eq!(arr[0].get("n"), Some(&json!(3)));
assert_eq!(arr[1].get("total"), Some(&Value::Null), "{restored}");
}
#[test]
fn dialect_aggregate_group_having_sort_limit() {
let registry = registry_with(&schemas());
let cmd = json!({ "kind": "aggregate", "collection": "posts", "pipeline": [
{ "$group": { "_id": "$status", "n": { "$sum": 1 }, "total": { "$sum": "$views" } } },
{ "$match": { "n": { "$gt": 1 } } },
{ "$sort": { "total": -1 } },
{ "$limit": 10 },
{ "$project": { "_id": 0, "status": "$_id", "n": 1, "total": 1 } }
] });
for backend in [Backend::Mysql, Backend::Postgres, Backend::Sqlite] {
let out = translate(backend, &cmd, ®istry).expect("group having translate");
assert_eq!(
out["unsupported"].as_array().map(|a| a.len()),
Some(0),
"[{backend:?}] {out}"
);
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(text.contains("HAVING COUNT(*) >"), "[{backend:?}] {text}");
assert!(text.contains("ORDER BY SUM("), "[{backend:?}] {text}");
assert!(text.contains("DESC"), "[{backend:?}] {text}");
assert!(text.contains("LIMIT"), "[{backend:?}] {text}");
}
assert_sql_parity("aggregate-group-having", &cmd);
}
#[test]
fn dialect_group_having_sort_by_key_is_supported() {
let registry = registry_with(&schemas());
let cmd = json!({ "kind": "aggregate", "collection": "posts", "pipeline": [
{ "$group": { "_id": "$status", "n": { "$sum": 1 } } },
{ "$match": { "_id": { "$eq": "published" } } },
{ "$sort": { "_id": 1 } },
{ "$project": { "_id": 0, "status": "$_id", "n": 1 } }
] });
for backend in [Backend::Mysql, Backend::Postgres, Backend::Sqlite] {
let out = translate(backend, &cmd, ®istry).expect("group having by-key translate");
assert_eq!(
out["unsupported"].as_array().map(|a| a.len()),
Some(0),
"[{backend:?}] {out}"
);
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("HAVING t."),
"[{backend:?}] HAVING 应引用分组键列: {text}"
);
assert!(
text.contains("ORDER BY t."),
"[{backend:?}] ORDER BY 应引用分组键列: {text}"
);
}
assert_sql_parity("aggregate-group-having-by-key", &cmd);
}
#[test]
fn dialect_aggregate_group_all_rows() {
let registry = registry_with(&schemas());
let cmd = json!({ "kind": "aggregate", "collection": "posts", "pipeline": [
{ "$group": { "_id": null, "n": { "$sum": 1 }, "total": { "$sum": "$views" } } },
{ "$facet": { "__rows": [] } },
{ "$replaceRoot": { "newRoot": { "$ifNull": [
{ "$arrayElemAt": ["$__rows", 0] },
{ "_id": null, "n": 0, "total": null }
] } } },
{ "$project": { "_id": 0, "n": 1, "total": 1 } }
] });
for backend in [Backend::Mysql, Backend::Postgres, Backend::Sqlite] {
let out = translate(backend, &cmd, ®istry).expect("group all-rows translate");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
!text.to_uppercase().contains("GROUP BY"),
"[{backend:?}] 全表单组不应有 GROUP BY: {text}"
);
assert!(text.contains("COUNT(*)"), "[{backend:?}] {text}");
}
assert_sql_parity("aggregate-group-all-rows", &cmd);
}
#[test]
fn dialect_group_object_dotted_key_is_rejected() {
let registry = registry_with(&[json!({
"name": "Course", "collection": "courses", "timestamps": false,
"fields": {
"status": { "type": "string" },
"meta": { "type": "object", "fields": { "level": { "type": "string" } } }
},
"relations": {}
})]);
let cmd = json!({ "kind": "aggregate", "collection": "courses", "pipeline": [
{ "$group": { "_id": { "meta": { "level": "$meta.level" } },
"n": { "$sum": 1 } } },
{ "$project": { "_id": 0, "meta.level": "$_id.meta.level", "n": 1 } }
] });
let err = translate(Backend::Postgres, &cmd, ®istry)
.expect_err("object 点号路径分组键在 SQL 侧必须显式报错");
assert!(err.contains("meta.level"), "错误应指明分组键: {err}");
}
#[test]
fn dialect_user_pipeline_stage_is_explicit_error_on_sql() {
let registry = registry_with(&schemas());
let mut ast = parse_gql("Post($pipeline:@p0){ title }").expect("parse_gql");
let params: Map<String, Value> =
serde_json::from_str(r#"{ "p0": [ { "$addFields": { "x": 1 } } ] }"#).expect("params 解析");
let pipeline = build_pipeline(&mut ast, ¶ms, ®istry, None)
.expect("standard 档 `$pipeline` 直通应放行");
let stages = pipeline.as_array().cloned().expect("应为阶段数组");
assert_eq!(
stages[0],
json!({ "$addFields": { "x": 1 } }),
"用户阶段应原样进 pipeline(Mongo 源执行)"
);
let cmd = json!({ "kind": "aggregate", "collection": "posts", "pipeline": stages });
for backend in [Backend::Mysql, Backend::Postgres, Backend::Sqlite] {
let err =
translate(backend, &cmd, ®istry).expect_err("SQL 侧应显式报错(不做静默忽略)");
assert!(
err.contains("$addFields"),
"[{backend:?}] 错误应点明不支持阶段: {err}"
);
}
}
#[test]
fn dialect_group_dotted_key_restores_nested_object() {
let shape = json!({
"columns": [
{ "alias": "b0", "path": ["status"], "isArray": false, "always": true },
{ "alias": "b1", "path": ["meta","level"], "isArray": false, "always": true },
{ "alias": "a0", "path": ["n"], "isArray": false, "always": true }
],
"present": ""
});
let rows = json!([
{ "b0": "draft", "b1": "gold", "a0": 2 },
{ "b0": "draft", "b1": null, "a0": 5 },
]);
let restored = restore_rows_json(&shape, &rows).expect("restore");
let arr = restored.as_array().unwrap();
assert_eq!(arr.len(), 2, "每行一篇: {restored}");
assert_eq!(
arr[0].get("meta").and_then(|m| m.get("level")),
Some(&json!("gold")),
"点号路径应还原为嵌套对象: {restored}"
);
assert_eq!(
arr[1].get("meta").and_then(|m| m.get("level")),
Some(&Value::Null),
"by 键为 null 时也应输出该键(always): {restored}"
);
}
fn rp_cmd(model: &str, collection: &str, c0: Value) -> Value {
let registry = registry_with(&schemas());
let mut ast = parse_gql(&format!("{model}($condition:@c0){{ _id }}")).expect("GQL 解析失败");
let mut params = Map::new();
params.insert("c0".to_string(), c0);
let pipeline = build_pipeline(&mut ast, ¶ms, ®istry, None).expect("build_pipeline");
json!({ "kind": "aggregate", "collection": collection, "pipeline": pipeline })
}
#[test]
fn dialect_relation_predicate_count_exists() {
let cmd = rp_cmd(
"Order",
"orders",
json!({ "items": { "$count": { "$gt": 3 } } }),
);
for backend in [Backend::Mysql, Backend::Postgres, Backend::Sqlite] {
let out = translate(backend, &cmd, ®istry_with(&schemas())).expect("translate");
assert_eq!(
out["unsupported"].as_array().map(|a| a.len()),
Some(0),
"[{backend:?}] 关系谓词应完全下推: {out}"
);
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
let norm = normalize(text);
assert!(
norm.contains("exists (select 1 from order_items c where c.order_id = t._id"),
"[{backend:?}] 应为 EXISTS 相关子查询: {text}"
);
assert!(
norm.contains("group by c.order_id having count(*) > ?"),
"[{backend:?}] 应为 GROUP BY + HAVING: {text}"
);
assert!(
!norm.contains("join"),
"[{backend:?}] semi-join 不得扇出为 JOIN: {text}"
);
let params = out["stmts"][0]["params"]
.as_array()
.cloned()
.unwrap_or_default();
assert_eq!(params, vec![json!(3)], "[{backend:?}] {out}");
}
}
#[test]
fn dialect_relation_predicate_filter_sum_having() {
let cmd = rp_cmd(
"Order",
"orders",
json!({ "items": {
"filter": { "sku": "x" },
"agg": { "s": { "$sum": "qty" } },
"having": { "s": { "$gt": 10 } }
} }),
);
for backend in [Backend::Mysql, Backend::Postgres, Backend::Sqlite] {
let out = translate(backend, &cmd, ®istry_with(&schemas())).expect("translate");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
let norm = normalize(text);
assert!(
norm.contains("c.sku = ?"),
"[{backend:?}] 子 filter 应下推为子查询 WHERE: {text}"
);
assert!(
norm.contains("having sum(c.qty) > ?"),
"[{backend:?}] having 应为 HAVING SUM: {text}"
);
let params = out["stmts"][0]["params"]
.as_array()
.cloned()
.unwrap_or_default();
assert_eq!(params, vec![json!("x"), json!(10)], "[{backend:?}] {out}");
}
}
#[test]
fn dialect_relation_predicate_anti_join_not_exists() {
for c0 in [
json!({ "$not": { "items": { "$count": { "$gt": 3 } } } }),
json!({ "items": { "$exists": false } }),
] {
let cmd = rp_cmd("Order", "orders", c0.clone());
for backend in [Backend::Mysql, Backend::Postgres, Backend::Sqlite] {
let out = translate(backend, &cmd, ®istry_with(&schemas())).expect("translate");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
let norm = normalize(text);
assert!(
norm.contains("not exists (select 1 from order_items c"),
"[{backend:?}] 应为 NOT EXISTS: {text}"
);
assert!(
!norm.contains("not not exists"),
"[{backend:?}] 不得双重否定: {text}"
);
}
}
}
#[test]
fn dialect_relation_predicate_with_scalar_sibling() {
let cmd = rp_cmd(
"Order",
"orders",
json!({ "$and": [ { "code": "A" }, { "items": { "$exists": true } } ] }),
);
let out = translate(Backend::Postgres, &cmd, ®istry_with(&schemas())).expect("translate");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("t.\"code\" = $1") && text.contains("EXISTS (SELECT 1"),
"标量条件与 EXISTS 应 AND 并存: {text}"
);
assert_eq!(
out["stmts"][0]["params"],
json!(["A", 0]),
"标量参数在前、EXISTS 的 HAVING 参数在后: {out}"
);
}
const RP_JSON_SCHEMAS: &str = r#"[
{
"name": "Order", "collection": "orders", "timestamps": false,
"fields": { "code": { "type": "string" } },
"relations": {
"items": { "model": "OrderItem", "type": "many", "localField": "_id", "foreignField": "orderId" }
}
},
{
"name": "OrderItem", "collection": "order_items", "timestamps": false,
"fields": {
"orderId": { "type": "string" },
"tags": { "type": "array" },
"meta": { "type": "object", "fields": {
"level": { "type": "string" },
"seo": { "type": "object", "fields": { "title": { "type": "string" } } }
} }
},
"relations": {}
}
]"#;
fn rp_json_cmd(c0: Value) -> (Value, Registry) {
let schemas: Vec<Value> = serde_json::from_str(RP_JSON_SCHEMAS).expect("schemas 解析失败");
let registry = registry_with(&schemas);
let mut ast = parse_gql("Order($condition:@c0){ _id }").expect("GQL 解析失败");
let mut params = Map::new();
params.insert("c0".to_string(), c0);
let pipeline = build_pipeline(&mut ast, ¶ms, ®istry, None).expect("build_pipeline");
(
json!({ "kind": "aggregate", "collection": "orders", "pipeline": pipeline }),
registry,
)
}
#[test]
fn dialect_relation_predicate_json_filter_pushdown() {
let (cmd, reg) =
rp_json_cmd(json!({ "items": { "$filter": { "tags": "x" }, "$count": { "$gt": 1 } } }));
let sq = translate(Backend::Sqlite, &cmd, ®).expect("translate");
let text = sq["stmts"][0]["text"].as_str().unwrap();
assert!(
text.contains("EXISTS (SELECT 1 FROM \"order_items\" c")
&& text
.contains("EXISTS (SELECT 1 FROM json_each(c.\"tags\") WHERE json_each.value = ?)"),
"U1 应在 EXISTS 内下推数组谓词: {text}"
);
assert_eq!(
sq["stmts"][0]["params"],
json!(["x", 1]),
"参数顺序须与 SQL 文本占位符一致(子 filter 值在前、HAVING 常量在后): {sq}"
);
let (cmd, reg) = rp_json_cmd(
json!({ "items": { "$filter": { "meta.seo.title": "x" }, "$count": { "$gt": 1 } } }),
);
let pg = translate(Backend::Postgres, &cmd, ®).expect("translate");
let text = pg["stmts"][0]["text"].as_str().unwrap();
assert!(
text.contains("(c.\"meta\" #>> '{seo,title}')"),
"U3 应在 EXISTS 内下推 JSON 标量提取: {text}"
);
let (cmd, reg) = rp_json_cmd(
json!({ "items": { "$filter": { "meta": { "level": "a" } }, "$count": { "$gt": 1 } } }),
);
let my = translate(Backend::Mysql, &cmd, ®).expect("translate");
let text = my["stmts"][0]["text"].as_str().unwrap();
assert!(
text.contains("c.`meta` = CAST(? AS JSON)"),
"U2 应在 EXISTS 内下推对象整值等值: {text}"
);
let warnings = my["warnings"].as_array().cloned().unwrap_or_default();
assert!(
warnings
.iter()
.any(|w| w.as_str().unwrap_or("").contains("键序")),
"U2 键序告警必须从关系谓词 filter 冒泡(禁静默): {warnings:?}"
);
}
const RP_NESTED_SCHEMAS: &str = r#"[
{
"name": "Order", "collection": "orders", "timestamps": false,
"fields": { "code": { "type": "string" } },
"relations": {
"items": { "model": "OrderItem", "type": "many", "localField": "_id", "foreignField": "orderId" }
}
},
{
"name": "OrderItem", "collection": "order_items", "timestamps": false,
"fields": { "orderId": { "type": "string" }, "sku": { "type": "string" } },
"relations": {
"addons": { "model": "OrderAddon", "type": "many", "localField": "_id", "foreignField": "itemId" }
}
},
{
"name": "OrderAddon", "collection": "order_addons", "timestamps": false,
"fields": { "itemId": { "type": "string" }, "price": { "type": "int" } },
"relations": {}
}
]"#;
fn rp_nested_cmd(c0: Value) -> (Value, Registry) {
let schemas: Vec<Value> = serde_json::from_str(RP_NESTED_SCHEMAS).expect("schemas 解析失败");
let registry = registry_with(&schemas);
let mut ast = parse_gql("Order($condition:@c0){ _id }").expect("GQL 解析失败");
let mut params = Map::new();
params.insert("c0".to_string(), c0);
let pipeline = build_pipeline(&mut ast, ¶ms, ®istry, None).expect("build_pipeline");
(
json!({ "kind": "aggregate", "collection": "orders", "pipeline": pipeline }),
registry,
)
}
#[test]
fn dialect_relation_predicate_nested_exists() {
let (cmd, reg) = rp_nested_cmd(json!({
"items": { "$filter": { "addons.price": { "$gt": 10 } }, "$count": { "$gt": 1 } }
}));
for backend in [Backend::Mysql, Backend::Postgres, Backend::Sqlite] {
let out = translate(backend, &cmd, ®).expect("translate");
assert_eq!(
out["unsupported"].as_array().map(|a| a.len()),
Some(0),
"[{backend:?}] 嵌套关系下钻应完全下推: {out}"
);
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
let norm = normalize(text);
assert!(
norm.contains("exists (select 1 from order_items c where c.order_id = orders._id")
|| norm.contains("exists (select 1 from order_items c where c.order_id = t._id"),
"[{backend:?}] 外层应为 EXISTS 相关子查询: {text}"
);
assert!(
norm.contains("and exists (select 1 from order_addons n0 where n0.item_id = c._id and n0.price > ?)"),
"[{backend:?}] 内层应为嵌套 EXISTS(挂在 c 上): {text}"
);
assert!(
norm.contains("group by c.order_id having count(*) > ?"),
"[{backend:?}] 外层应为 GROUP BY + HAVING: {text}"
);
let params = out["stmts"][0]["params"]
.as_array()
.cloned()
.unwrap_or_default();
assert_eq!(params, vec![json!(10), json!(1)], "[{backend:?}] {out}");
}
}
#[test]
fn dialect_relation_predicate_nested_with_scalar_sibling() {
let (cmd, reg) = rp_nested_cmd(json!({
"items": {
"$filter": { "sku": "x", "addons.price": { "$gt": 10 } },
"$count": { "$gt": 1 }
}
}));
let out = translate(Backend::Postgres, &cmd, ®).expect("translate");
let text = out["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("c.\"sku\" = $1"),
"子级标量 filter 应留在子表 c 上: {text}"
);
assert!(
text.contains("n0.\"price\" > $2"),
"嵌套 filter 应落在孙表 n0 上: {text}"
);
assert_eq!(
out["stmts"][0]["params"],
json!(["x", 10, 1]),
"参数顺序须与 SQL 文本占位符一致(子级标量 → 嵌套 filter → HAVING): {out}"
);
}
#[test]
fn dialect_boolean_column_restores_as_json_bool() {
let registry = registry_with(&[json!({
"name": "Enrollment", "collection": "enrollments", "timestamps": false,
"fields": {
"paid": { "type": "boolean" }, "free": { "type": "bool" },
"amount": { "type": "float" }
},
"relations": {}
})]);
let cmd = json!({ "kind": "find", "collection": "enrollments", "filter": {},
"projection": { "_id": 1, "paid": 1, "free": 1, "amount": 1 } });
for backend in [Backend::Mysql, Backend::Postgres, Backend::Sqlite] {
let out = translate(backend, &cmd, ®istry).expect("translate");
let shape = &out["stmts"][0]["rowShape"];
let cols = shape["columns"].as_array().expect("columns");
for bool_col in ["paid", "free"] {
let c = cols
.iter()
.find(|c| c["path"] == json!([bool_col]))
.unwrap_or_else(|| panic!("{bool_col} 列"));
assert_eq!(
c["bool"],
json!(true),
"[{backend:?}] schema 布尔字段必须标记(含 `bool` 简写): {shape}"
);
}
let amount = cols
.iter()
.find(|c| c["path"] == json!(["amount"]))
.expect("amount 列");
assert_eq!(
amount["bool"],
json!(false),
"[{backend:?}] 非布尔字段不得被标记: {shape}"
);
}
let shape =
&translate(Backend::Sqlite, &cmd, ®istry).expect("translate")["stmts"][0]["rowShape"];
let rows = json!([
{ "_id": "e1", "paid": 1, "free": 0, "amount": 3.5, "__present": ",paid,free,amount," },
{ "_id": "e2", "paid": 0, "free": 1, "amount": 4, "__present": ",paid,free,amount," },
{ "_id": "e3", "paid": null, "free": null, "amount": 0, "__present": ",paid,free,amount," },
]);
let restored = restore_rows_json(shape, &rows).expect("restore");
assert_eq!(
restored,
json!([
{ "_id": "e1", "paid": true, "free": false, "amount": 3.5 },
{ "_id": "e2", "paid": false, "free": true, "amount": 4 },
{ "_id": "e3", "paid": null, "free": null, "amount": 0 },
]),
"0/1 应归一为 true/false,null 保持 null,非布尔列不变: {restored}"
);
let pg_shape =
&translate(Backend::Postgres, &cmd, ®istry).expect("translate")["stmts"][0]["rowShape"];
let pg_rows = json!([{ "_id": "e1", "paid": true, "free": false, "amount": 3.5 }]);
let pg_restored = restore_rows_json(pg_shape, &pg_rows).expect("restore");
assert_eq!(
pg_restored,
json!([{ "_id": "e1", "paid": true, "free": false, "amount": 3.5 }])
);
}
#[test]
fn dialect_postgres_float_literal_gets_explicit_double_cast() {
let registry = registry_with(&schemas());
let float_cmd = json!({ "kind": "find", "collection": "posts",
"filter": { "views": { "$gt": 2.5 } }, "projection": { "_id": 1 } });
let pg = translate(Backend::Postgres, &float_cmd, ®istry).expect("translate");
let text = pg["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("CAST($1 AS double precision)"),
"PG 浮点字面量须显式标注双精度: {text}"
);
let int_cmd = json!({ "kind": "find", "collection": "posts",
"filter": { "views": { "$gte": 10 } }, "projection": { "_id": 1 } });
let pg_int = translate(Backend::Postgres, &int_cmd, ®istry).expect("translate");
assert!(
!pg_int["stmts"][0]["text"]
.as_str()
.unwrap_or("")
.contains("CAST("),
"整数字面量不应加 CAST: {}",
pg_int["stmts"][0]["text"]
);
for backend in [Backend::Mysql, Backend::Sqlite] {
let out = translate(backend, &float_cmd, ®istry).expect("translate");
assert!(
!out["stmts"][0]["text"]
.as_str()
.unwrap_or("")
.contains("CAST("),
"[{backend:?}] 不应引入 CAST: {}",
out["stmts"][0]["text"]
);
}
let in_cmd = json!({ "kind": "find", "collection": "posts",
"filter": { "views": { "$in": [2.5, 3] } }, "projection": { "_id": 1 } });
let pg_in = translate(Backend::Postgres, &in_cmd, ®istry).expect("translate");
let in_text = pg_in["stmts"][0]["text"].as_str().unwrap_or("");
assert!(
in_text.contains("CAST($1 AS double precision)") && in_text.contains("$2"),
"$in 中浮点元素须标注、整数元素保持: {in_text}"
);
assert_eq!(
pg_in["stmts"][0]["params"],
json!([2.5, 3]),
"参数值本身不变: {pg_in}"
);
}