Skip to content

Fix expression precedence for lambda, ternary and NOT operators#279

Merged
git-hulk merged 1 commit into
masterfrom
fix/expression-precedence
Jul 4, 2026
Merged

Fix expression precedence for lambda, ternary and NOT operators#279
git-hulk merged 1 commit into
masterfrom
fix/expression-precedence

Conversation

@git-hulk

@git-hulk git-hulk commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

The precedence table in parser_column.go disagreed with ClickHouse in several places, and two constructs were unreachable. Crucially, all of these misparses round-trip to identical SQL text, so the round-trip-based golden tests never caught them — only consumers reading the AST saw wrong trees.

Input Before After
arrayMap(x -> x + 1, arr) body was (x -> x) + 1 body is x + 1
x -> y -> x + y (x -> y) -> (x + y) x -> (y -> x + y) (right-assoc)
NOT a = b (NOT a) = b NOT (a = b)
NOT NOT a NOT (column \"NOT\") AS a NOT (NOT a)
a OR b ? 1 : 2 a OR (b ? 1 : 2) (a OR b) ? 1 : 2
x NOT BETWEEN 1 AND 2 hard error (dead code) parses; BetweenClause.Not
a = b NOT IN (1) (a = b) NOT IN (1) a = (b NOT IN (1)), same as IN
SELECT 1 INTERSECT SELECT 2 misparsed as 1 AS INTERSECT, then error supported, wired like EXCEPT

Changes

  • Reordered the precedence constants to match ClickHouse: -> binds loosest, then ?:, then OR/AND. Removed the duplicate TokenKindDot/TokenKindDash arms in getNextPrecedence.
  • Lambda -> gets its own right-associative parseInfix case (body parsed at precedence-1).
  • Prefix NOT parses its operand at PrecedenceNot via parseSubExpr instead of stopping at a primary expression.
  • Infix NOT binds with the precedence of the operator it negates (peek-based), and NOT BETWEEN is reachable; BetweenClause gains a Not field (parser/formatter wired).
  • GLOBAL (only valid before IN) binds like IN itself.
  • New INTERSECT keyword + SelectQuery.Intersect, mirroring EXCEPT in parser, formatter, Accept, and Walk. While wiring it I found Walk never visited SelectQuery.Except either — added both.

AST / grammar impact

  • New fields: BetweenClause.Not, SelectQuery.Intersect (the golden JSON churn is exactly these two fields; no format output changed for any existing fixture).
  • New structural tests in precedence_test.go assert tree shapes directly, since round-trip goldens cannot catch precedence regressions.

🤖 Generated with Claude Code

The precedence table disagreed with ClickHouse in several places and two
operators were unreachable. All of these misparses round-tripped to the
same SQL text, so the golden tests never caught them:

- Lambda `->` bound tightest instead of loosest: `x -> x + 1` parsed as
  `(x -> x) + 1`. It now binds loosest and is right-associative.
- Ternary `?:` bound above OR/AND: `a OR b ? 1 : 2` parsed as
  `a OR (b ? 1 : 2)` instead of `(a OR b) ? 1 : 2`.
- Prefix NOT grabbed only a primary: `NOT a = b` parsed as `(NOT a) = b`
  instead of `NOT (a = b)`, and `NOT NOT a` misparsed the second NOT as
  a column named NOT.
- `x NOT BETWEEN 1 AND 2` was a hard error: the BETWEEN branch inside
  parseInfix's NOT case sat after the default arm and was dead code.
  BetweenClause gains a Not field.
- `NOT IN` entered through NOT's low precedence while `IN` had its own,
  so `a = b IN (1)` and `a = b NOT IN (1)` grouped differently.
- getNextPrecedence listed TokenKindDot/TokenKindDash twice.
- INTERSECT was not a keyword at all: `SELECT 1 INTERSECT SELECT 2`
  misparsed as `SELECT 1 AS INTERSECT` then errored. It is now wired
  like EXCEPT (SelectQuery.Intersect), including walk/format support;
  walk also gains the previously missing Except child.

Golden JSON diffs are purely the two new fields (BetweenClause.Not,
SelectQuery.Intersect); no format output changed. New structural tests
in precedence_test.go assert the tree shapes directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@git-hulk git-hulk merged commit 5c30692 into master Jul 4, 2026
4 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2586409eef

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread parser/parser_query.go
}
selectStmt.Except = exceptExpr
case p.tryConsumeKeywords(KeywordIntersect):
intersectExpr, err := p.parseSelectQuery(p.Pos())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve INTERSECT precedence in set chains

When an INTERSECT is followed later by a lower-priority UNION or EXCEPT, this recursive RHS parse lets that lower-priority operator bind inside the right operand. ClickHouse documents INTERSECT as higher priority than UNION/EXCEPT (https://clickhouse.com/docs/sql-reference/statements/select/intersect), so SELECT a FROM t1 INTERSECT SELECT b FROM t2 UNION ALL SELECT c FROM t3 should be represented as (t1 INTERSECT t2) UNION ALL t3; currently the top node is Intersect and the RHS carries UnionAll, changing the AST semantics for consumers.

Useful? React with 👍 / 👎.

Comment thread parser/parser_query.go
Comment on lines +1012 to +1013
case p.tryConsumeKeywords(KeywordIntersect):
intersectExpr, err := p.parseSelectQuery(p.Pos())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Accept INTERSECT DISTINCT before the RHS query

ClickHouse supports INTERSECT DISTINCT (https://clickhouse.com/docs/sql-reference/statements/select/intersect), but this branch consumes only the bare INTERSECT keyword and immediately expects the next token to start a SELECT. Valid queries such as SELECT number FROM numbers(10) INTERSECT DISTINCT SELECT number FROM numbers(5) therefore fail at DISTINCT instead of parsing and formatting.

Useful? React with 👍 / 👎.

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