Skip to content

Walk every child field of expressions when building subquery scopes - #11

Merged
ravenac95 merged 1 commit into
mainfrom
walk-skipped-expression-fields
Oct 5, 2026
Merged

ravenac95 merged 1 commit into
mainfrom
walk-skipped-expression-fields

Conversation

@ravenac95

Copy link
Copy Markdown
Member

WalkInScopeIter::get_children hand-lists the child fields of most node types and leaves some out. A query in one of those fields gets no subquery scope, so its tables and columns are invisible to everything built on the scope tree:

  • AggregateFunction walks only args. It skips filter, order_by and limit, and every non-typed aggregate parses to this node.
  • WindowFunction skips the over.frame bounds and the keep clause.
  • Cast skips format and default.
  • In skips unnest.
SELECT json_agg(u.a) FILTER (WHERE u.a > (SELECT max(t.x) FROM t)) FROM u
-- before: no subquery scope, so `t` is never seen
-- after:  one Subquery scope with source `t`

What changes

  • Hand-written arms that stay: only the ones that enforce scope boundaries.
    • Select: FROM and JOIN items are walked as before, and CTEs are not.
    • Table, Subquery and Exists: unchanged.
    • In: its query operand still gets its own scope through the existing In arm, so the walk leaves it out.
  • Every other node takes its children from the canonical AST traversal (Expression::children()), so a field added to the AST later is covered automatically.
  • Unchanged: the stop rules and the pointer dedup in collect_subqueries_in_expr, so each query still gets exactly one scope.
  • Public API: walk_in_scope and find_all_in_scope now also yield nodes in the previously skipped positions. The CHANGELOG notes this.
  • Out of scope: Select's own hand-listed fields (prewhere, distinct_on, windows and others). Subqueries there already got scopes through collect_subqueries. Widening what walk_in_scope yields for a Select would change what other callers see, so that's left for a separate change.

Tests

test_subquery_in_expression_field_is_one_subquery_scope checks that each position below registers exactly one subquery scope with source t, and that t doesn't leak into the outer scope:

  • aggregate FILTER, both a scalar subquery and a bare ARRAY(SELECT …);
  • aggregate ORDER BY;
  • a window frame bound;
  • CAST … FORMAT;
  • Oracle CAST … DEFAULT … ON CONVERSION ERROR;
  • BigQuery IN UNNEST(ARRAY(SELECT …)).

No existing expectation changed.

Local checks pass: cargo fmt --all -- --check, make check-consistency, clippy -D warnings on the ffi and wasm crates, and make test-rust-verify-core.

🤖 Generated with Claude Code


Generated by Claude Code

WalkInScopeIter::get_children hand-listed the child fields of most node
types and left some out, so a query inside them got no subquery scope. An
aggregate's FILTER, ORDER BY and LIMIT, a window frame bound, a CAST FORMAT
or DEFAULT expression and IN UNNEST(...) were all skipped, and every
non-typed aggregate parses to AggregateFunction.

Only the arms that enforce scope boundaries stay hand-written: Select (FROM
and JOIN items are walked as before, CTEs are not), Table, Subquery,
Exists, and In, whose query operand still gets its own scope. Every other
node takes its children from the canonical AST traversal, so new fields are
covered automatically. Stop rules are unchanged, and the existing pointer
dedup keeps each query to a single scope.

walk_in_scope and find_all_in_scope now also yield the nodes in those
previously skipped positions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fa6xBWiFS7JFH5kt1epZ7o
@ravenac95
ravenac95 merged commit 8a0b51f into main Oct 5, 2026
4 of 8 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.

2 participants