Skip to content

feat(gfql): native polars UNWIND of a carried collect() list column#1752

Merged
lmeyerov merged 2 commits into
masterfrom
feat/gfql-unwind-collect-polars
Jul 20, 2026
Merged

feat(gfql): native polars UNWIND of a carried collect() list column#1752
lmeyerov merged 2 commits into
masterfrom
feat/gfql-unwind-collect-polars

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

What

... WITH collect(x) AS xs UNWIND xs AS y RETURN ... — exploding a list-valued column produced by collect() — previously declined on polars (unwind_polars accepted only a scalar literal list). Now explodes a List-dtype column reference (with_columns copy → filter(list.len() > 0)explode), matching the pandas oracle exactly: empty-list/null cells → 0 rows, nulls within a list survive, source column retained; the pre-filter also makes it independent of the polars-2.0 empty_as_null default.

Verification (independent — re-run by the reviewing agent)

  • End-to-end g.gfql() A/B vs pandas, match: UNWIND [1,2,3] AS x WITH collect(x) AS xs UNWIND xs AS y RETURN y → rows {1,2,3}; with collect(DISTINCT ...); and RETURN count(y), sum(y) → (4, 10).
  • Differential fuzz vs pandas over ~3500 collect→UNWIND→{RETURN, grouped, filtered, ORDER BY, aggregate, nested-unwind} queries: 0 disagreements.
  • test_engine_polars_row_pipeline.py: 20 unwind/collect tests pass (independently re-run). ruff clean; mypy master-baseline; added lines covered.

Scope

Non-list columns, name collisions, unknown identifiers, nested-list literals → honest NIE. The MATCH ... WITH collect(..) UNWIND .. MATCH form (IC6 with a trailing re-traversal) is compile-rewritten into WITH→MATCH reentry — a separate path (#1751), not this PR.

Off master (touches row_pipeline.py::unwind_polars — overlaps #1750/#1751's file; trivial rebase). Refs the IC6/IC11 coverage push.

🤖 Generated with Claude Code

lmeyerov and others added 2 commits July 20, 2026 13:00
Extend `unwind_polars` to explode a List-dtype column reference (a `collect()`
output / carried binding), not just a scalar-literal list. This is the row-
pipeline analogue of the IC6 `WITH collect(x) AS xs UNWIND xs AS y` shape:
previously the second UNWIND declined (NIE) because the expr was an Identifier,
not a ListLiteral, forcing engine='pandas'.

Mirrors the pandas oracle (`RowPipelineMixin.unwind` list-column branch)
exactly: empty-list and null cells contribute 0 rows; nulls WITHIN a list
survive as real elements; the source column is retained and exploded values
append as `as_`. Implemented as with_columns(copy) -> filter(list.len() > 0)
-> explode, which also makes the result independent of the polars 2.0
`empty_as_null` default. Non-list columns, name collisions, unknown
identifiers, and nested-list literals still decline (NIE) — no pandas bridge,
no silent-wrong.

NOTE (out of scope): the collect->UNWIND->MATCH re-entry form (IC6 with a
trailing MATCH) is compile-time rewritten to whole-row MATCH-after-WITH
re-entry; polars declines there because the whole-entity projection does not
attach `_cypher_entity_projection_meta` and `binding_rows_polars` declines on
`_gfql_start_nodes`. That is the separate WITH-MATCH re-entry residual, not a
row-pipeline unwind gap.

Correctness gate: differential fuzz vs pandas (2000 queries, 0 disagreements)
plus parity/pin/decline tests. ruff clean; no new mypy errors; new lines
fully covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
@lmeyerov
lmeyerov merged commit e676fb0 into master Jul 20, 2026
57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant