From d4b21ce4ebfd42eb975a3e1f2cc879d455a23fe6 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Sun, 5 Jul 2026 22:46:08 +0300 Subject: [PATCH] =?UTF-8?q?feat(compiler):=20parse=20+=20typecheck=20undir?= =?UTF-8?q?ected=20traversal=20=E2=80=94=20`$a=20=20$b`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iss-gq-undirected-traversal, the Expand-internal design (no plan-level Union; unblocked from iss-744/579): the grammar gains an undirected_edge alternative in the traversal rule (angle brackets are collision-free — comparisons live in the structurally separate filter production), the AST Traversal carries `undirected`, Direction gains `Both`, and typecheck resolves undirected patterns to Both after the new T22 rule: undirected requires a same-endpoint-type edge (an asymmetric edge is well-typed in at most one orientation, so the form is rejected with guidance to use the directional pattern). Lowering passes Both through; the reverse-expand orientation flip is a no-op for a symmetric traversal. Bounds ({min,max}) and not{} compose unchanged. Direction has no serde derives and IR never crosses a wire — no compatibility surface. Parser/typecheck tests cover the bare, bounded, inside-not forms, Both resolution on Knows (Person->Person), and the T22 rejection on WorksAt (Person->Company). --- crates/omnigraph-compiler/src/ir/lower.rs | 6 +++ crates/omnigraph-compiler/src/query/ast.rs | 3 ++ crates/omnigraph-compiler/src/query/parser.rs | 11 ++++- .../src/query/parser_tests.rs | 40 ++++++++++++++++++ .../omnigraph-compiler/src/query/query.pest | 6 ++- .../omnigraph-compiler/src/query/typecheck.rs | 19 ++++++++- .../src/query/typecheck_tests.rs | 41 +++++++++++++++++++ crates/omnigraph-compiler/src/types.rs | 3 ++ 8 files changed, 125 insertions(+), 4 deletions(-) diff --git a/crates/omnigraph-compiler/src/ir/lower.rs b/crates/omnigraph-compiler/src/ir/lower.rs index 9427e27f..1f00f96c 100644 --- a/crates/omnigraph-compiler/src/ir/lower.rs +++ b/crates/omnigraph-compiler/src/ir/lower.rs @@ -279,6 +279,9 @@ fn lower_clauses( let dst_type = match direction { Direction::Out => edge.to_type.clone(), Direction::In => edge.from_type.clone(), + // Undirected requires from_type == to_type (typecheck rule), + // so either endpoint type is correct. + Direction::Both => edge.to_type.clone(), }; if src_bound && dst_bound { @@ -310,10 +313,13 @@ fn lower_clauses( let reverse_dir = match direction { Direction::Out => Direction::In, Direction::In => Direction::Out, + // Symmetric: reversing an undirected expand is a no-op. + Direction::Both => Direction::Both, }; let src_type = match direction { Direction::Out => edge.from_type.clone(), Direction::In => edge.to_type.clone(), + Direction::Both => edge.from_type.clone(), }; let introduced_filters = deferred_filters.remove(&traversal.src).unwrap_or_default(); diff --git a/crates/omnigraph-compiler/src/query/ast.rs b/crates/omnigraph-compiler/src/query/ast.rs index f0bbdb5b..802f7f4a 100644 --- a/crates/omnigraph-compiler/src/query/ast.rs +++ b/crates/omnigraph-compiler/src/query/ast.rs @@ -60,6 +60,9 @@ pub struct Traversal { pub dst: String, pub min_hops: u32, pub max_hops: Option, + /// `$a $b` — match the edge in either direction (set semantics; + /// same-endpoint-type edges only, enforced at typecheck). + pub undirected: bool, } #[derive(Debug, Clone)] diff --git a/crates/omnigraph-compiler/src/query/parser.rs b/crates/omnigraph-compiler/src/query/parser.rs index 3284876c..3330cea6 100644 --- a/crates/omnigraph-compiler/src/query/parser.rs +++ b/crates/omnigraph-compiler/src/query/parser.rs @@ -427,7 +427,15 @@ fn parse_traversal(pair: pest::iterators::Pair) -> Result { let mut inner = pair.into_inner(); let src_var = inner.next().unwrap().as_str(); let src = src_var.strip_prefix('$').unwrap_or(src_var).to_string(); - let edge_name = inner.next().unwrap().as_str().to_string(); + let edge_pair = inner.next().unwrap(); + let (edge_name, undirected) = match edge_pair.as_rule() { + // `` — the inner edge_ident carries the name. + Rule::undirected_edge => ( + edge_pair.into_inner().next().unwrap().as_str().to_string(), + true, + ), + _ => (edge_pair.as_str().to_string(), false), + }; let mut min_hops = 1u32; let mut max_hops = Some(1u32); @@ -452,6 +460,7 @@ fn parse_traversal(pair: pest::iterators::Pair) -> Result { dst, min_hops, max_hops, + undirected, }) } diff --git a/crates/omnigraph-compiler/src/query/parser_tests.rs b/crates/omnigraph-compiler/src/query/parser_tests.rs index 5267a367..45a0832c 100644 --- a/crates/omnigraph-compiler/src/query/parser_tests.rs +++ b/crates/omnigraph-compiler/src/query/parser_tests.rs @@ -83,6 +83,46 @@ order { $p.age desc } assert!(q.order_clause[0].descending); } +#[test] +fn test_parse_undirected_traversal() { + // `$a $b`, bare + bounded + inside not{}. + let input = r#" +query related($name: String) { +match { + $p: Person { name: $name } + $p $f + $p {1,3} $g + not { $f $g } +} +return { $f.name } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match &q.match_clause[1] { + Clause::Traversal(t) => { + assert_eq!(t.edge_name, "knows"); + assert!(t.undirected, "bare undirected form"); + assert_eq!((t.min_hops, t.max_hops), (1, Some(1))); + } + c => panic!("expected Traversal, got {c:?}"), + } + match &q.match_clause[2] { + Clause::Traversal(t) => { + assert!(t.undirected, "bounded undirected form"); + assert_eq!((t.min_hops, t.max_hops), (1, Some(3))); + } + c => panic!("expected Traversal, got {c:?}"), + } + match &q.match_clause[3] { + Clause::Negation(inner) => match &inner[0] { + Clause::Traversal(t) => assert!(t.undirected, "undirected inside not{{}}"), + c => panic!("expected Traversal in not, got {c:?}"), + }, + c => panic!("expected Negation, got {c:?}"), + } +} + #[test] fn test_parse_traversal() { let input = r#" diff --git a/crates/omnigraph-compiler/src/query/query.pest b/crates/omnigraph-compiler/src/query/query.pest index 353c226a..8818838d 100644 --- a/crates/omnigraph-compiler/src/query/query.pest +++ b/crates/omnigraph-compiler/src/query/query.pest @@ -52,8 +52,10 @@ prop_match_list = { prop_match ~ ("," ~ prop_match)* ~ ","? } prop_match = { ident ~ ":" ~ match_value } match_value = { literal | variable | now_call } -// Traversal: $p knows $f -traversal = { variable ~ edge_ident ~ traversal_bounds? ~ variable } +// Traversal: $p knows $f (directional) or $p $f (undirected — +// matches the edge in either direction; same-endpoint-type edges only). +traversal = { variable ~ (undirected_edge | edge_ident) ~ traversal_bounds? ~ variable } +undirected_edge = { "<" ~ edge_ident ~ ">" } traversal_bounds = { "{" ~ integer ~ "," ~ integer? ~ "}" } // Filter: $f.age > 25 diff --git a/crates/omnigraph-compiler/src/query/typecheck.rs b/crates/omnigraph-compiler/src/query/typecheck.rs index 2ac16044..91db57bb 100644 --- a/crates/omnigraph-compiler/src/query/typecheck.rs +++ b/crates/omnigraph-compiler/src/query/typecheck.rs @@ -767,11 +767,22 @@ fn typecheck_traversal( )); } + // Undirected (`$a $b`): only meaningful when both orientations + // carry the same endpoint types — for an asymmetric edge the pattern is + // well-typed in at most one direction, so the undirected form is either + // pointless or a type error; require the directional form instead. + if traversal.undirected && edge.from_type != edge.to_type { + return Err(CompilerError::Type(format!( + "T22: undirected traversal `<{}>` requires a same-endpoint-type edge, but `{}: {} -> {}` is asymmetric; use the directional form", + edge.name, edge.name, edge.from_type, edge.to_type + ))); + } + // Determine direction based on bound variables and edge endpoints let src_bound = ctx.bindings.get(&traversal.src); let dst_bound = ctx.bindings.get(&traversal.dst); - let direction; + let mut direction; if let Some(src_bv) = src_bound { // T5: src type must match one endpoint of the edge @@ -810,6 +821,12 @@ fn typecheck_traversal( bind_traversal_endpoint(ctx, &traversal.dst, &edge.to_type, edge)?; } + if traversal.undirected { + // The orientation inference above is a no-op for a same-type edge + // (both arms resolve identically); the user asked for both ways. + direction = Direction::Both; + } + ctx.traversals.push(ResolvedTraversal { src: traversal.src.clone(), dst: traversal.dst.clone(), diff --git a/crates/omnigraph-compiler/src/query/typecheck_tests.rs b/crates/omnigraph-compiler/src/query/typecheck_tests.rs index 7fa8f946..63a0095a 100644 --- a/crates/omnigraph-compiler/src/query/typecheck_tests.rs +++ b/crates/omnigraph-compiler/src/query/typecheck_tests.rs @@ -725,6 +725,47 @@ return { sum($p.name) as s } assert!(err.to_string().contains("T8")); } +#[test] +fn test_undirected_traversal_resolves_both_on_same_type_edge() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person { name: "Alice" } + $p $f +} +return { $f.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert_eq!(ctx.traversals[0].direction, Direction::Both); + assert_eq!(ctx.bindings["f"].type_name, "Person"); +} + +#[test] +fn test_undirected_traversal_rejected_on_asymmetric_edge() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person { name: "Alice" } + $p $c +} +return { $c.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("T22"), "expected T22, got: {msg}"); + assert!(msg.contains("WorksAt"), "names the edge type: {msg}"); +} + #[test] fn test_traversal_direction_out() { let catalog = setup(); diff --git a/crates/omnigraph-compiler/src/types.rs b/crates/omnigraph-compiler/src/types.rs index 5140acc6..2f58312d 100644 --- a/crates/omnigraph-compiler/src/types.rs +++ b/crates/omnigraph-compiler/src/types.rs @@ -186,6 +186,9 @@ impl PropType { pub enum Direction { Out, In, + /// Undirected: traverse the edge both ways, deduplicated per source + /// (`$a $b`). Only valid on same-endpoint-type edges. + Both, } #[cfg(test)]