Skip to content

Evaluate UNION / EXCEPT / INTERSECT chains left to right to close #202 - #2582

Open
joshuablac wants to merge 1 commit into
AlaSQL:developfrom
joshuablac:fix/set-op-chain-202
Open

joshuablac wants to merge 1 commit into
AlaSQL:developfrom
joshuablac:fix/set-op-chain-202

Conversation

@joshuablac

Copy link
Copy Markdown

Closes #202

Problem

Chains of UNION / UNION ALL / EXCEPT / INTERSECT are evaluated from right to left. In addition, a parenthesized chain on the left loses an operation. With e1 = {1,2,3,4}, e2 = {1,5}, e3 = {3,5}, e4 = {2,5}:

Query develop SQL / with this PR
e1 EXCEPT e2 EXCEPT e3 (the issue) {2,3,4} {2,4}
e1 UNION e2 EXCEPT e3 {1,2,3,4} {1,2,4}
e1 EXCEPT e2 UNION e3 {2,4} {2,3,4,5}
e1 INTERSECT e2 UNION e3 {1,3} {1,3,5}
e2 UNION ALL e3 EXCEPT e4 {1,3,5} {1,3}
e1 EXCEPT e2 EXCEPT e3 EXCEPT e4 {2,3,4} {4}
e1 UNION e2 INTERSECT e3 EXCEPT e4 {1,2,3,4} {1,3,4}
(e1 EXCEPT e2) EXCEPT e3 {1,2,4} (the EXCEPT e2 is lost) {2,4}
(e1 EXCEPT e2) UNION e3 {1,2,3,4,5} (the EXCEPT is lost) {2,3,4,5}

Already correct on develop, and unchanged:

  • e1 UNION e2 INTERSECT e3, e1 EXCEPT e2 INTERSECT e3 and e1 INTERSECT e2 EXCEPT e3
  • e1 EXCEPT (e2 EXCEPT e3)
  • chains that use only UNION or only UNION ALL

SQL evaluates UNION and EXCEPT from left to right, with INTERSECT binding tighter. PostgreSQL, SQL Server and MySQL all follow this. I cross-checked every case above in SQLite. It agrees on all of them except where INTERSECT follows a UNION/EXCEPT: SQLite deliberately gives all compound operators the same precedence. For those cases this PR keeps the standard precedence, which test339 (cases 11 and 13) already asserts.

Cause

  1. The grammar nests a chain to the right (A op1 B op2 C → A.op1 = B, B.op2 = C). Each SELECT applied its operator to the complete result of the rest of the chain, so A op1 (B op2 C).
  2. For (A op1 B) op2 C, the ParenthesizedSelect UnionClause / LPAR Select RPAR UnionClause actions merged the outer clause into the inner SELECT with yy.extend. That replaced the inner operation when both used the same operator, and otherwise left two of union/except/… set, of which only one was used.

Fix

Grammar (src/alasqlparser.jison, parser regenerated with the pinned jison 0.4.18; the diff to alasqlparser.js is only those actions):

  • A parenthesized right operand is marked parenthesizedOperand, so its own set operations stay a group.
  • yy.extendSetOp (in src/28yy.js): when a parenthesized SELECT already has a set operation, the outer clause is kept in outerSetOps instead of overwriting it.

Compile (src/40select.js): compileSetOps flattens the chain into a list of {kind, corresponding, fn}, which is applied to the result from left to right:

  • An INTERSECT that follows a UNION/EXCEPT joins that operation's right operand.
  • Parenthesized operands are compiled as a whole.
  • Each operation keeps its own CORRESPONDING flag.

The existing behavior of using the right operand's ORDER BY when the query has none is kept unchanged.

Execution (src/38query.js): the four existing UNION ALL / UNION / EXCEPT / INTERSECT branches are moved unchanged into applySetOp(query, kind, fn, corresponding), which runs once per operation. A query with a single set operation therefore runs the same code as before. Two related details:

To check that single set operations are unaffected, I ran 44 queries against develop and against this branch: each operator with and without CORRESPONDING, SELECT *, FROM ?, ORDER BY/LIMIT/OFFSET after the operation, parenthesized operands with their own ORDER BY/LIMIT (the #2417 patterns), subqueries in FROM and in IN (...). All 44 results are identical.

Tests

  • Added test/test202-B.js (test202.js is taken):

    • the issue's SELECT * … EXCEPT … EXCEPT
    • left-to-right chains, and INTERSECT precedence
    • parenthesized groups on both sides
    • ORDER BY after a chain
    • a chain with CORRESPONDING

    All 6 fail on develop and pass with this change.

  • Updated tests that asserted the right-to-left results:

    • test339, case 9: a UNION b EXCEPT c → [3, 4]
    • test339, case 10: a EXCEPT b UNION c → [1, 2, 3, 5]
    • test339, case 12: a INTERSECT b UNION c → [1, 2, 5]
    • test7, cases I, J and K. Their comments already noted that the expected values were "the current behavior" and not the correct result. I and K now get the result their comments describe. J follows INTERSECT precedence: 10 UNION ALL (20 INTERSECT 20) UNION ALL 30.
    • test2414, case P: ((… a = 1) UNION (… a = 2 ORDER BY a)) UNION … a > 2 ORDER BY a DESC now includes the a = 2 row that was dropped before: [4, 3, 2, 1].
  • Full suite (bun test, same command as yarn test-only): 2255 pass, 213 skip. The only failures are the 2 CLI tests in test2149.js, which fail the same way on unmodified develop in my local setup (a space in my checkout path, and the version-injection step of build.sh not running).

Notes

  • Any query with two or more set operations can return different (correct) rows. Queries with a single set operation are unaffected.
  • Select.toString() is unchanged, so it still prints chains without parentheses. Nothing in the code re-parses that output.
  • Out of scope: A UNION (B) EXCEPT C (a parenthesized operand followed by another operation) still fails to parse, as on develop.
  • This regenerates alasqlparser.js. Several open PRs also change the grammar, so whichever merges second needs yarn jison again.

This fix was written with AI assistance (Claude Code) and verified locally as described above.

🤖 Generated with Claude Code

…SQL#202

The parser nests a chain of set operations to the right, and each SELECT
applied its operator to the full result of the rest of the chain, so
A EXCEPT B EXCEPT C was computed as A EXCEPT (B EXCEPT C). In addition,
(A EXCEPT B) UNION C lost the inner operation, because the outer clause was
merged into the parenthesized SELECT and replaced or hid its own one.

- Compile: the chain is flattened into a list of set operations applied
  left to right, with INTERSECT binding tighter than UNION and EXCEPT, as in
  SQL. A parenthesized operand stays a group.
- Grammar: a parenthesized right operand is marked (parenthesizedOperand),
  and an outer set operation on a parenthesized SELECT that already has one
  is kept apart (outerSetOps) instead of overwriting it.
- Execution: the four existing UNION ALL / UNION / EXCEPT / INTERSECT
  branches are moved unchanged into applySetOp(), called once per
  operation. A query with a single set operation runs the same code.

test339 (9, 10, 12), test7 (I, J, K) and test2414 (P) asserted the old
right-to-left results; they now expect the SQL results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 17:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

Fix multiple EXCEPT problem

2 participants