feat(compiler): parse + typecheck undirected traversal — $a <edge> $b

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).
This commit is contained in:
aaltshuler 2026-07-05 22:46:08 +03:00 committed by Andrew Altshuler
parent db217e7db2
commit d4b21ce4eb
8 changed files with 125 additions and 4 deletions

View file

@ -83,6 +83,46 @@ order { $p.age desc }
assert!(q.order_clause[0].descending);
}
#[test]
fn test_parse_undirected_traversal() {
// `$a <edge> $b`, bare + bounded + inside not{}.
let input = r#"
query related($name: String) {
match {
$p: Person { name: $name }
$p <knows> $f
$p <knows>{1,3} $g
not { $f <knows> $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#"