From 54101f7e2c8cafe9e3cd5ffa63f507a4ebc4fd9a Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 20 Apr 2026 22:49:09 +0300 Subject: [PATCH] Extract remaining crowded compiler test modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files where inline tests crowded out production code (test/prod ratio ≥ 0.8) move to sibling files via `#[path]`. Files where production dominates (query_input.rs, schema_plan.rs) stay inline — extracting would add noise, not reduce it. - ir/lower.rs: 1239 → 577 lines (ratio 1.15) - catalog/mod.rs: 594 → 326 lines (ratio 0.83) - query/lint.rs: 562 → 314 lines (ratio 0.80) catalog/tests.rs uses the shorter name since it's inside a module directory (no ambiguity with filename). All 229 compiler tests green, identical count to before. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/omnigraph-compiler/src/catalog/mod.rs | 272 +------ .../omnigraph-compiler/src/catalog/tests.rs | 268 +++++++ crates/omnigraph-compiler/src/ir/lower.rs | 666 +----------------- .../omnigraph-compiler/src/ir/lower_tests.rs | 662 +++++++++++++++++ crates/omnigraph-compiler/src/query/lint.rs | 252 +------ .../src/query/lint_tests.rs | 248 +++++++ 6 files changed, 1184 insertions(+), 1184 deletions(-) create mode 100644 crates/omnigraph-compiler/src/catalog/tests.rs create mode 100644 crates/omnigraph-compiler/src/ir/lower_tests.rs create mode 100644 crates/omnigraph-compiler/src/query/lint_tests.rs diff --git a/crates/omnigraph-compiler/src/catalog/mod.rs b/crates/omnigraph-compiler/src/catalog/mod.rs index 18ba3d9..0bb536d 100644 --- a/crates/omnigraph-compiler/src/catalog/mod.rs +++ b/crates/omnigraph-compiler/src/catalog/mod.rs @@ -322,273 +322,5 @@ pub fn build_catalog(schema: &SchemaFile) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use crate::schema::ast::{EdgeDecl, NodeDecl}; - use crate::schema::parser::parse_schema; - use crate::types::PropType; - - fn test_schema() -> &'static str { - r#" -node Person { - name: String - age: I32? -} -node Company { - name: String -} -edge Knows: Person -> Person { - since: Date? -} -edge WorksAt: Person -> Company { - title: String? -} -"# - } - - #[test] - fn test_build_catalog() { - let schema = parse_schema(test_schema()).unwrap(); - let catalog = build_catalog(&schema).unwrap(); - assert_eq!(catalog.node_types.len(), 2); - assert_eq!(catalog.edge_types.len(), 2); - assert!(catalog.node_types.contains_key("Person")); - assert!(catalog.node_types.contains_key("Company")); - } - - #[test] - fn test_edge_lookup() { - let schema = parse_schema(test_schema()).unwrap(); - let catalog = build_catalog(&schema).unwrap(); - let edge = catalog.lookup_edge_by_name("knows").unwrap(); - assert_eq!(edge.from_type, "Person"); - assert_eq!(edge.to_type, "Person"); - let upper = catalog.lookup_edge_by_name("KNOWS").unwrap(); - assert_eq!(upper.name, "Knows"); - } - - #[test] - fn test_node_arrow_schema() { - let schema = parse_schema(test_schema()).unwrap(); - let catalog = build_catalog(&schema).unwrap(); - let person = &catalog.node_types["Person"]; - assert_eq!(person.arrow_schema.fields().len(), 3); // id, name, age - } - - #[test] - fn test_duplicate_node_error() { - let input = r#" -node Person { name: String } -node Person { age: I32 } -"#; - let schema = parse_schema(input).unwrap(); - assert!(build_catalog(&schema).is_err()); - } - - #[test] - fn test_bad_edge_endpoint() { - let input = r#" -node Person { name: String } -edge Knows: Person -> Alien -"#; - let schema = parse_schema(input).unwrap(); - assert!(build_catalog(&schema).is_err()); - } - - #[test] - fn test_id_fields_are_utf8() { - let schema = parse_schema(test_schema()).unwrap(); - let catalog = build_catalog(&schema).unwrap(); - let person = &catalog.node_types["Person"]; - assert_eq!( - person - .arrow_schema - .field_with_name("id") - .unwrap() - .data_type(), - &DataType::Utf8 - ); - let knows = &catalog.edge_types["Knows"]; - assert_eq!( - knows - .arrow_schema - .field_with_name("id") - .unwrap() - .data_type(), - &DataType::Utf8 - ); - assert_eq!( - knows - .arrow_schema - .field_with_name("src") - .unwrap() - .data_type(), - &DataType::Utf8 - ); - assert_eq!( - knows - .arrow_schema - .field_with_name("dst") - .unwrap() - .data_type(), - &DataType::Utf8 - ); - } - - #[test] - fn test_key_property_tracking() { - let input = r#" -node Signal { - slug: String @key - title: String -} -node Person { - name: String -} -edge Emits: Person -> Signal -"#; - let schema = parse_schema(input).unwrap(); - let catalog = build_catalog(&schema).unwrap(); - assert_eq!(catalog.node_types["Signal"].key_property(), Some("slug")); - assert_eq!(catalog.node_types["Person"].key_property(), None); - } - - #[test] - fn test_edge_lookup_handles_non_ascii_leading_character() { - let schema = SchemaFile { - declarations: vec![ - SchemaDecl::Node(NodeDecl { - name: "Person".to_string(), - annotations: vec![], - implements: vec![], - properties: vec![crate::schema::ast::PropDecl { - name: "name".to_string(), - prop_type: PropType::scalar(ScalarType::String, false), - annotations: vec![], - }], - constraints: vec![], - }), - SchemaDecl::Edge(EdgeDecl { - name: "Édges".to_string(), - from_type: "Person".to_string(), - to_type: "Person".to_string(), - cardinality: Default::default(), - annotations: vec![], - properties: vec![], - constraints: vec![], - }), - ], - }; - let catalog = build_catalog(&schema).unwrap(); - assert!(catalog.lookup_edge_by_name("édges").is_some()); - } - - #[test] - fn test_edge_lookup_rejects_case_fold_collisions() { - let input = r#" -node Person { name: String } -edge Knows: Person -> Person -edge KNOWS: Person -> Person -"#; - let schema = parse_schema(input).unwrap(); - let err = build_catalog(&schema).unwrap_err(); - assert!(err.to_string().contains("case folding")); - } - - #[test] - fn test_catalog_composite_unique() { - let input = r#" -node Person { - first: String - last: String - @unique(first, last) -} -"#; - let schema = parse_schema(input).unwrap(); - let catalog = build_catalog(&schema).unwrap(); - let person = &catalog.node_types["Person"]; - assert!( - person - .unique_constraints - .contains(&vec!["first".to_string(), "last".to_string()]) - ); - } - - #[test] - fn test_catalog_composite_index() { - let input = r#" -node Event { - category: String - date: Date - @index(category, date) -} -"#; - let schema = parse_schema(input).unwrap(); - let catalog = build_catalog(&schema).unwrap(); - let event = &catalog.node_types["Event"]; - assert!( - event - .indices - .contains(&vec!["category".to_string(), "date".to_string()]) - ); - } - - #[test] - fn test_catalog_edge_cardinality() { - let input = r#" -node Person { name: String } -node Company { name: String } -edge WorksAt: Person -> Company @card(0..1) -"#; - let schema = parse_schema(input).unwrap(); - let catalog = build_catalog(&schema).unwrap(); - let edge = &catalog.edge_types["WorksAt"]; - assert_eq!(edge.cardinality.min, 0); - assert_eq!(edge.cardinality.max, Some(1)); - } - - #[test] - fn test_catalog_interfaces_stored() { - let input = r#" -interface Named { - name: String -} -node Person implements Named { - age: I32? -} -"#; - let schema = parse_schema(input).unwrap(); - let catalog = build_catalog(&schema).unwrap(); - assert!(catalog.interfaces.contains_key("Named")); - assert!(catalog.interfaces["Named"].properties.contains_key("name")); - } - - #[test] - fn test_catalog_node_implements() { - let input = r#" -interface Named { - name: String -} -node Person implements Named { - age: I32? -} -"#; - let schema = parse_schema(input).unwrap(); - let catalog = build_catalog(&schema).unwrap(); - assert_eq!(catalog.node_types["Person"].implements, vec!["Named"]); - } - - #[test] - fn test_key_implies_index() { - let input = r#" -node Signal { - slug: String @key - title: String -} -"#; - let schema = parse_schema(input).unwrap(); - let catalog = build_catalog(&schema).unwrap(); - let signal = &catalog.node_types["Signal"]; - assert!(signal.indices.contains(&vec!["slug".to_string()])); - } -} +#[path = "tests.rs"] +mod tests; diff --git a/crates/omnigraph-compiler/src/catalog/tests.rs b/crates/omnigraph-compiler/src/catalog/tests.rs new file mode 100644 index 0000000..883b4a9 --- /dev/null +++ b/crates/omnigraph-compiler/src/catalog/tests.rs @@ -0,0 +1,268 @@ +use super::*; +use crate::schema::ast::{EdgeDecl, NodeDecl}; +use crate::schema::parser::parse_schema; +use crate::types::PropType; + +fn test_schema() -> &'static str { + r#" +node Person { +name: String +age: I32? +} +node Company { +name: String +} +edge Knows: Person -> Person { +since: Date? +} +edge WorksAt: Person -> Company { +title: String? +} +"# +} + +#[test] +fn test_build_catalog() { + let schema = parse_schema(test_schema()).unwrap(); + let catalog = build_catalog(&schema).unwrap(); + assert_eq!(catalog.node_types.len(), 2); + assert_eq!(catalog.edge_types.len(), 2); + assert!(catalog.node_types.contains_key("Person")); + assert!(catalog.node_types.contains_key("Company")); +} + +#[test] +fn test_edge_lookup() { + let schema = parse_schema(test_schema()).unwrap(); + let catalog = build_catalog(&schema).unwrap(); + let edge = catalog.lookup_edge_by_name("knows").unwrap(); + assert_eq!(edge.from_type, "Person"); + assert_eq!(edge.to_type, "Person"); + let upper = catalog.lookup_edge_by_name("KNOWS").unwrap(); + assert_eq!(upper.name, "Knows"); +} + +#[test] +fn test_node_arrow_schema() { + let schema = parse_schema(test_schema()).unwrap(); + let catalog = build_catalog(&schema).unwrap(); + let person = &catalog.node_types["Person"]; + assert_eq!(person.arrow_schema.fields().len(), 3); // id, name, age +} + +#[test] +fn test_duplicate_node_error() { + let input = r#" +node Person { name: String } +node Person { age: I32 } +"#; + let schema = parse_schema(input).unwrap(); + assert!(build_catalog(&schema).is_err()); +} + +#[test] +fn test_bad_edge_endpoint() { + let input = r#" +node Person { name: String } +edge Knows: Person -> Alien +"#; + let schema = parse_schema(input).unwrap(); + assert!(build_catalog(&schema).is_err()); +} + +#[test] +fn test_id_fields_are_utf8() { + let schema = parse_schema(test_schema()).unwrap(); + let catalog = build_catalog(&schema).unwrap(); + let person = &catalog.node_types["Person"]; + assert_eq!( + person + .arrow_schema + .field_with_name("id") + .unwrap() + .data_type(), + &DataType::Utf8 + ); + let knows = &catalog.edge_types["Knows"]; + assert_eq!( + knows + .arrow_schema + .field_with_name("id") + .unwrap() + .data_type(), + &DataType::Utf8 + ); + assert_eq!( + knows + .arrow_schema + .field_with_name("src") + .unwrap() + .data_type(), + &DataType::Utf8 + ); + assert_eq!( + knows + .arrow_schema + .field_with_name("dst") + .unwrap() + .data_type(), + &DataType::Utf8 + ); +} + +#[test] +fn test_key_property_tracking() { + let input = r#" +node Signal { +slug: String @key +title: String +} +node Person { +name: String +} +edge Emits: Person -> Signal +"#; + let schema = parse_schema(input).unwrap(); + let catalog = build_catalog(&schema).unwrap(); + assert_eq!(catalog.node_types["Signal"].key_property(), Some("slug")); + assert_eq!(catalog.node_types["Person"].key_property(), None); +} + +#[test] +fn test_edge_lookup_handles_non_ascii_leading_character() { + let schema = SchemaFile { + declarations: vec![ + SchemaDecl::Node(NodeDecl { + name: "Person".to_string(), + annotations: vec![], + implements: vec![], + properties: vec![crate::schema::ast::PropDecl { + name: "name".to_string(), + prop_type: PropType::scalar(ScalarType::String, false), + annotations: vec![], + }], + constraints: vec![], + }), + SchemaDecl::Edge(EdgeDecl { + name: "Édges".to_string(), + from_type: "Person".to_string(), + to_type: "Person".to_string(), + cardinality: Default::default(), + annotations: vec![], + properties: vec![], + constraints: vec![], + }), + ], + }; + let catalog = build_catalog(&schema).unwrap(); + assert!(catalog.lookup_edge_by_name("édges").is_some()); +} + +#[test] +fn test_edge_lookup_rejects_case_fold_collisions() { + let input = r#" +node Person { name: String } +edge Knows: Person -> Person +edge KNOWS: Person -> Person +"#; + let schema = parse_schema(input).unwrap(); + let err = build_catalog(&schema).unwrap_err(); + assert!(err.to_string().contains("case folding")); +} + +#[test] +fn test_catalog_composite_unique() { + let input = r#" +node Person { +first: String +last: String +@unique(first, last) +} +"#; + let schema = parse_schema(input).unwrap(); + let catalog = build_catalog(&schema).unwrap(); + let person = &catalog.node_types["Person"]; + assert!( + person + .unique_constraints + .contains(&vec!["first".to_string(), "last".to_string()]) + ); +} + +#[test] +fn test_catalog_composite_index() { + let input = r#" +node Event { +category: String +date: Date +@index(category, date) +} +"#; + let schema = parse_schema(input).unwrap(); + let catalog = build_catalog(&schema).unwrap(); + let event = &catalog.node_types["Event"]; + assert!( + event + .indices + .contains(&vec!["category".to_string(), "date".to_string()]) + ); +} + +#[test] +fn test_catalog_edge_cardinality() { + let input = r#" +node Person { name: String } +node Company { name: String } +edge WorksAt: Person -> Company @card(0..1) +"#; + let schema = parse_schema(input).unwrap(); + let catalog = build_catalog(&schema).unwrap(); + let edge = &catalog.edge_types["WorksAt"]; + assert_eq!(edge.cardinality.min, 0); + assert_eq!(edge.cardinality.max, Some(1)); +} + +#[test] +fn test_catalog_interfaces_stored() { + let input = r#" +interface Named { +name: String +} +node Person implements Named { +age: I32? +} +"#; + let schema = parse_schema(input).unwrap(); + let catalog = build_catalog(&schema).unwrap(); + assert!(catalog.interfaces.contains_key("Named")); + assert!(catalog.interfaces["Named"].properties.contains_key("name")); +} + +#[test] +fn test_catalog_node_implements() { + let input = r#" +interface Named { +name: String +} +node Person implements Named { +age: I32? +} +"#; + let schema = parse_schema(input).unwrap(); + let catalog = build_catalog(&schema).unwrap(); + assert_eq!(catalog.node_types["Person"].implements, vec!["Named"]); +} + +#[test] +fn test_key_implies_index() { + let input = r#" +node Signal { +slug: String @key +title: String +} +"#; + let schema = parse_schema(input).unwrap(); + let catalog = build_catalog(&schema).unwrap(); + let signal = &catalog.node_types["Signal"]; + assert!(signal.indices.contains(&vec!["slug".to_string()])); +} diff --git a/crates/omnigraph-compiler/src/ir/lower.rs b/crates/omnigraph-compiler/src/ir/lower.rs index a077b4b..c130d18 100644 --- a/crates/omnigraph-compiler/src/ir/lower.rs +++ b/crates/omnigraph-compiler/src/ir/lower.rs @@ -573,667 +573,5 @@ fn lower_match_value(value: &MatchValue, param_names: &HashSet) -> IRExp } #[cfg(test)] -mod tests { - use super::*; - use crate::catalog::build_catalog; - use crate::query::parser::parse_query; - use crate::query::typecheck::{CheckedQuery, typecheck_query, typecheck_query_decl}; - use crate::schema::parser::parse_schema; - - fn setup() -> Catalog { - let schema = parse_schema( - r#" -node Person { name: String age: I32? } -node Company { name: String } -edge Knows: Person -> Person { since: Date? } -edge WorksAt: Person -> Company -"#, - ) - .unwrap(); - build_catalog(&schema).unwrap() - } - - #[test] - fn test_lower_basic() { - let catalog = setup(); - let qf = parse_query( - r#" -query q($name: String) { - match { - $p: Person { name: $name } - $p knows $f - } - return { $f.name, $f.age } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - assert_eq!(ir.pipeline.len(), 2); // NodeScan + Expand - assert_eq!(ir.return_exprs.len(), 2); - } - - #[test] - fn test_lower_negation() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - not { $p worksAt $_ } - } - return { $p.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - assert_eq!(ir.pipeline.len(), 2); // NodeScan + AntiJoin - assert!(matches!(&ir.pipeline[1], IROp::AntiJoin { .. })); - } - - #[test] - fn test_lower_mutation_update() { - let catalog = setup(); - let qf = parse_query( - r#" -query q($name: String, $age: I32) { - update Person set { age: $age } where name = $name -} -"#, - ) - .unwrap(); - let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); - assert!(matches!(checked, CheckedQuery::Mutation(_))); - - let ir = lower_mutation_query(&qf.queries[0]).unwrap(); - match &ir.ops[0] { - MutationOpIR::Update { - type_name, - assignments, - predicate, - } => { - assert_eq!(type_name, "Person"); - assert_eq!(assignments.len(), 1); - assert_eq!(assignments[0].property, "age"); - assert_eq!(predicate.property, "name"); - } - _ => panic!("expected update mutation op"), - } - } - - #[test] - fn test_lower_bounded_traversal() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p knows{1,3} $f - } - return { $f.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - let expand = ir - .pipeline - .iter() - .find_map(|op| match op { - IROp::Expand { - min_hops, max_hops, .. - } => Some((*min_hops, *max_hops)), - _ => None, - }) - .expect("expected expand op"); - assert_eq!(expand.0, 1); - assert_eq!(expand.1, Some(3)); - } - - #[test] - fn test_lower_now_uses_reserved_runtime_param() { - let catalog = setup(); - let qf = parse_query( - r#" -query stamp() { - match { $p: Person } - return { now() as ts } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - assert!(matches!( - ir.return_exprs[0].expr, - IRExpr::Param(ref name) if name == NOW_PARAM_NAME - )); - } - - #[test] - fn test_lower_mutation_now_uses_reserved_runtime_param() { - let catalog = build_catalog( - &parse_schema( - r#" -node Event { - slug: String @key - updated_at: DateTime? -} -"#, - ) - .unwrap(), - ) - .unwrap(); - let qf = parse_query( - r#" -query stamp() { - update Event set { updated_at: now() } where updated_at = now() -} -"#, - ) - .unwrap(); - let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); - assert!(matches!(checked, CheckedQuery::Mutation(_))); - - let ir = lower_mutation_query(&qf.queries[0]).unwrap(); - match &ir.ops[0] { - MutationOpIR::Update { - assignments, - predicate, - .. - } => { - assert!(matches!( - assignments[0].value, - IRExpr::Param(ref name) if name == NOW_PARAM_NAME - )); - assert!(matches!( - predicate.value, - IRExpr::Param(ref name) if name == NOW_PARAM_NAME - )); - } - _ => panic!("expected update mutation op"), - } - } - - #[test] - fn test_lower_multi_mutation() { - let catalog = setup(); - let qf = parse_query( - r#" -query q($name: String, $age: I32, $friend: String) { - insert Person { name: $name, age: $age } - insert Knows { from: $name, to: $friend } -} -"#, - ) - .unwrap(); - let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); - assert!(matches!(checked, CheckedQuery::Mutation(_))); - - let ir = lower_mutation_query(&qf.queries[0]).unwrap(); - assert_eq!(ir.ops.len(), 2); - assert!( - matches!(&ir.ops[0], MutationOpIR::Insert { type_name, .. } if type_name == "Person") - ); - assert!( - matches!(&ir.ops[1], MutationOpIR::Insert { type_name, .. } if type_name == "Knows") - ); - } - - /// Destination binding is deferred: NodeScan + Expand + Filter (no cross-join). - #[test] - fn test_lower_traversal_with_destination_binding() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p worksAt $c - $c: Company { name: "Acme" } - } - return { $p.name, $c.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - // Should be: NodeScan($p) → Expand($p→$c, dst_filters=[name=="Acme"]) - // NOT: NodeScan($p) → NodeScan($c) → cross-join → cycle-close - assert_eq!(ir.pipeline.len(), 2); - assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); - assert!(matches!( - &ir.pipeline[1], - IROp::Expand { src_var, dst_var, dst_filters, .. } - if src_var == "p" && dst_var == "c" && dst_filters.len() == 1 - )); - } - - /// Multi-hop chain: all intermediate and final bindings are deferred. - #[test] - fn test_lower_chain_defers_all_intermediate_bindings() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person { name: "Alice" } - $p knows $f - $f: Person { name: "Bob" } - $f worksAt $c - $c: Company { name: "Acme" } - } - return { $c.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - // Should be: NodeScan($p,[name=Alice]) → Expand($p→$f, [name==Bob]) - // → Expand($f→$c, [name==Acme]) - assert_eq!(ir.pipeline.len(), 3); - assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); - assert!(matches!( - &ir.pipeline[1], - IROp::Expand { src_var, dst_var, dst_filters, .. } - if src_var == "p" && dst_var == "f" && dst_filters.len() == 1 - )); - assert!(matches!( - &ir.pipeline[2], - IROp::Expand { src_var, dst_var, dst_filters, .. } - if src_var == "f" && dst_var == "c" && dst_filters.len() == 1 - )); - } - - /// Reverse traversal: source binding is deferred when destination is the root. - #[test] - fn test_lower_reverse_traversal_defers_source_binding() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $c: Company { name: "Acme" } - $p worksAt $c - $p: Person { name: "Alice" } - } - return { $p.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - // $c is root (first declared). $p is deferred (connected via traversal). - // Traversal $p worksAt $c: $c is bound, $p is not → reverse expand. - // Pipeline: NodeScan($c,[name=Acme]) → Expand($c→$p, In, [name==Alice]) - assert_eq!(ir.pipeline.len(), 2); - assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "c")); - assert!(matches!( - &ir.pipeline[1], - IROp::Expand { src_var, dst_var, dst_filters, .. } - if src_var == "c" && dst_var == "p" && dst_filters.len() == 1 - )); - } - - /// Independent bindings (no traversal) still cross-join. - #[test] - fn test_lower_independent_bindings_still_cross_join() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $c: Company - } - return { $p.name, $c.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - // No traversal connecting them → both get NodeScans (cross-join at runtime) - assert_eq!(ir.pipeline.len(), 2); - assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); - assert!(matches!(&ir.pipeline[1], IROp::NodeScan { variable, .. } if variable == "c")); - } - - /// Destination binding without filters: no NodeScan, no post-expand filter. - #[test] - fn test_lower_destination_binding_without_filters() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p worksAt $c - $c: Company - } - return { $p.name, $c.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - // $c binding is deferred (no filters) → just NodeScan + Expand - assert_eq!(ir.pipeline.len(), 2); - assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); - assert!(matches!( - &ir.pipeline[1], - IROp::Expand { src_var, dst_var, .. } - if src_var == "p" && dst_var == "c" - )); - } - - /// Traversals declared in non-topological order are reordered automatically. - #[test] - fn test_lower_out_of_order_traversals() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $f worksAt $c - $p knows $f - $f: Person - $c: Company { name: "Acme" } - } - return { $c.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - // Even though "$f worksAt $c" is declared before "$p knows $f", - // the iterative lowering processes "$p knows $f" first (because $p - // is bound) and then "$f worksAt $c" (once $f is bound). - assert_eq!(ir.pipeline.len(), 3); - assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); - // First expand: $p → $f (knows) - assert!(matches!( - &ir.pipeline[1], - IROp::Expand { src_var, dst_var, .. } - if src_var == "p" && dst_var == "f" - )); - // Second expand: $f → $c (worksAt), with filter from $c binding - assert!(matches!( - &ir.pipeline[2], - IROp::Expand { src_var, dst_var, dst_filters, .. } - if src_var == "f" && dst_var == "c" && dst_filters.len() == 1 - )); - } - - /// Wildcard $_ must not bridge unrelated components in the adjacency graph. - #[test] - fn test_lower_wildcard_does_not_bridge_components() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p knows $_ - $c: Company - } - return { $p.name, $c.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - // $p and $c are in separate components (connected only through $_). - // Both must get their own NodeScan — $c must NOT be deferred. - // Bindings are emitted first, then traversals. - assert_eq!(ir.pipeline.len(), 3); - assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); - assert!(matches!(&ir.pipeline[1], IROp::NodeScan { variable, .. } if variable == "c")); - // The expand for $p knows $_ (wildcard destination) - assert!(matches!( - &ir.pipeline[2], - IROp::Expand { src_var, dst_var, .. } - if src_var == "p" && dst_var == "_" - )); - } - - /// Fan-out: one root fans to two deferred destinations via different edges. - #[test] - fn test_lower_fan_out_topology() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person { name: "Alice" } - $p knows $f - $f: Person { name: "Bob" } - $p worksAt $c - $c: Company { name: "Acme" } - } - return { $f.name, $c.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - // Root: $p. Deferred: $f, $c (both reachable from $p). - assert_eq!(ir.pipeline.len(), 3); - assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); - assert!(matches!( - &ir.pipeline[1], - IROp::Expand { src_var, dst_var, dst_filters, .. } - if src_var == "p" && dst_var == "f" && dst_filters.len() == 1 - )); - assert!(matches!( - &ir.pipeline[2], - IROp::Expand { src_var, dst_var, dst_filters, .. } - if src_var == "p" && dst_var == "c" && dst_filters.len() == 1 - )); - } - - /// Fan-in: two sources converge on one destination; second source is - /// introduced via reverse expand from the shared destination. - #[test] - fn test_lower_fan_in_topology() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $a: Person { name: "Alice" } - $a knows $c - $b: Person { name: "Bob" } - $b knows $c - $c: Person - } - return { $a.name, $b.name, $c.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - // Root: $a (first in component {a,b,c}). Deferred: $b, $c. - // $a knows $c: expand(a→c). $b knows $c: reverse expand(c→b). - assert_eq!(ir.pipeline.len(), 3); - assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "a")); - assert!(matches!( - &ir.pipeline[1], - IROp::Expand { src_var, dst_var, dst_filters, .. } - if src_var == "a" && dst_var == "c" && dst_filters.is_empty() - )); - assert!(matches!( - &ir.pipeline[2], - IROp::Expand { src_var, dst_var, dst_filters, .. } - if src_var == "c" && dst_var == "b" && dst_filters.len() == 1 - )); - } - - /// Genuine graph cycle: deferred binding is introduced by first traversal, - /// second traversal triggers cycle-closing. - #[test] - fn test_lower_cycle_with_deferred_binding() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $a: Person - $a knows $b - $b: Person { name: "Bob" } - $b knows $a - } - return { $a.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - // $b is deferred, introduced by first expand. - // Second traversal ($b knows $a) is genuine cycle-closing. - assert_eq!(ir.pipeline.len(), 4); - assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "a")); - assert!(matches!( - &ir.pipeline[1], - IROp::Expand { src_var, dst_var, dst_filters, .. } - if src_var == "a" && dst_var == "b" && dst_filters.len() == 1 - )); - // Cycle-closing expand to __temp_a - assert!(matches!( - &ir.pipeline[2], - IROp::Expand { src_var, dst_var, dst_filters, .. } - if src_var == "b" && dst_var.starts_with("__temp_") && dst_filters.is_empty() - )); - // Cycle-closing filter: __temp_a.id == a.id - assert!(matches!(&ir.pipeline[3], IROp::Filter(_))); - } - - /// Multiple filters on a single deferred binding. - #[test] - fn test_lower_multiple_filters_on_deferred_binding() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p knows $f - $f: Person { name: "Bob", age: 25 } - } - return { $f.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - // Two prop_matches → two dst_filters on the Expand. - assert_eq!(ir.pipeline.len(), 2); - assert!(matches!( - &ir.pipeline[1], - IROp::Expand { dst_filters, .. } - if dst_filters.len() == 2 - )); - } - - /// Parameter in a deferred binding filter (unit test level). - #[test] - fn test_lower_param_filter_on_deferred_binding() { - let catalog = setup(); - let qf = parse_query( - r#" -query q($company: String) { - match { - $p: Person - $p worksAt $c - $c: Company { name: $company } - } - return { $p.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - assert_eq!(ir.pipeline.len(), 2); - assert!(matches!( - &ir.pipeline[1], - IROp::Expand { dst_filters, .. } - if dst_filters.len() == 1 - )); - // The filter's right-hand side should be a Param, not a Literal - if let IROp::Expand { dst_filters, .. } = &ir.pipeline[1] { - assert!(matches!(&dst_filters[0].right, IRExpr::Param(name) if name == "company")); - } - } - - /// Negation with inner binding: inner binding is NOT deferred because - /// bound_vars (from outer scope) is not in binding_set for the inner call. - /// This documents current behavior — the inner pipeline uses a NodeScan + - /// cycle-closing, which is correct but less efficient than deferral. - #[test] - fn test_lower_negation_with_inner_binding() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - not { - $p worksAt $c - $c: Company { name: "Acme" } - } - } - return { $p.name } -} -"#, - ) - .unwrap(); - let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); - - // Outer: NodeScan($p) + AntiJoin - assert_eq!(ir.pipeline.len(), 2); - assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); - let IROp::AntiJoin { inner, .. } = &ir.pipeline[1] else { - panic!("expected AntiJoin"); - }; - // Inner pipeline: $c is NOT deferred (it's the only binding in the - // inner scope), so it gets a NodeScan + cycle-closing (3 ops). - assert_eq!(inner.len(), 3); - assert!(matches!(&inner[0], IROp::NodeScan { variable, .. } if variable == "c")); - assert!(matches!(&inner[1], IROp::Expand { .. })); - assert!(matches!(&inner[2], IROp::Filter(_))); - } -} +#[path = "lower_tests.rs"] +mod tests; diff --git a/crates/omnigraph-compiler/src/ir/lower_tests.rs b/crates/omnigraph-compiler/src/ir/lower_tests.rs new file mode 100644 index 0000000..50ce93a --- /dev/null +++ b/crates/omnigraph-compiler/src/ir/lower_tests.rs @@ -0,0 +1,662 @@ +use super::*; +use crate::catalog::build_catalog; +use crate::query::parser::parse_query; +use crate::query::typecheck::{CheckedQuery, typecheck_query, typecheck_query_decl}; +use crate::schema::parser::parse_schema; + +fn setup() -> Catalog { + let schema = parse_schema( + r#" +node Person { name: String age: I32? } +node Company { name: String } +edge Knows: Person -> Person { since: Date? } +edge WorksAt: Person -> Company +"#, + ) + .unwrap(); + build_catalog(&schema).unwrap() +} + +#[test] +fn test_lower_basic() { + let catalog = setup(); + let qf = parse_query( + r#" +query q($name: String) { +match { + $p: Person { name: $name } + $p knows $f +} +return { $f.name, $f.age } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + assert_eq!(ir.pipeline.len(), 2); // NodeScan + Expand + assert_eq!(ir.return_exprs.len(), 2); +} + +#[test] +fn test_lower_negation() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + not { $p worksAt $_ } +} +return { $p.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + assert_eq!(ir.pipeline.len(), 2); // NodeScan + AntiJoin + assert!(matches!(&ir.pipeline[1], IROp::AntiJoin { .. })); +} + +#[test] +fn test_lower_mutation_update() { + let catalog = setup(); + let qf = parse_query( + r#" +query q($name: String, $age: I32) { +update Person set { age: $age } where name = $name +} +"#, + ) + .unwrap(); + let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); + assert!(matches!(checked, CheckedQuery::Mutation(_))); + + let ir = lower_mutation_query(&qf.queries[0]).unwrap(); + match &ir.ops[0] { + MutationOpIR::Update { + type_name, + assignments, + predicate, + } => { + assert_eq!(type_name, "Person"); + assert_eq!(assignments.len(), 1); + assert_eq!(assignments[0].property, "age"); + assert_eq!(predicate.property, "name"); + } + _ => panic!("expected update mutation op"), + } +} + +#[test] +fn test_lower_bounded_traversal() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p knows{1,3} $f +} +return { $f.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + let expand = ir + .pipeline + .iter() + .find_map(|op| match op { + IROp::Expand { + min_hops, max_hops, .. + } => Some((*min_hops, *max_hops)), + _ => None, + }) + .expect("expected expand op"); + assert_eq!(expand.0, 1); + assert_eq!(expand.1, Some(3)); +} + +#[test] +fn test_lower_now_uses_reserved_runtime_param() { + let catalog = setup(); + let qf = parse_query( + r#" +query stamp() { +match { $p: Person } +return { now() as ts } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + assert!(matches!( + ir.return_exprs[0].expr, + IRExpr::Param(ref name) if name == NOW_PARAM_NAME + )); +} + +#[test] +fn test_lower_mutation_now_uses_reserved_runtime_param() { + let catalog = build_catalog( + &parse_schema( + r#" +node Event { +slug: String @key +updated_at: DateTime? +} +"#, + ) + .unwrap(), + ) + .unwrap(); + let qf = parse_query( + r#" +query stamp() { +update Event set { updated_at: now() } where updated_at = now() +} +"#, + ) + .unwrap(); + let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); + assert!(matches!(checked, CheckedQuery::Mutation(_))); + + let ir = lower_mutation_query(&qf.queries[0]).unwrap(); + match &ir.ops[0] { + MutationOpIR::Update { + assignments, + predicate, + .. + } => { + assert!(matches!( + assignments[0].value, + IRExpr::Param(ref name) if name == NOW_PARAM_NAME + )); + assert!(matches!( + predicate.value, + IRExpr::Param(ref name) if name == NOW_PARAM_NAME + )); + } + _ => panic!("expected update mutation op"), + } +} + +#[test] +fn test_lower_multi_mutation() { + let catalog = setup(); + let qf = parse_query( + r#" +query q($name: String, $age: I32, $friend: String) { +insert Person { name: $name, age: $age } +insert Knows { from: $name, to: $friend } +} +"#, + ) + .unwrap(); + let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); + assert!(matches!(checked, CheckedQuery::Mutation(_))); + + let ir = lower_mutation_query(&qf.queries[0]).unwrap(); + assert_eq!(ir.ops.len(), 2); + assert!( + matches!(&ir.ops[0], MutationOpIR::Insert { type_name, .. } if type_name == "Person") + ); + assert!( + matches!(&ir.ops[1], MutationOpIR::Insert { type_name, .. } if type_name == "Knows") + ); +} + +/// Destination binding is deferred: NodeScan + Expand + Filter (no cross-join). +#[test] +fn test_lower_traversal_with_destination_binding() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p worksAt $c + $c: Company { name: "Acme" } +} +return { $p.name, $c.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + // Should be: NodeScan($p) → Expand($p→$c, dst_filters=[name=="Acme"]) + // NOT: NodeScan($p) → NodeScan($c) → cross-join → cycle-close + assert_eq!(ir.pipeline.len(), 2); + assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); + assert!(matches!( + &ir.pipeline[1], + IROp::Expand { src_var, dst_var, dst_filters, .. } + if src_var == "p" && dst_var == "c" && dst_filters.len() == 1 + )); +} + +/// Multi-hop chain: all intermediate and final bindings are deferred. +#[test] +fn test_lower_chain_defers_all_intermediate_bindings() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person { name: "Alice" } + $p knows $f + $f: Person { name: "Bob" } + $f worksAt $c + $c: Company { name: "Acme" } +} +return { $c.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + // Should be: NodeScan($p,[name=Alice]) → Expand($p→$f, [name==Bob]) + // → Expand($f→$c, [name==Acme]) + assert_eq!(ir.pipeline.len(), 3); + assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); + assert!(matches!( + &ir.pipeline[1], + IROp::Expand { src_var, dst_var, dst_filters, .. } + if src_var == "p" && dst_var == "f" && dst_filters.len() == 1 + )); + assert!(matches!( + &ir.pipeline[2], + IROp::Expand { src_var, dst_var, dst_filters, .. } + if src_var == "f" && dst_var == "c" && dst_filters.len() == 1 + )); +} + +/// Reverse traversal: source binding is deferred when destination is the root. +#[test] +fn test_lower_reverse_traversal_defers_source_binding() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $c: Company { name: "Acme" } + $p worksAt $c + $p: Person { name: "Alice" } +} +return { $p.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + // $c is root (first declared). $p is deferred (connected via traversal). + // Traversal $p worksAt $c: $c is bound, $p is not → reverse expand. + // Pipeline: NodeScan($c,[name=Acme]) → Expand($c→$p, In, [name==Alice]) + assert_eq!(ir.pipeline.len(), 2); + assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "c")); + assert!(matches!( + &ir.pipeline[1], + IROp::Expand { src_var, dst_var, dst_filters, .. } + if src_var == "c" && dst_var == "p" && dst_filters.len() == 1 + )); +} + +/// Independent bindings (no traversal) still cross-join. +#[test] +fn test_lower_independent_bindings_still_cross_join() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $c: Company +} +return { $p.name, $c.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + // No traversal connecting them → both get NodeScans (cross-join at runtime) + assert_eq!(ir.pipeline.len(), 2); + assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); + assert!(matches!(&ir.pipeline[1], IROp::NodeScan { variable, .. } if variable == "c")); +} + +/// Destination binding without filters: no NodeScan, no post-expand filter. +#[test] +fn test_lower_destination_binding_without_filters() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p worksAt $c + $c: Company +} +return { $p.name, $c.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + // $c binding is deferred (no filters) → just NodeScan + Expand + assert_eq!(ir.pipeline.len(), 2); + assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); + assert!(matches!( + &ir.pipeline[1], + IROp::Expand { src_var, dst_var, .. } + if src_var == "p" && dst_var == "c" + )); +} + +/// Traversals declared in non-topological order are reordered automatically. +#[test] +fn test_lower_out_of_order_traversals() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $f worksAt $c + $p knows $f + $f: Person + $c: Company { name: "Acme" } +} +return { $c.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + // Even though "$f worksAt $c" is declared before "$p knows $f", + // the iterative lowering processes "$p knows $f" first (because $p + // is bound) and then "$f worksAt $c" (once $f is bound). + assert_eq!(ir.pipeline.len(), 3); + assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); + // First expand: $p → $f (knows) + assert!(matches!( + &ir.pipeline[1], + IROp::Expand { src_var, dst_var, .. } + if src_var == "p" && dst_var == "f" + )); + // Second expand: $f → $c (worksAt), with filter from $c binding + assert!(matches!( + &ir.pipeline[2], + IROp::Expand { src_var, dst_var, dst_filters, .. } + if src_var == "f" && dst_var == "c" && dst_filters.len() == 1 + )); +} + +/// Wildcard $_ must not bridge unrelated components in the adjacency graph. +#[test] +fn test_lower_wildcard_does_not_bridge_components() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p knows $_ + $c: Company +} +return { $p.name, $c.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + // $p and $c are in separate components (connected only through $_). + // Both must get their own NodeScan — $c must NOT be deferred. + // Bindings are emitted first, then traversals. + assert_eq!(ir.pipeline.len(), 3); + assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); + assert!(matches!(&ir.pipeline[1], IROp::NodeScan { variable, .. } if variable == "c")); + // The expand for $p knows $_ (wildcard destination) + assert!(matches!( + &ir.pipeline[2], + IROp::Expand { src_var, dst_var, .. } + if src_var == "p" && dst_var == "_" + )); +} + +/// Fan-out: one root fans to two deferred destinations via different edges. +#[test] +fn test_lower_fan_out_topology() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person { name: "Alice" } + $p knows $f + $f: Person { name: "Bob" } + $p worksAt $c + $c: Company { name: "Acme" } +} +return { $f.name, $c.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + // Root: $p. Deferred: $f, $c (both reachable from $p). + assert_eq!(ir.pipeline.len(), 3); + assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); + assert!(matches!( + &ir.pipeline[1], + IROp::Expand { src_var, dst_var, dst_filters, .. } + if src_var == "p" && dst_var == "f" && dst_filters.len() == 1 + )); + assert!(matches!( + &ir.pipeline[2], + IROp::Expand { src_var, dst_var, dst_filters, .. } + if src_var == "p" && dst_var == "c" && dst_filters.len() == 1 + )); +} + +/// Fan-in: two sources converge on one destination; second source is +/// introduced via reverse expand from the shared destination. +#[test] +fn test_lower_fan_in_topology() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $a: Person { name: "Alice" } + $a knows $c + $b: Person { name: "Bob" } + $b knows $c + $c: Person +} +return { $a.name, $b.name, $c.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + // Root: $a (first in component {a,b,c}). Deferred: $b, $c. + // $a knows $c: expand(a→c). $b knows $c: reverse expand(c→b). + assert_eq!(ir.pipeline.len(), 3); + assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "a")); + assert!(matches!( + &ir.pipeline[1], + IROp::Expand { src_var, dst_var, dst_filters, .. } + if src_var == "a" && dst_var == "c" && dst_filters.is_empty() + )); + assert!(matches!( + &ir.pipeline[2], + IROp::Expand { src_var, dst_var, dst_filters, .. } + if src_var == "c" && dst_var == "b" && dst_filters.len() == 1 + )); +} + +/// Genuine graph cycle: deferred binding is introduced by first traversal, +/// second traversal triggers cycle-closing. +#[test] +fn test_lower_cycle_with_deferred_binding() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $a: Person + $a knows $b + $b: Person { name: "Bob" } + $b knows $a +} +return { $a.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + // $b is deferred, introduced by first expand. + // Second traversal ($b knows $a) is genuine cycle-closing. + assert_eq!(ir.pipeline.len(), 4); + assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "a")); + assert!(matches!( + &ir.pipeline[1], + IROp::Expand { src_var, dst_var, dst_filters, .. } + if src_var == "a" && dst_var == "b" && dst_filters.len() == 1 + )); + // Cycle-closing expand to __temp_a + assert!(matches!( + &ir.pipeline[2], + IROp::Expand { src_var, dst_var, dst_filters, .. } + if src_var == "b" && dst_var.starts_with("__temp_") && dst_filters.is_empty() + )); + // Cycle-closing filter: __temp_a.id == a.id + assert!(matches!(&ir.pipeline[3], IROp::Filter(_))); +} + +/// Multiple filters on a single deferred binding. +#[test] +fn test_lower_multiple_filters_on_deferred_binding() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p knows $f + $f: Person { name: "Bob", age: 25 } +} +return { $f.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + // Two prop_matches → two dst_filters on the Expand. + assert_eq!(ir.pipeline.len(), 2); + assert!(matches!( + &ir.pipeline[1], + IROp::Expand { dst_filters, .. } + if dst_filters.len() == 2 + )); +} + +/// Parameter in a deferred binding filter (unit test level). +#[test] +fn test_lower_param_filter_on_deferred_binding() { + let catalog = setup(); + let qf = parse_query( + r#" +query q($company: String) { +match { + $p: Person + $p worksAt $c + $c: Company { name: $company } +} +return { $p.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + assert_eq!(ir.pipeline.len(), 2); + assert!(matches!( + &ir.pipeline[1], + IROp::Expand { dst_filters, .. } + if dst_filters.len() == 1 + )); + // The filter's right-hand side should be a Param, not a Literal + if let IROp::Expand { dst_filters, .. } = &ir.pipeline[1] { + assert!(matches!(&dst_filters[0].right, IRExpr::Param(name) if name == "company")); + } +} + +/// Negation with inner binding: inner binding is NOT deferred because +/// bound_vars (from outer scope) is not in binding_set for the inner call. +/// This documents current behavior — the inner pipeline uses a NodeScan + +/// cycle-closing, which is correct but less efficient than deferral. +#[test] +fn test_lower_negation_with_inner_binding() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + not { + $p worksAt $c + $c: Company { name: "Acme" } + } +} +return { $p.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + + // Outer: NodeScan($p) + AntiJoin + assert_eq!(ir.pipeline.len(), 2); + assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p")); + let IROp::AntiJoin { inner, .. } = &ir.pipeline[1] else { + panic!("expected AntiJoin"); + }; + // Inner pipeline: $c is NOT deferred (it's the only binding in the + // inner scope), so it gets a NodeScan + cycle-closing (3 ops). + assert_eq!(inner.len(), 3); + assert!(matches!(&inner[0], IROp::NodeScan { variable, .. } if variable == "c")); + assert!(matches!(&inner[1], IROp::Expand { .. })); + assert!(matches!(&inner[2], IROp::Filter(_))); +} diff --git a/crates/omnigraph-compiler/src/query/lint.rs b/crates/omnigraph-compiler/src/query/lint.rs index 9805449..38ae6ee 100644 --- a/crates/omnigraph-compiler/src/query/lint.rs +++ b/crates/omnigraph-compiler/src/query/lint.rs @@ -310,253 +310,5 @@ fn severity_rank(severity: QueryLintSeverity) -> u8 { } #[cfg(test)] -mod tests { - use super::*; - use crate::build_catalog; - use crate::schema::parser::parse_schema; - - fn catalog(schema: &str) -> Catalog { - let schema = parse_schema(schema).unwrap(); - build_catalog(&schema).unwrap() - } - - #[test] - fn parse_failure_returns_structured_error_output() { - let output = lint_query_file( - &catalog("node Person { name: String }"), - "query broken(", - "/tmp/queries.gq", - QueryLintSchemaSource::file("/tmp/schema.pg"), - ); - - assert_eq!(output.status, QueryLintStatus::Error); - assert_eq!(output.queries_processed, 0); - assert_eq!(output.errors, 1); - assert!(output.results.is_empty()); - assert_eq!(output.findings.len(), 1); - assert_eq!(output.findings[0].severity, QueryLintSeverity::Error); - assert_eq!(output.findings[0].code, PARSE_ERROR_CODE); - } - - #[test] - fn mixed_valid_and_invalid_queries_preserve_per_query_results() { - let output = lint_query_file( - &catalog( - r#" -node Person { - slug: String @key - name: String? -} -"#, - ), - r#" -query list_people() { - match { $p: Person } - return { $p.name } -} - -query bad_update($slug: String) { - update Person set { missing: "nope" } where slug = $slug -} -"#, - "/tmp/queries.gq", - QueryLintSchemaSource::file("/tmp/schema.pg"), - ); - - assert_eq!(output.queries_processed, 2); - assert_eq!(output.results[0].name, "list_people"); - assert_eq!(output.results[0].status, QueryLintStatus::Ok); - assert_eq!(output.results[1].name, "bad_update"); - assert_eq!(output.results[1].status, QueryLintStatus::Error); - assert!( - output.results[1] - .error - .as_deref() - .unwrap_or_default() - .contains("has no property") - ); - } - - #[test] - fn hardcoded_mutation_warning_only_fires_for_mutation_queries() { - let output = lint_query_file( - &catalog( - r#" -node Person { - slug: String @key - name: String? -} -"#, - ), - r#" -query list_people() { - match { $p: Person } - return { $p.name } -} - -query insert_person() { - insert Person { slug: "p1", name: "P1" } -} -"#, - "/tmp/queries.gq", - QueryLintSchemaSource::file("/tmp/schema.pg"), - ); - - assert!(output.results[0].warnings.is_empty()); - assert_eq!( - output.results[1].warnings, - vec![HARDCODED_MUTATION_WARNING.to_string()] - ); - assert_eq!(output.warnings, 1); - } - - #[test] - fn l201_warns_for_nullable_uncovered_update_fields() { - let output = lint_query_file( - &catalog( - r#" -node Policy { - slug: String @key - name: String? - effectiveTo: DateTime? -} -"#, - ), - r#" -query update_policy($slug: String, $name: String) { - update Policy set { name: $name } where slug = $slug -} -"#, - "/tmp/queries.gq", - QueryLintSchemaSource::file("/tmp/schema.pg"), - ); - - assert_eq!(output.findings.len(), 1); - assert_eq!(output.findings[0].code, L201_CODE); - assert_eq!( - output.findings[0].message, - "Policy.effectiveTo exists in schema but no update query sets it" - ); - assert_eq!(output.findings[0].query_names, vec!["update_policy"]); - } - - #[test] - fn l201_does_not_fire_without_valid_update_queries() { - let output = lint_query_file( - &catalog( - r#" -node Policy { - slug: String @key - effectiveTo: DateTime? -} -"#, - ), - r#" -query insert_policy($slug: String) { - insert Policy { slug: $slug } -} -"#, - "/tmp/queries.gq", - QueryLintSchemaSource::file("/tmp/schema.pg"), - ); - - assert!(output.findings.is_empty()); - } - - #[test] - fn l201_excludes_embed_target_properties() { - let output = lint_query_file( - &catalog( - r#" -node Doc { - slug: String @key - body: String? - summary: String? - embedding: Vector(3)? @embed(body) -} -"#, - ), - r#" -query update_doc($slug: String, $body: String) { - update Doc set { body: $body } where slug = $slug -} -"#, - "/tmp/queries.gq", - QueryLintSchemaSource::file("/tmp/schema.pg"), - ); - - assert_eq!(output.findings.len(), 1); - assert_eq!(output.findings[0].property.as_deref(), Some("summary")); - } - - #[test] - fn l201_excludes_key_properties_even_if_catalog_is_modified() { - let mut catalog = catalog( - r#" -node Policy { - slug: String @key - name: String? -} -"#, - ); - catalog - .node_types - .get_mut("Policy") - .unwrap() - .properties - .get_mut("slug") - .unwrap() - .nullable = true; - - let output = lint_query_file( - &catalog, - r#" -query update_policy($slug: String, $name: String) { - update Policy set { name: $name } where slug = $slug -} -"#, - "/tmp/queries.gq", - QueryLintSchemaSource::file("/tmp/schema.pg"), - ); - - assert!( - output - .findings - .iter() - .all(|finding| finding.property.as_deref() != Some("slug")) - ); - } - - #[test] - fn findings_and_query_names_are_deterministic() { - let output = lint_query_file( - &catalog( - r#" -node Policy { - slug: String @key - c_field: String? - b_field: String? - a_field: String? -} -"#, - ), - &r#" -query update_b($slug: String) { - update Policy set { a_field: "x" } where slug = $slug -} - -query update_a($slug: String) { - update Policy set { a_field: "x" } where slug = $slug -} -"#, - "/tmp/queries.gq", - QueryLintSchemaSource::file("/tmp/schema.pg"), - ); - - assert_eq!(output.findings.len(), 2); - assert_eq!(output.findings[0].property.as_deref(), Some("b_field")); - assert_eq!(output.findings[1].property.as_deref(), Some("c_field")); - assert_eq!(output.findings[0].query_names, vec!["update_a", "update_b"]); - assert_eq!(output.findings[1].query_names, vec!["update_a", "update_b"]); - } -} +#[path = "lint_tests.rs"] +mod tests; diff --git a/crates/omnigraph-compiler/src/query/lint_tests.rs b/crates/omnigraph-compiler/src/query/lint_tests.rs new file mode 100644 index 0000000..fa9fba0 --- /dev/null +++ b/crates/omnigraph-compiler/src/query/lint_tests.rs @@ -0,0 +1,248 @@ +use super::*; +use crate::build_catalog; +use crate::schema::parser::parse_schema; + +fn catalog(schema: &str) -> Catalog { + let schema = parse_schema(schema).unwrap(); + build_catalog(&schema).unwrap() +} + +#[test] +fn parse_failure_returns_structured_error_output() { + let output = lint_query_file( + &catalog("node Person { name: String }"), + "query broken(", + "/tmp/queries.gq", + QueryLintSchemaSource::file("/tmp/schema.pg"), + ); + + assert_eq!(output.status, QueryLintStatus::Error); + assert_eq!(output.queries_processed, 0); + assert_eq!(output.errors, 1); + assert!(output.results.is_empty()); + assert_eq!(output.findings.len(), 1); + assert_eq!(output.findings[0].severity, QueryLintSeverity::Error); + assert_eq!(output.findings[0].code, PARSE_ERROR_CODE); +} + +#[test] +fn mixed_valid_and_invalid_queries_preserve_per_query_results() { + let output = lint_query_file( + &catalog( + r#" +node Person { +slug: String @key +name: String? +} +"#, + ), + r#" +query list_people() { +match { $p: Person } +return { $p.name } +} + +query bad_update($slug: String) { +update Person set { missing: "nope" } where slug = $slug +} +"#, + "/tmp/queries.gq", + QueryLintSchemaSource::file("/tmp/schema.pg"), + ); + + assert_eq!(output.queries_processed, 2); + assert_eq!(output.results[0].name, "list_people"); + assert_eq!(output.results[0].status, QueryLintStatus::Ok); + assert_eq!(output.results[1].name, "bad_update"); + assert_eq!(output.results[1].status, QueryLintStatus::Error); + assert!( + output.results[1] + .error + .as_deref() + .unwrap_or_default() + .contains("has no property") + ); +} + +#[test] +fn hardcoded_mutation_warning_only_fires_for_mutation_queries() { + let output = lint_query_file( + &catalog( + r#" +node Person { +slug: String @key +name: String? +} +"#, + ), + r#" +query list_people() { +match { $p: Person } +return { $p.name } +} + +query insert_person() { +insert Person { slug: "p1", name: "P1" } +} +"#, + "/tmp/queries.gq", + QueryLintSchemaSource::file("/tmp/schema.pg"), + ); + + assert!(output.results[0].warnings.is_empty()); + assert_eq!( + output.results[1].warnings, + vec![HARDCODED_MUTATION_WARNING.to_string()] + ); + assert_eq!(output.warnings, 1); +} + +#[test] +fn l201_warns_for_nullable_uncovered_update_fields() { + let output = lint_query_file( + &catalog( + r#" +node Policy { +slug: String @key +name: String? +effectiveTo: DateTime? +} +"#, + ), + r#" +query update_policy($slug: String, $name: String) { +update Policy set { name: $name } where slug = $slug +} +"#, + "/tmp/queries.gq", + QueryLintSchemaSource::file("/tmp/schema.pg"), + ); + + assert_eq!(output.findings.len(), 1); + assert_eq!(output.findings[0].code, L201_CODE); + assert_eq!( + output.findings[0].message, + "Policy.effectiveTo exists in schema but no update query sets it" + ); + assert_eq!(output.findings[0].query_names, vec!["update_policy"]); +} + +#[test] +fn l201_does_not_fire_without_valid_update_queries() { + let output = lint_query_file( + &catalog( + r#" +node Policy { +slug: String @key +effectiveTo: DateTime? +} +"#, + ), + r#" +query insert_policy($slug: String) { +insert Policy { slug: $slug } +} +"#, + "/tmp/queries.gq", + QueryLintSchemaSource::file("/tmp/schema.pg"), + ); + + assert!(output.findings.is_empty()); +} + +#[test] +fn l201_excludes_embed_target_properties() { + let output = lint_query_file( + &catalog( + r#" +node Doc { +slug: String @key +body: String? +summary: String? +embedding: Vector(3)? @embed(body) +} +"#, + ), + r#" +query update_doc($slug: String, $body: String) { +update Doc set { body: $body } where slug = $slug +} +"#, + "/tmp/queries.gq", + QueryLintSchemaSource::file("/tmp/schema.pg"), + ); + + assert_eq!(output.findings.len(), 1); + assert_eq!(output.findings[0].property.as_deref(), Some("summary")); +} + +#[test] +fn l201_excludes_key_properties_even_if_catalog_is_modified() { + let mut catalog = catalog( + r#" +node Policy { +slug: String @key +name: String? +} +"#, + ); + catalog + .node_types + .get_mut("Policy") + .unwrap() + .properties + .get_mut("slug") + .unwrap() + .nullable = true; + + let output = lint_query_file( + &catalog, + r#" +query update_policy($slug: String, $name: String) { +update Policy set { name: $name } where slug = $slug +} +"#, + "/tmp/queries.gq", + QueryLintSchemaSource::file("/tmp/schema.pg"), + ); + + assert!( + output + .findings + .iter() + .all(|finding| finding.property.as_deref() != Some("slug")) + ); +} + +#[test] +fn findings_and_query_names_are_deterministic() { + let output = lint_query_file( + &catalog( + r#" +node Policy { +slug: String @key +c_field: String? +b_field: String? +a_field: String? +} +"#, + ), + &r#" +query update_b($slug: String) { +update Policy set { a_field: "x" } where slug = $slug +} + +query update_a($slug: String) { +update Policy set { a_field: "x" } where slug = $slug +} +"#, + "/tmp/queries.gq", + QueryLintSchemaSource::file("/tmp/schema.pg"), + ); + + assert_eq!(output.findings.len(), 2); + assert_eq!(output.findings[0].property.as_deref(), Some("b_field")); + assert_eq!(output.findings[1].property.as_deref(), Some("c_field")); + assert_eq!(output.findings[0].query_names, vec!["update_a", "update_b"]); + assert_eq!(output.findings[1].query_names, vec!["update_a", "update_b"]); +}