Skip to content

refactor(gfql/lowering + ast_normalizer): ExpressionText-passthrough WhereClause.expr → expr_tree readers (#1213 sub-PR C)#1218

Merged
lmeyerov merged 1 commit intomasterfrom
m4-1213-where-expr-readers-c
Apr 26, 2026
Merged

refactor(gfql/lowering + ast_normalizer): ExpressionText-passthrough WhereClause.expr → expr_tree readers (#1213 sub-PR C)#1218
lmeyerov merged 1 commit intomasterfrom
m4-1213-where-expr-readers-c

Conversation

@lmeyerov
Copy link
Copy Markdown
Contributor

Summary

Sub-PR C of #1213. Migrate all ExpressionText-passthrough readers of WhereClause.expr to consume WhereClause.expr_tree-derived values. Builds on sub-PR B (#1216, merged) which handled the text-only readers.

Adds two helpers in cypher/lowering.py:

  • _where_clause_expr_text(where) -> Optional[ExpressionText] — synthesizes a fresh ExpressionText from where.expr_tree using where.span (which equals the original where.expr.span for parser-produced WhereClauses, both derived from the same _span_from_meta(meta) of the generic_where_clause rule). Preserves master's error-position semantics.
  • _rewrite_where_clause_and_resync(where, rewrite, field) -> WhereClause — applies a text rewrite via the existing (ExpressionText, str) -> ExpressionText callback shape and resynchronizes expr_tree to a single-atom BooleanExpr carrying the rewritten text. Option B of the C decision (text round-trip + single-atom resynthesis) per coordination on gfql/ir: migrate WhereClause.expr readers to expr_tree, then remove the field (#1200 slice 5) #1213.

Side-effect fix

Closes the latent text/tree staleness bug that existed on master post-#1214: replace(where, expr=rewrite(where.expr, ...)) used to leave expr_tree pointing at the pre-rewrite text. The invariant (expr is None) == (expr_tree is None) still passed (both still non-None), but text and tree were semantically out of sync. Now both stay in sync after rewriting.

Sites migrated

Site Pattern Helper used
lowering.py:6322 (dynamic row-where) _extract_relationship_type_where(query.where.expr, ...) + row_where = query.where.expr _where_clause_expr_text
lowering.py:7530-7538 (reentry remaining wheres) replace(where_clause, expr=rewrite_expr(where_clause.expr, "where")) _rewrite_where_clause_and_resync
lowering.py:7550-7553 (reentry primary where) same pattern for query.reentry_where _rewrite_where_clause_and_resync
lowering.py:7998 (pre_join_filters) pre_join_filters.append(query.where.expr) _where_clause_expr_text (synthesize then append)
lowering.py:8176-8191 (connected OPTIONAL MATCH) _extract_relationship_type_where(where.expr, ...) + entangled _unsupported(value=.text, line=.span.line, column=.span.column) _where_clause_expr_text (uniform synthesis covers both sub-uses)
ast_normalizer.py::_rewrite_where replace(where, expr=_rewrite_shortest_path_expr_text(where.expr, ...)) inline equivalent (single callsite, doesn't justify cross-module helper)

Out of scope (deferred to D + E)

  • ast_normalizer.py:520-526 — constructor passthrough (expr=query.where.expr inside a WhereClause(...) re-construction). Sub-PR D drops the expr= arg as part of the writer-side migration. The gate at L520 is migrated (expr is not Noneexpr_tree is not None).

Test plan

  • pytest graphistry/tests/compute/gfql/ — 1538 passed, 80 skipped, 15 xfailed. No regressions.
  • mypy graphistry/compute/gfql/cypher/lowering.py graphistry/compute/gfql/cypher/ast_normalizer.py — clean.

Coordination

Sub-PR C per the ownership split agreed on #1213:

Sub-PR Owner State
A — helper lift + invariant colleague ✅ merged (#1214)
B — text-only readers us ✅ merged (#1216)
C — passthrough readers us this PR
D — drop expr= from constructions colleague ready to start once C merges
E — drop the expr field colleague combined with D

After this lands, the colleague (D + E owner) is unblocked. Will post coordination signal on #1213 when this merges.

Closes

Partial closure of #1213; full closure on D + E.

Related

…WhereClause.expr → expr_tree readers (#1213 sub-PR C)

Second read-side slice of #1213.  Migrate all `ExpressionText`-passthrough
readers of `WhereClause.expr` to consume `WhereClause.expr_tree`-derived
values via two new helpers in `cypher/lowering.py`:

- `_where_clause_expr_text(where)` — synthesizes a fresh `ExpressionText`
  from `where.expr_tree` using `where.span` (which equals the original
  `where.expr.span` for parser-produced WhereClauses, both derived from
  the same `_span_from_meta(meta)` of `generic_where_clause`) so error
  positions match master's behavior.
- `_rewrite_where_clause_and_resync(where, rewrite, field)` — applies a
  text rewrite via the existing `(ExpressionText, str) -> ExpressionText`
  callback shape and resynchronizes `expr_tree` to a single-atom
  `BooleanExpr` carrying the rewritten text (Option B of the C decision
  posted on #1213; documented in plan.md).

Side-effect fix: closes the latent text/tree staleness bug on master
post-#1214 where `replace(where, expr=rewrite(where.expr, ...))` left
`expr_tree` pointing at the pre-rewrite text.  Now both fields stay in
sync after rewriting.

Sites migrated:

- `lowering.py:6322` — dynamic row-where extraction
  (`_extract_relationship_type_where(query.where.expr, ...)` + `row_where`
  passthrough → use `_where_clause_expr_text`).
- `lowering.py:7530-7538` — reentry-where rewrite of remaining wheres
  (use `_rewrite_where_clause_and_resync`).
- `lowering.py:7550-7553` — reentry-where rewrite of primary
  `query.reentry_where` (same helper).
- `lowering.py:7998` — `pre_join_filters.append(query.where.expr)` →
  append the synthesized ExpressionText.
- `lowering.py:8176-8191` — connected-OPTIONAL-MATCH where lowering
  (`_extract_relationship_type_where` + `_unsupported` error reporting
  with `.text` and `.span.line/.column`) → all consume the synthesized
  ExpressionText uniformly.
- `ast_normalizer.py::_rewrite_where` — shortest-path expr rewrite via
  `_rewrite_shortest_path_expr_text` (inline equivalent of the lowering
  helper since this module only has one callsite).

Out of scope (deferred to D + E):

- `ast_normalizer.py:520-526` constructor passthrough (`expr=query.where.expr`
  inside a `WhereClause(...)` re-construction) — left for sub-PR D to drop
  along with the other writer-side construction-site updates in parser.py.
  The gate at L520 is migrated (`expr is not None` → `expr_tree is not None`).

Verified:
- 1538/1538 GFQL tests pass; no regressions.
- `mypy` clean on `cypher/lowering.py` and `cypher/ast_normalizer.py`.

Coordination: this is sub-PR C per the split agreed on #1213.  Ready for
the colleague's combined sub-PRs D + E (drop `expr=` from `WhereClause(...)`
constructions; remove the field) once this lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lmeyerov
Copy link
Copy Markdown
Contributor Author

PR Review: #1218 — refactor(gfql/lowering + ast_normalizer): ExpressionText-passthrough WhereClause.expr → expr_tree readers (#1213 sub-PR C)

Branch: m4-1213-where-expr-readers-c @ c52c07954
Base: master @ 37bac2c2f (post-#1216)
Stack: not stacked
Files: lowering.py, ast_normalizer.py, CHANGELOG.md
LOC: +86 / −26
CI: 131/131 conclusive checks pass; mergeable; awaiting REVIEW_REQUIRED.


Blockers

None.

Important

None.

Suggestions

  1. [Quality, trivial] _where_clause_expr_text docstring could be sharper about hand-built / future-WhereClause edge cases (where where.span != where.expr_tree.span). Currently no such WhereClauses exist; cosmetic.

(Wave 1 also surfaced an Option-B structural-collapse concern and a missing-direct-unit-test concern; both rejected/downgraded under adversarial pressure with code-level proof. See adversarial/wave-1.md.)

Human checks required

  1. Approve and merge.
  2. Confirm Closes #1213 is not set (this PR is partial; full closure deferred until D + E).
  3. Post coordination signal on gfql/ir: migrate WhereClause.expr readers to expr_tree, then remove the field (#1200 slice 5) #1213 thread when merged: "C merged, ready for D + E."

Rejected / False positives

Wave 1 finding Disposition Proof
Option B collapses post-rewrite boolean structure to single atom; binder fans out fewer conjuncts REJECTED Master had a latent staleness bug — replace(where, expr=rewrite(...)) left expr_tree unchanged, so binder walked the original tree and emitted BoundPredicates with pre-rewrite atom_text. PR fixes that by ensuring the rewritten text reaches BoundPredicate.expression. The structural fan-out loss is documented as a future #1200 enhancement (re-parse the rewritten text back into a tree) — not a regression.
No direct unit test for _rewrite_where_clause_and_resync DOWNGRADED End-to-end coverage via reentry-where tests + full GFQL suite (1538 pass) + #1214 invariant test (would fail on text/tree desync) is sufficient. Direct unit test is documentation, not safety net.
Cross-module helper duplication (_where_clause_expr_text not shared with ast_normalizer) NO FINDING Lifting to _boolean_expr_text.py would couple it to WhereClause/ExpressionText import surfaces, complicating slice E's field removal. Defer until slice E needs the refactor.

Methodology

Per ~/Work/graphistry/.agents/skills/review/SKILL.md. Single wave (justified by tight scope, mechanical migration with full test parity, identical structure to PR #1216's review). 11 dimensions assessed in waves/wave-1/report.md. Adversarial pressure test in adversarial/wave-1.md. Convergence on Wave 1.

Recommendation

Approve and merge with --admin. No defects. Side-effect fix of the latent text/tree staleness bug is a strict correctness improvement over master. Post-merge: signal on #1213 for the colleague to start D + E.

@lmeyerov lmeyerov merged commit 56e2a76 into master Apr 26, 2026
133 checks passed
lmeyerov added a commit that referenced this pull request Apr 26, 2026
* refactor(#1213 sub-PR D+E): drop WhereClause.expr field

Final slice of #1200 / #1213. Removes the legacy raw-text WHERE field now
that all production readers (binder, lowering, ast_normalizer) consume
expr_tree-derived text via boolean_expr_to_text() per sub-PR B/C.

Sub-PR D — drop expr= from writer construction sites:
  - parser.py: _mixed_where_clause, where_clause non-structured branch,
    generic_where_clause fallback (the three sites that synthesized
    BooleanExpr atoms in #1214); also strip expr=None from the three
    structured-path constructions
  - ast_normalizer.py: drop expr= forwarder + switch gate to expr_tree

Sub-PR E — remove the field:
  - ast.py: drop WhereClause.expr; update docstring to two-shape contract
    (structured vs tree, with mixed pattern+expr as the third combination)
  - test_where_clause_expr_tree_invariant.py: rewrite from the now-vacuous
    (expr is None) == (expr_tree is None) symmetry to the surviving
    routing-shape contract (structured / tree / mixed)
  - test_parser.py, test_boolean_expr.py, test_ast_normalizer.py,
    test_where_bool_conformance.py: replace `where.expr is None/not None`
    with `where.expr_tree is None/not None`; replace `where.expr.text`
    with `boolean_expr_to_text(where.expr_tree)`; drop the slice-1
    backward-compat test that asserted both fields were populated

NOT YET REBASED ONTO POST-C MASTER. This commit produces the expected
93 test failures from lowering/ast_normalizer readers that still access
.expr; sub-PR C migrates them. Rebase onto post-C master before pushing
or running CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(#1213 sub-PR D+E): pre-stage CHANGELOG entry for field removal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#1213 sub-PR D+E): drop leftover expr= from sub-PR C's replace() calls

Sub-PR C (#1218) introduced two ``replace(where, expr=rewritten, expr_tree=new_tree)``
calls in ``ast_normalizer.py::_rewrite_where`` and ``lowering.py::_rewrite_where_clause_and_resync``.
These passed both fields because the field still existed at C's merge time.

After this PR removes the field, the ``expr=`` arg becomes invalid keyword
to the dataclass constructor. Drop it from both sites; ``expr_tree`` is the
single source of truth post-D+E.

Also drops the now-unused ``expr=...`` and ``expr=None`` args from three
hand-built ``WhereClause(...)`` test fixtures in ``test_binder_expr_tree.py``
that broke for the same reason, plus the now-unused ``ExpressionText`` import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(#1213 sub-PR D+E): apply review findings — docstring + test coverage

Two review waves (focus + general per the new review policy) converged on:

1. **WhereClause docstring missing the "mixed" routing shape** (IMPORTANT, both
   waves). The new docstring listed only 2 shapes; reality is 3 (and arguably
   subdivided further by which kind of structured `predicates` populated).
   Rewrote to enumerate all three observable shapes, distinguishing
   `WherePredicate` from `WherePatternPredicate` in the structured case.

2. **Invariant test missing parser construction paths** (IMPORTANT, focus
   wave). Added `MATCH (a) WHERE (a)-[]->(:Admin) RETURN a` covering
   `where_pattern_only_clause` (structured, no AND, single
   `WherePatternPredicate`).  `expr_and_where_pattern_clause` (expr-first
   mixed) doesn't currently parse; `where_clause` _ExpressionSlice branch is
   Lark-internal routing already exercised by integration tests.

3. **Stale binder comment claiming `predicates`/`expr_tree` mutually exclusive**
   (SUGGESTION, general wave).  They aren't — the mixed shape populates
   both — and the binder body correctly handles all three shapes.  Updated
   the comment to match the AST docstring's 3-shape contract.

4. **CHANGELOG polish** (SUGGESTIONS, both waves): clarified count
   ("9 query forms across the 3 shapes" rather than the Cartesian-reading
   "3 × 8"), added an API note that `WhereClause` is exported and external
   constructors must drop `expr=` kwarg.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
@lmeyerov lmeyerov deleted the m4-1213-where-expr-readers-c branch April 26, 2026 03:58
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