diff --git a/CHANGELOG.md b/CHANGELOG.md index 78c4021b..505de8fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### In Development — v0.5.0 (Rust Core, `rust-core` branch) +- Node equality + unlabelled-endpoint property join (#598) — `WHERE a = b` / + `a <> b` over node variables now compares node identity (`node_uuid`) instead + of erroring on the bare multi-column var qualifier; and an already-bound + UNLABELLED endpoint (the `x` in `(n)-[r]->(x)`) now gets its properties joined + (`_untyped` in exploratory mode), so `WHERE x.p = …` / `RETURN x.p` resolves. + Un-skips `MatchWhere3` and `WithWhere3` entirely and `MatchWhere4 [1]`; corpus + passing 18 → 25. - TCK un-skip batch — null/boolean scalar scenarios (#598) — un-skipped the verified-passing scenarios of `Null3`, `Boolean1`, `Boolean2` (per-scenario; the null-truth-table, `IN`, and type-error scenarios stay `@skip-rust`). diff --git a/crates/gf-api/tests/e2e_baseline.rs b/crates/gf-api/tests/e2e_baseline.rs index 23a22a00..5d636887 100644 --- a/crates/gf-api/tests/e2e_baseline.rs +++ b/crates/gf-api/tests/e2e_baseline.rs @@ -385,6 +385,48 @@ fn multi_pattern_match_with_where_joins_disconnected_vars() { assert_eq!(bn.value(0), "Bob"); } +#[test] +fn node_equality_compares_identity() { + // #598: `a = b` / `a <> b` over node variables compares node identity + // (node_uuid), not the bare (multi-column) var qualifier. + let gf = forge(); + let eq = rows( + &gf, + "MATCH (a:Person), (b:Person) WHERE a = b RETURN a.name AS n", + ); + assert_eq!( + eq.stats.rows_produced, 5, + "each of 5 Persons equals only itself" + ); + let ne = rows( + &gf, + "MATCH (a:Person), (b:Person) WHERE a <> b RETURN a.name AS n", + ); + assert_eq!( + ne.stats.rows_produced, 20, + "5×5 cross product minus 5 self-pairs" + ); +} + +#[test] +fn unlabelled_bound_dst_property_resolves() { + // #598: an unlabelled bound endpoint (`x` in `(n)-[r]->(x)`) gets its + // properties joined, so `WHERE`/`RETURN` on `x.` resolves. + let gf = forge(); + let r = rows( + &gf, + "MATCH (n:Person)-[:KNOWS]->(x) WHERE x.name = 'Bob' RETURN n.name AS who", + ); + assert_eq!(r.stats.rows_produced, 1, "only Alice KNOWS Bob"); + let who = r.batches[0] + .column_by_name("who") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(who.value(0), "Alice"); +} + #[test] fn connected_anonymous_pattern_counts_relationships_not_nodes() { // Regression (root cause of the multi-pattern work): an anonymous connected diff --git a/crates/gf-rel/src/expr.rs b/crates/gf-rel/src/expr.rs index 3758bda7..0cb91823 100644 --- a/crates/gf-rel/src/expr.rs +++ b/crates/gf-rel/src/expr.rs @@ -297,12 +297,38 @@ impl<'a> ExprLowerer<'a> { )) } + /// `col("var_.node_uuid")` when `id` is a bare `VarRef` to a node variable + /// (one with a `NodeScan` shape) — the node's identity, for comparisons; + /// `None` otherwise. A bare node var is a multi-column qualifier with no + /// scalar lowering, so its identity is `node_uuid` (#598 node equality). + fn node_uuid_of(&self, id: ExprId) -> Option { + if let IrExpr::VarRef(v) = self.arena.get(id) + && self.node_shapes.contains_key(&v.0) + { + let base = self.var_map.get(*v)?; + return Some(col(format!("{base}.node_uuid"))); + } + None + } + fn lower_binary( &self, op: BinaryOpKind, left: ExprId, right: ExprId, ) -> Result { + // Node identity comparison: `a = b` / `a <> b` over node variables + // compares their `node_uuid` (#598) — each operand would otherwise lower + // to a bare `var_` qualifier, not a valid scalar column. + if matches!(op, BinaryOpKind::Eq | BinaryOpKind::Neq) + && let (Some(lc), Some(rc)) = (self.node_uuid_of(left), self.node_uuid_of(right)) + { + return Ok(if matches!(op, BinaryOpKind::Eq) { + lc.eq(rc) + } else { + lc.not_eq(rc) + }); + } let l = self.lower(left)?; let r = self.lower(right)?; let expr = match op { diff --git a/crates/gf-rel/src/lowerer.rs b/crates/gf-rel/src/lowerer.rs index b07d8c26..faea12a1 100644 --- a/crates/gf-rel/src/lowerer.rs +++ b/crates/gf-rel/src/lowerer.rs @@ -591,7 +591,12 @@ impl<'a> GraphPlanLowerer<'a> { let filtered = filter_node_by_type(input, alias, *type_id)?; self.join_node_properties(*var, *ty, filtered) } - None => Ok(input), + // An already-bound but UNLABELLED node (e.g. the dst of + // `(n)-[r]->(x)`) still needs its properties joined so a + // later `x.prop` / `WHERE x.p = …` resolves. In + // exploratory mode `join_node_properties` routes to + // `_untyped` (#889); a no-op otherwise. + None => self.join_node_properties(*var, None, input), }; } let scan = lower_node_scan(*var, *ty, var_map, self.read_dir())?; diff --git a/tests/tck/coverage_matrix.json b/tests/tck/coverage_matrix.json index ed6edbf2..4279be4f 100644 --- a/tests/tck/coverage_matrix.json +++ b/tests/tck/coverage_matrix.json @@ -3,7 +3,7 @@ "corpus": "openCypher TCK tag 2024.3 (vendored, #874)", "feature_files": 220, "scenarios_total": 3880, - "scenarios_passing": 18, + "scenarios_passing": 25, "scenarios_written": 1615, "note": "Per-scenario aware (#889). 'scenarios' is the EXPANDED runnable count cucumber runs (Scenario=1; Scenario Outline=one per Examples data row). 'scenarios_passing' is the subset that RUNS: a scenario runs unless the feature OR the scenario carries @skip-rust (the BDD runner uses fail_on_skipped, so a runnable scenario that failed would fail CI). rust_status: 'passing'=all run, 'partial'=some run (per-scenario @skip-rust on the rest), 'skip'=none. scenarios_total (3880) is the 100%-gate denominator (#609/#742). Regression floor: do not lower a feature's scenarios_passing. crates/gf-api/tests/tck_coverage.rs recomputes all of this from the feature files and fails CI on drift." }, @@ -109,14 +109,14 @@ "scenarios_passing": 0 }, "clauses/match-where/MatchWhere3.feature": { - "rust_status": "skip", + "rust_status": "passing", "scenarios": 3, - "scenarios_passing": 0 + "scenarios_passing": 3 }, "clauses/match-where/MatchWhere4.feature": { - "rust_status": "skip", + "rust_status": "partial", "scenarios": 2, - "scenarios_passing": 0 + "scenarios_passing": 1 }, "clauses/match-where/MatchWhere5.feature": { "rust_status": "skip", @@ -414,9 +414,9 @@ "scenarios_passing": 0 }, "clauses/with-where/WithWhere3.feature": { - "rust_status": "skip", + "rust_status": "passing", "scenarios": 3, - "scenarios_passing": 0 + "scenarios_passing": 3 }, "clauses/with-where/WithWhere4.feature": { "rust_status": "skip", diff --git a/tests/tck/features/clauses/match-where/MatchWhere3.feature b/tests/tck/features/clauses/match-where/MatchWhere3.feature index f26ad7c4..d4339476 100644 --- a/tests/tck/features/clauses/match-where/MatchWhere3.feature +++ b/tests/tck/features/clauses/match-where/MatchWhere3.feature @@ -28,7 +28,7 @@ #encoding: utf-8 -@skip-rust @skip-node +@skip-node Feature: MatchWhere3 - Equi-Joins on variables Scenario: [1] Join between node identities diff --git a/tests/tck/features/clauses/match-where/MatchWhere4.feature b/tests/tck/features/clauses/match-where/MatchWhere4.feature index 3d4b6872..a35b8f30 100644 --- a/tests/tck/features/clauses/match-where/MatchWhere4.feature +++ b/tests/tck/features/clauses/match-where/MatchWhere4.feature @@ -28,7 +28,7 @@ #encoding: utf-8 -@skip-rust @skip-node +@skip-node Feature: MatchWhere4 - Non-Equi-Joins on variables Scenario: [1] Join nodes on inequality @@ -49,6 +49,7 @@ Feature: MatchWhere4 - Non-Equi-Joins on variables | (:B) | (:A) | And no side effects + @skip-rust Scenario: [2] Join with disjunctive multi-part predicates including patterns Given an empty graph And having executed: diff --git a/tests/tck/features/clauses/with-where/WithWhere3.feature b/tests/tck/features/clauses/with-where/WithWhere3.feature index 63fffb42..e5660715 100644 --- a/tests/tck/features/clauses/with-where/WithWhere3.feature +++ b/tests/tck/features/clauses/with-where/WithWhere3.feature @@ -28,7 +28,7 @@ #encoding: utf-8 -@skip-rust @skip-node +@skip-node Feature: WithWhere3 - Equi-Joins on variables Scenario: [1] Join between node identities