mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-07-12 03:12:11 +02:00
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:
parent
db217e7db2
commit
d4b21ce4eb
8 changed files with 125 additions and 4 deletions
|
|
@ -279,6 +279,9 @@ fn lower_clauses(
|
||||||
let dst_type = match direction {
|
let dst_type = match direction {
|
||||||
Direction::Out => edge.to_type.clone(),
|
Direction::Out => edge.to_type.clone(),
|
||||||
Direction::In => edge.from_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 {
|
if src_bound && dst_bound {
|
||||||
|
|
@ -310,10 +313,13 @@ fn lower_clauses(
|
||||||
let reverse_dir = match direction {
|
let reverse_dir = match direction {
|
||||||
Direction::Out => Direction::In,
|
Direction::Out => Direction::In,
|
||||||
Direction::In => Direction::Out,
|
Direction::In => Direction::Out,
|
||||||
|
// Symmetric: reversing an undirected expand is a no-op.
|
||||||
|
Direction::Both => Direction::Both,
|
||||||
};
|
};
|
||||||
let src_type = match direction {
|
let src_type = match direction {
|
||||||
Direction::Out => edge.from_type.clone(),
|
Direction::Out => edge.from_type.clone(),
|
||||||
Direction::In => edge.to_type.clone(),
|
Direction::In => edge.to_type.clone(),
|
||||||
|
Direction::Both => edge.from_type.clone(),
|
||||||
};
|
};
|
||||||
let introduced_filters =
|
let introduced_filters =
|
||||||
deferred_filters.remove(&traversal.src).unwrap_or_default();
|
deferred_filters.remove(&traversal.src).unwrap_or_default();
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,9 @@ pub struct Traversal {
|
||||||
pub dst: String,
|
pub dst: String,
|
||||||
pub min_hops: u32,
|
pub min_hops: u32,
|
||||||
pub max_hops: Option<u32>,
|
pub max_hops: Option<u32>,
|
||||||
|
/// `$a <edge> $b` — match the edge in either direction (set semantics;
|
||||||
|
/// same-endpoint-type edges only, enforced at typecheck).
|
||||||
|
pub undirected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,15 @@ fn parse_traversal(pair: pest::iterators::Pair<Rule>) -> Result<Traversal> {
|
||||||
let mut inner = pair.into_inner();
|
let mut inner = pair.into_inner();
|
||||||
let src_var = inner.next().unwrap().as_str();
|
let src_var = inner.next().unwrap().as_str();
|
||||||
let src = src_var.strip_prefix('$').unwrap_or(src_var).to_string();
|
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() {
|
||||||
|
// `<edge>` — 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 min_hops = 1u32;
|
||||||
let mut max_hops = Some(1u32);
|
let mut max_hops = Some(1u32);
|
||||||
|
|
||||||
|
|
@ -452,6 +460,7 @@ fn parse_traversal(pair: pest::iterators::Pair<Rule>) -> Result<Traversal> {
|
||||||
dst,
|
dst,
|
||||||
min_hops,
|
min_hops,
|
||||||
max_hops,
|
max_hops,
|
||||||
|
undirected,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,46 @@ order { $p.age desc }
|
||||||
assert!(q.order_clause[0].descending);
|
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]
|
#[test]
|
||||||
fn test_parse_traversal() {
|
fn test_parse_traversal() {
|
||||||
let input = r#"
|
let input = r#"
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,10 @@ prop_match_list = { prop_match ~ ("," ~ prop_match)* ~ ","? }
|
||||||
prop_match = { ident ~ ":" ~ match_value }
|
prop_match = { ident ~ ":" ~ match_value }
|
||||||
match_value = { literal | variable | now_call }
|
match_value = { literal | variable | now_call }
|
||||||
|
|
||||||
// Traversal: $p knows $f
|
// Traversal: $p knows $f (directional) or $p <knows> $f (undirected —
|
||||||
traversal = { variable ~ edge_ident ~ traversal_bounds? ~ variable }
|
// 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? ~ "}" }
|
traversal_bounds = { "{" ~ integer ~ "," ~ integer? ~ "}" }
|
||||||
|
|
||||||
// Filter: $f.age > 25
|
// Filter: $f.age > 25
|
||||||
|
|
|
||||||
|
|
@ -767,11 +767,22 @@ fn typecheck_traversal(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Undirected (`$a <edge> $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
|
// Determine direction based on bound variables and edge endpoints
|
||||||
let src_bound = ctx.bindings.get(&traversal.src);
|
let src_bound = ctx.bindings.get(&traversal.src);
|
||||||
let dst_bound = ctx.bindings.get(&traversal.dst);
|
let dst_bound = ctx.bindings.get(&traversal.dst);
|
||||||
|
|
||||||
let direction;
|
let mut direction;
|
||||||
|
|
||||||
if let Some(src_bv) = src_bound {
|
if let Some(src_bv) = src_bound {
|
||||||
// T5: src type must match one endpoint of the edge
|
// 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)?;
|
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 {
|
ctx.traversals.push(ResolvedTraversal {
|
||||||
src: traversal.src.clone(),
|
src: traversal.src.clone(),
|
||||||
dst: traversal.dst.clone(),
|
dst: traversal.dst.clone(),
|
||||||
|
|
|
||||||
|
|
@ -725,6 +725,47 @@ return { sum($p.name) as s }
|
||||||
assert!(err.to_string().contains("T8"));
|
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 <knows> $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 <worksAt> $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]
|
#[test]
|
||||||
fn test_traversal_direction_out() {
|
fn test_traversal_direction_out() {
|
||||||
let catalog = setup();
|
let catalog = setup();
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,9 @@ impl PropType {
|
||||||
pub enum Direction {
|
pub enum Direction {
|
||||||
Out,
|
Out,
|
||||||
In,
|
In,
|
||||||
|
/// Undirected: traverse the edge both ways, deduplicated per source
|
||||||
|
/// (`$a <edge> $b`). Only valid on same-endpoint-type edges.
|
||||||
|
Both,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue