Skip to content

fix: keep set operations after subquery operands at the outer query; build set-operation scopes in linear time - #32

Merged
kvlonge merged 2 commits into
mainfrom
fix/union-after-scalar-subquery
Oct 2, 2026
Merged

kvlonge merged 2 commits into
mainfrom
fix/union-after-scalar-subquery

Conversation

@kvlonge

@kvlonge kvlonge commented Oct 2, 2026

Copy link
Copy Markdown

Why

  • A set operation after a parenthesised subquery in an expression was attached to the subquery: WHERE a.x < (SELECT …) UNION ALL SELECT … parsed as a.x < ((SELECT …) UNION ALL SELECT …). The same happened in HAVING, JOIN ON, BETWEEN and the SELECT list, so downstream tools reported false column-count errors and mis-formatted the query.
  • Scope building for set operations copied the whole left branch at every level, which made validation and analysis quadratic in the number of branches.

Changes

  • fix: a bracketed query takes a following set operation only when it is the first operand inside another bracket (x IN ((SELECT 1) UNION (SELECT 2))) or when CTAS opts in.
  • perf: nested plain set-operation branch scopes store a shallow node. Lineage and nullability get the full branch query through the new set_operation_branch_queries, and Scope::output_columns follows the left branch.

Verification

  • Regression tests across 7 dialects (parse tree, regenerated SQL, validation) for the misparsed shapes and the unchanged bracketed forms.
  • Scaling tests: linear scope size and a timing ratio for 100 vs 400 branches; identical diagnostics for column-count mismatches at the start, middle and end of long chains.
  • Differential checks: old vs new on 330+ query × dialect cases, byte-identical, apart from the intended fixes.
  • Crate suite passes apart from failures that also occur on main.

A parenthesized query used as an expression operand, such as the scalar
subquery in `WHERE x < (SELECT ...) UNION ALL SELECT ...`, absorbed the
following set operation, so the UNION/EXCEPT/INTERSECT became part of the
predicate instead of combining the outer SELECTs. The same happened in
HAVING, JOIN ON, BETWEEN and doubly parenthesized operands.

A parenthesized query now continues into a set operation only where it
starts the contents of an enclosing parenthesis, as in
`x IN ((SELECT 1) UNION (SELECT 2))`, or where CREATE TABLE AS parses its
parenthesized query. Statement-level parenthesized set operations are
unchanged.
Each set-operation branch scope copied its whole operand, so a chain of N
UNION/INTERSECT/EXCEPT branches copied every earlier branch at each level and
scope building, validation and query analysis grew quadratically.

A branch that is itself an unmodified set operation now stores its operator
and clauses with NULL placeholders for set-operation operands, which are the
expressions of its own branch scopes. Lineage and query-analysis nullability
borrow each branch's complete query from its parent instead, and
Scope::output_columns follows the leftmost branch scope. The scope tree shape
and all validation, analysis and lineage results are unchanged.

Add set_operation_branch_queries for consumers that need a branch's complete
query.
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