Merge pull request #42 from ModernRelay/refactor/extract-compiler-tests-p3

Extract remaining crowded compiler tests (Phase 3)
This commit is contained in:
Andrew Altshuler 2026-04-20 23:09:14 +03:00 committed by GitHub
commit 674eee16bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1184 additions and 1184 deletions

View file

@ -322,273 +322,5 @@ pub fn build_catalog(schema: &SchemaFile) -> Result<Catalog> {
}
#[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;

View file

@ -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()]));
}

View file

@ -573,667 +573,5 @@ fn lower_match_value(value: &MatchValue, param_names: &HashSet<String>) -> 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;

View file

@ -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(_)));
}

View file

@ -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;

View file

@ -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"]);
}