Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
42 changes: 42 additions & 0 deletions crates/gf-api/tests/e2e_baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<prop>` 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::<StringArray>()
.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
Expand Down
26 changes: 26 additions & 0 deletions crates/gf-rel/src/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,12 +297,38 @@ impl<'a> ExprLowerer<'a> {
))
}

/// `col("var_<v>.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<DfExpr> {
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<DfExpr, LoweringError> {
// Node identity comparison: `a = b` / `a <> b` over node variables
// compares their `node_uuid` (#598) — each operand would otherwise lower
// to a bare `var_<v>` 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 {
Expand Down
7 changes: 6 additions & 1 deletion crates/gf-rel/src/lowerer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())?;
Expand Down
14 changes: 7 additions & 7 deletions tests/tck/coverage_matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/tck/features/clauses/with-where/WithWhere3.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading