Skip to content

Fix parsing of a parenthesized left operand of a set operator - #312

Merged
git-hulk merged 4 commits into
AfterShip:masterfrom
therealpandey:fix-parenthesized-set-operator-left-operand
Aug 11, 2026
Merged

Fix parsing of a parenthesized left operand of a set operator#312
git-hulk merged 4 commits into
AfterShip:masterfrom
therealpandey:fix-parenthesized-set-operator-left-operand

Conversation

@therealpandey

@therealpandey therealpandey commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #311

parseSelectQuery bound UNION/EXCEPT/INTERSECT before consuming the closing paren of a parenthesized select, so a set operator after ) never got a chance to bind:

SELECT a FROM ((SELECT 1 AS a) UNION ALL (SELECT 2 AS a))  -- was: expected ')', but got '<keyword>'
(SELECT 1 AS a) UNION ALL SELECT 2 AS a                    -- was: unexpected token: "("
SELECT 1 UNION ALL (SELECT 2) UNION ALL SELECT 3           -- was: <EOF> or ';' was expected
SELECT a FROM (((SELECT 1 AS a)))                          -- was: expected keyword <"SELECT">

All of these run on ClickHouse 26.8.1.337.

parseSelectQuery now parses a parenthesized operand by recursing into itself and consuming ) first, and keeps the group in the AST as a SelectQuery with a new InnerQuery field wrapping the inner query, so the operator after ) binds to the whole group and FormatSQL reproduces the parentheses. The grouping has to survive into the AST: ClickHouse gives INTERSECT higher precedence than UNION/EXCEPT, so flattening (SELECT 1 UNION ALL SELECT 2) INTERSECT SELECT 2 into the paren-free chain would silently change the result set from 2 to 1, 2 on a parse→format round trip (see the discussion on #311).

The group also binds SETTINGS and FORMAT after the ), which ClickHouse accepts — (SELECT 1) SETTINGS max_threads=1 FORMAT JSONEachRow — and once either is bound no set operator may follow, matching ClickHouse.

InnerQuery marshals with omitempty and parseCTEStmt now consumes the parens around a CTE body itself, so CTE ASTs and all existing goldens are unchanged. parseStmt additionally dispatches statements starting with ( to parseSelectQuery.

Note for consumers: the few parenthesized-select inputs master already parsed by dropping the parens — EXPLAIN SYNTAX (SELECT 1), ALTER TABLE ... MODIFY QUERY (SELECT 1), SELECT 1 UNION ALL (SELECT 2) — now produce the InnerQuery wrapper (with the inner query one level down and SelectItems nil on the wrapper), and Format preserves their parentheses instead of normalizing them away.

Covered by a fixture under parser/testdata/query/ including both INTERSECT precedence cases and the SETTINGS/FORMAT cases, an AST-shape test asserting the operator binds to the group, and invalid-syntax cases ((SELECT 1, (SELECT 1) UNION SELECT 2, (SELECT 1) SETTINGS max_threads=1 UNION ALL SELECT 2) that must keep failing. Every statement in the format golden round-trips to exactly its input text; regenerated goldens are new files only.

parseSelectQuery bound UNION/EXCEPT/INTERSECT before consuming the
closing paren of a parenthesized select, so the operator never got a
chance to bind and ((SELECT 1) UNION ALL (SELECT 2)) failed with
"expected ')'". Parse a parenthesized operand by recursing into
parseSelectQuery and closing the paren first, then attach the operator's
right operand to the tail of the chain the left operand already carries.
Statements starting with '(' now dispatch to parseSelectQuery as well.

Fixes AfterShip#311
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

Flattening a parenthesized operand into the existing chain changes
semantics on the format round trip: ClickHouse gives INTERSECT higher
precedence than UNION/EXCEPT, so (SELECT 1 UNION ALL SELECT 2)
INTERSECT SELECT 2 returns 2 while its flattened form returns 1 and 2.

A parenthesized operand is now kept as a SelectQuery with the new Paren
field wrapping the inner query, so the operator after ')' binds to the
whole group and FormatSQL reproduces the parentheses. parseCTEStmt
consumes the parens around a CTE body itself so CTE ASTs are unchanged,
and Paren marshals with omitempty, so no existing golden changes.
ClickHouse allows SETTINGS and FORMAT after the closing paren of a
parenthesized select: (SELECT 1) SETTINGS max_threads=1 FORMAT
JSONEachRow. The Paren group only bound set operators, so SETTINGS was
rejected and a trailing FORMAT was consumed by parseStmt's discard call
and silently dropped from the AST.

Parse SETTINGS and FORMAT onto the group's own fields, mirroring the
tail of parseSelectStmt, and have FormatSQL emit them. Once either
clause is bound, no set operator may follow, matching ClickHouse, which
rejects (SELECT 1) SETTINGS max_threads=1 UNION ALL SELECT 2.
@therealpandey

Copy link
Copy Markdown
Contributor Author

Hey @git-hulk,
This is ready for review now!

@git-hulk

git-hulk commented Aug 8, 2026

Copy link
Copy Markdown
Member

@therealpandey Thank you!Will have a look soon.

@therealpandey

Copy link
Copy Markdown
Contributor Author

Hey @git-hulk,
Sorry for troubling you! Just a gentle reminder.

Comment thread parser/ast.go Outdated
@git-hulk

git-hulk commented Aug 10, 2026

Copy link
Copy Markdown
Member

@therealpandey Sorry for the late reply due to I'm on vacation those days. Overall is good to me, one comment inline. Thank you!

@therealpandey

Copy link
Copy Markdown
Contributor Author

Thanks @git-hulk. Addressed your comment.

Appreciate the fast follow ups.

@git-hulk

Copy link
Copy Markdown
Member

@therealpandey Thanks a lot for your contributions.

@git-hulk
git-hulk merged commit ec52b07 into AfterShip:master Aug 11, 2026
1 of 2 checks passed
pull Bot pushed a commit to nagyist/signoz that referenced this pull request Aug 12, 2026
### Description

Bumps `github.com/AfterShip/clickhouse-sql-parser` from v0.5.5 to
v0.5.6.

- v0.5.6 parses a parenthesized left operand of a set operator (upstream
AfterShip/clickhouse-sql-parser#312), e.g.
`(SELECT 1) UNION ALL (SELECT 2)`.
- Moves the three now-passing parenthesized set-operation cases into the
pass table in `clickhouse_sql_test.go` as regression canaries.
- Records the outstanding `NULLS FIRST|LAST` ORDER BY gap in the
known-gap table — the parser still rejects it, so it stays tracked until
fixed upstream.
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.

[bug] parenthesized left operand of UNION/EXCEPT/INTERSECT is rejected

2 participants