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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

### Fixed
- **`rows(table='edges')` silently returned the wrong table after a NAMED traversal**: the named-middle rewrite — which turns `[...named ops..., rows()]` into `rows(binding_ops=...)` so a Cypher multi-alias `RETURN` lowers to a bindings table — skipped itself when the call already carried `binding_ops`, `source` or `alias_endpoints`, but not when it carried a non-default `table`. So naming any op in the middle changed which table came back: an odd-length middle silently produced the BINDINGS table instead of the requested edges (no error, `table=` simply ignored), and an even-length middle — a path ending on an edge, e.g. `(person)-[r:KNOWS]-` — produced a non-alternating op list and raised "require ... a single connected alternating node/edge path". An unnamed middle was unaffected, which is what made it look shape-specific rather than naming-specific. Both chain surfaces are fixed (the generic chain and the native polars chain carry the rewrite independently, so fixing one left the other wrong). Note the guard keys on a NON-DEFAULT table: `rows()` declares `table='nodes'` and always emits it, so an explicit `rows(table='nodes')` is indistinguishable from a bare `rows()` and still rewrites — pinned as a known limitation rather than left to be rediscovered.
- **Indexed bindings bypass returned the WHOLE edge table for `rows(table='edges')`**: the bypass exists to SKIP the canonical traversal, so whatever it hands the suffix is the pre-traversal graph. That is sound for a bindings table — the indexed path bag already is the answer — but the gate declined only for `source` / `alias_endpoints` / `alias_prefilters`, not for a non-default `table`, so a `rows(table='edges')` after a named middle read the full edge frame instead of the traversal-narrowed one (measured on a 12-edge fixture: 12 rows instead of 3, on pandas, cuDF and native polars, and the wrong values survive a following `select`). Both gates now decline a non-default `table` (`chain._plan_indexed_middle` and the native polars `_try_indexed_middle_polars`), matching the named-middle rewrite's guard. This became reachable only once that rewrite stopped firing for a non-default `table` — before it, the rewrite converted the call on BOTH the indexed and the scan path, so the two agreed on the wrong table. Same class as the earlier "indexed bypass could return every node for `rows()` over an unnamed pattern".
- **The named-middle rewrite discarded the caller's other `rows()` params**: both rewrites built a FRESH `rows(binding_ops=...)` instead of adding `binding_ops` to the call the caller wrote, so every param the rewrite has no opinion about was thrown away. For `attach_prop_aliases` — the projection pushdown — that is observable: spelling a pattern as a NAMED middle rather than as explicit `binding_ops` silently attached every alias's properties instead of the requested ones, i.e. the same query returned a wider schema depending only on how it was written. `alias_prefilters` was dropped the same way. Both are now carried through, on both chain surfaces. Known remaining gap, pinned by a strict-xfail test rather than left to be rediscovered: the native polars bindings builder never receives `alias_prefilters` at all, so hand-written GFQL passing that hint without an equivalent post-filter still gets engine-dependent row counts (Cypher-generated plans always keep the post-filter, so they agree across engines and only lose the pushdown).
- **Native polars variable-length bindings dropped zero-hop rows (`-[*0..k]->`)**: the polars `rows(binding_ops=...)` builder rebuilt the bindings table from the CHAIN OUTPUT, while the pandas oracle rebuilds from the pre-chain base graph. The traversal prunes to what IT considers matched, which is not the same set the bindings builder matches — a zero-hop variable-length segment binds a seed that has no matching outgoing edge, and the traversal drops that node entirely. So `MATCH (a)-[*0..2]->(b)` could silently return fewer rows than pandas (no error, just missing rows). The polars builder now sources its node/edge tables from the same pre-chain base graph pandas uses (which is also what the indexed builder was already handed). Found by differential fuzzing while adding unbounded support (#1709); pinned by a zero-hop regression test and by `-[*0..2]->` parity cases.

- **Indexed bypass could return every node for `rows()` over an unnamed pattern**: the boundary accepted a `rows()` call carrying no binding ops over a middle with no aliases. That call reads the traversal-narrowed node table, but the bypass hands it the full graph, so the query would have returned all nodes. The gate now requires that the rows call actually consumes the whole middle as binding ops — either it already carries them, or the named-middle rewrite installs exactly them.
Expand Down
23 changes: 22 additions & 1 deletion graphistry/compute/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,15 @@ def _plan_indexed_middle(
call.params.get("source") is not None
or call.params.get("alias_endpoints") is not None
or call.params.get("alias_prefilters")
# Serving the bypass SKIPS the canonical traversal, so the suffix runs against the
# PRE-traversal graph. That is sound for a bindings table (the path bag already is
# the answer) but not for `rows(table="edges")`, which would read the whole edge
# table instead of the traversal-narrowed one. Non-default `table` therefore keeps
# the scan path, for the same reason `source`/`alias_endpoints` do above. Compared
# against "nodes", not None: `rows()` defaults `table` to "nodes" and always emits
# it, so an `is None` test would disable the bypass outright — the same trap the
# named-middle rewrite's guard documents below.
or call.params.get("table", "nodes") != "nodes"
or not all(isinstance(op, (ASTNode, ASTEdge)) for op in middle)
):
return None
Expand Down Expand Up @@ -747,7 +756,19 @@ def _handle_boundary_calls(
and suffix[0].params.get("table", "nodes") == "nodes"
and all(isinstance(op, (ASTNode, ASTEdge)) for op in middle)
):
suffix = [rows_fn(binding_ops=serialize_binding_ops(middle))] + list(suffix[1:])
# ADD binding_ops to the call the caller wrote; do not build a fresh one. The
# rewrite's job is to say WHERE the bindings come from, and every other param
# on `rows()` is still the caller's — `attach_prop_aliases` (the #1711
# projection pushdown) and the advisory `alias_prefilters` both survive into
# the binding_ops builder, which is the only place they are read. Rebuilding
# threw them away, so merely naming the middle silently attached every alias's
# properties. The excluded params above cannot be present here.
prev_params = suffix[0].params
suffix = [rows_fn(
binding_ops=serialize_binding_ops(middle),
alias_prefilters=prev_params.get("alias_prefilters"),
attach_prop_aliases=prev_params.get("attach_prop_aliases"),
)] + list(suffix[1:])
g_temp = _chain_impl(
g_temp,
suffix,
Expand Down
15 changes: 14 additions & 1 deletion graphistry/compute/gfql/lazy/engine/polars/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,15 @@ def _run_calls_polars(g_cur, calls, start_nodes, base_graph, middle):
and calls[0].params.get("table", "nodes") == "nodes"
and all(isinstance(op, (_ASTNode, _ASTEdge)) for op in middle)
):
calls = [rows_fn(binding_ops=serialize_binding_ops(middle))] + list(calls[1:])
# See the twin in compute/chain.py: ADD binding_ops to the caller's call rather
# than building a fresh one, so the params the rewrite has no opinion about
# (`attach_prop_aliases`, `alias_prefilters`) reach the binding_ops builder.
prev_params = calls[0].params
calls = [rows_fn(
binding_ops=serialize_binding_ops(middle),
alias_prefilters=prev_params.get("alias_prefilters"),
attach_prop_aliases=prev_params.get("attach_prop_aliases"),
)] + list(calls[1:])

# Per-op NATIVE-OR-DEFER. Ops that don't lower:
# - non-native ROW op (correlated-subquery semi_apply/anti_semi_apply/join_apply):
Expand Down Expand Up @@ -801,6 +809,11 @@ def _try_indexed_middle_polars(
or suffix[0].params.get("source") is not None
or suffix[0].params.get("alias_endpoints") is not None
or suffix[0].params.get("alias_prefilters")
# See the twin guard in chain._plan_indexed_middle: serving the bypass skips the
# canonical traversal, so a non-default `table` would read the PRE-traversal edge
# table (the whole graph) instead of the narrowed one. "nodes", not None — `rows()`
# always emits `table`.
or suffix[0].params.get("table", "nodes") != "nodes"
or not all(isinstance(op, (ASTNode, ASTEdge)) for op in middle)
):
return None, False
Expand Down
Loading
Loading