Skip to content

fix(formatter,ast,parser): no inner semicolons; keep IGNORE NULLS, named args, ON DUPLICATE KEY, OUTPUT, SETTINGS (#602, #600) - #631

Merged
ajitpratap0 merged 3 commits into
mainfrom
fix/formatter-semicolons-dropped-clauses
Oct 14, 2026
Merged

ajitpratap0 merged 3 commits into
mainfrom
fix/formatter-semicolons-dropped-clauses

Conversation

@ajitpratap0

Copy link
Copy Markdown
Owner

Summary

#602 formatter: readable style emits ; inside CTE/IN/EXISTS subqueries and set operations, and ;; after INSERT ... SELECT / CREATE VIEW

In readable style (AddSemicolon=true), the formatter now ends only the outermost statement with ';'. Before this change, CTE bodies, IN/EXISTS/scalar subqueries and set-operation operands got an inner ';' that broke re-parsing, and INSERT ... SELECT and CREATE [MATERIALIZED] VIEW ended in ';;'. The fix adds an unexported nestedOpts helper that clears AddSemicolon and uses it at the 8 nested render sites. renderSetOperation now writes one ';' after the whole chain, still in a single linear pass over the spine. Compact output is byte-identical to before across 163 corpus statements. In a corpus check over testdata, readable-style re-parse failures fell from 36 to 6 and ';;' occurrences from 13 to 0. The new regression tests (24 round-trip cases, a corpus invariant, and FormatExpression subquery cases) fail on the parent and pass on HEAD; the race suite, vet and golangci-lint are clean. Known issues left for follow-ups: SHOW/DESCRIBE/SEQUENCE/fallback renderers never emit ';' (for example "SHOW TABLES\nSELECT 1;"), NOT EXISTS renders as "NOT (EXISTS (...) NOT )", MATCH...AGAINST and ARRAY-cast rendering are broken, the table name "groups" does not re-parse, nested-subquery indentation, and parenthesization of right-nested set operations.

#600 ast/formatter: SQL() and pkg/formatter drop IGNORE NULLS, named arguments, ON DUPLICATE KEY UPDATE and INSERT OUTPUT; parser discards SELECT ... SETTINGS

Fixes #600. tree.SQL() and pkg/formatter (compact and readable) now keep five constructs they used to drop:

  • FunctionCall IGNORE/RESPECT NULLS
  • named arguments (f(x => 1), via the new (*ast.NamedArgument).SQL())
  • MySQL ON DUPLICATE KEY UPDATE
  • SQL Server INSERT ... OUTPUT
  • ClickHouse SELECT ... SETTINGS

The SETTINGS clause is now parsed into the new additive field SelectStatement.Settings ([]ast.QuerySetting). For set operations it is stored on the rightmost SELECT. It is released and zeroed in the pool release path. A trailing FORMAT <name> is still accepted as parse-only, and a malformed SETTINGS list or a bare FORMAT now returns a syntax error.

Every reproduction from the issue round-trips verbatim through SQL(), FormatAST compact and FormatAST readable, and re-parses in its own dialect. The PostgreSQL ON CONFLICT ... RETURNING control case is unchanged. The new regression tests fail on the parent commit and pass on HEAD. The full -race suite, go vet and golangci-lint are clean.

Deferred (follow-ups):

Commits (3), one implementation commit per issue plus test-only follow-ups:

14 files changed, 957 insertions(+), 35 deletions(-)

Test Plan

  • Each issue's reproduction cases were run against main before the fix (Haiku scout), and a failing regression test was written before the code change.
  • Independent review (Opus, or Sonnet on small chains) with a bounded fix loop.
  • Independent Opus validator ran the repository race gate (CGO_ENABLED=1 go test -race -timeout 300s $(go list ./... | grep -v /cbinding)), go vet, golangci-lint, re-ran the reproduction cases on HEAD, and confirmed the new tests fail on the parent commit.
  • Perf items: benchstat -count=6 before/after on the same machine.

#602 evidence (validator)

Not a perf item, so benchstat does not apply. Evidence for the bug fix:
(1) Parent check: I extracted 61ca6c8 (the parent) with git archive into scratchpad/b3/602/parent and added nested_semicolon_test.go. On the parent, 22 of the 24 TestFormat_NestedStatementsNoInnerSemicolon subtests FAIL. The 2 that pass are the controls (derived table and select-list subquery), as expected. TestFormatAST_ReadableSemicolonOnlyTerminatesStatements and all 3 TestFormatExpression_SubqueryNoSemicolon subtests also FAIL there. On HEAD every one of these tests passes.
(2) Scout cases on HEAD, through FormatString:
- WITH c AS (SELECT 1)\nSELECT *\nFROM c;
- SELECT *\nFROM t\nWHERE a IN (SELECT 1);
- SELECT *\nFROM t\nWHERE EXISTS (SELECT 1);
- SELECT 1\nUNION\nSELECT 2;
- INSERT INTO t (a)\nSELECT a\nFROM u;
- CREATE VIEW v AS\nSELECT 1;
- Control: SELECT *\nFROM (SELECT 1) s;
- Control: SELECT (SELECT 1);
All of them re-parse, a second pass produces identical output, and the compact AST is the same before and after.
(3) Corpus diff, parent vs HEAD, over 163 parseable statements (testdata plus extras): compact output is byte-identical. Readable re-parse failures drop from 36 to 6, and ';;' occurrences from 13 to 0. No readable output on HEAD contains ';)' or ';' before UNION/EXCEPT/INTERSECT. The 6 remaining failures are exactly the non-semicolon defects listed in out_of_scope, and all of them also fail on the parent: NOT EXISTS rendered as NOT (EXISTS ... NOT ) (3 statements), MATCH AGAINST, ARRAY cast, and the "groups" table name.

#602 race gate: PASS. Ran CGO_ENABLED=1 go test -race -timeout 300s $(go list ./... | grep -v /cbinding) on HEAD 1a97287: exit 0, 44 packages ok, no FAIL lines and no flakes (log at scratchpad/b3/602/race.txt). CGO_ENABLED=1 go test -race ./pkg/cbinding/ also passed (ok, 1.8s).
#600 evidence (validator)

Not a performance item, so benchstat was not run. Bug evidence, with each case's output re-parsed under the same dialect:

- All 5 scout reproductions now render verbatim through tree.SQL() and FormatAST(CompactStyle), and the readable style keeps the clause. All of these outputs re-parse:
  - `FIRST_VALUE(x) IGNORE NULLS OVER (ORDER BY y)`
  - `f(x => 1)`
  - MySQL `... ON DUPLICATE KEY UPDATE a = 2`
  - SQL Server `INSERT INTO t (a) OUTPUT INSERTED.id VALUES (1)`
  - ClickHouse `SELECT a FROM t SETTINGS max_threads = 4` (readable: `SELECT a\nFROM t\nSETTINGS max_threads = 4;`)
- The PostgreSQL ON CONFLICT ... RETURNING control case is unchanged and still verbatim.
- Extra cases that also round-trip:
  - `LAG(x) RESPECT NULLS`
  - quoted named argument `"my arg" => 1`
  - multi-row ON DUPLICATE KEY with `VALUES(a), b = b + 1`
  - OUTPUT with INSERT ... SELECT
  - UNION ALL ... SETTINGS (stored on the rightmost select)
  - UNION ALL ... ORDER BY ... LIMIT ... SETTINGS (clause order kept)
  - SETTINGS with a boolean or string value
  - `SETTINGS ...; SELECT 1`
  - `SETTINGS ... FORMAT Null` / `FORMAT JSONEachRow` (parse OK; FORMAT is dropped as before, parse-only)

Regression tests fail on the parent tree, built with `git archive 1a97287` and the new test files copied in:
- TestRender_Issue600_KeepsClauses fails with "SQL() mismatch", "compact FormatAST mismatch", "readable FormatAST lost clause IGNORE NULLS / x => 1 / ON DUPLICATE KEY UPDATE ...", and "did not round-trip".
- The AST and parser tests do not compile on the parent, because SelectStatement.Settings and QuerySetting do not exist there.

The review-fix tests fail on 4c2e377 with the exact E2002 error the report gave: "expected statement, got NULL" for `FORMAT Null`.

#600 race gate: PASS. Ran CGO_ENABLED=1 go test -race -timeout 300s $(go list ./... | grep -v /cbinding) at HEAD d6049e4: 44 packages ok, 0 FAIL, exit 0, no flakes. That run includes TestClickHouseSettingsTTLFormat, clickhouse_qa select_settings and select_settings_multi, the new pool release test, and the sql_golden_test.

Breaking Changes

Observable behaviour changes:

  • tree.SQL() and pkg/formatter now emit IGNORE/RESPECT NULLS, name => value named arguments, INSERT ... OUTPUT, ON DUPLICATE KEY UPDATE and ClickHouse SELECT ... SETTINGS. Before, these were dropped.
  • SelectStatement.Children() includes setting values.
  • ClickHouse SELECT ... SETTINGS lists that are malformed (missing name, '=' or value, or trailing tokens other than FORMAT <name>) now return a syntax error instead of being skipped.

The commit body will document these. No exported signatures change, and the legacy ast.Setting type is untouched.

Notes

Fixes #602
Fixes #600
Follow-up to #566

🤖 Generated with Claude Code

https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve

@vercel

vercel Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
gosqlx-website Ready Ready Preview Oct 14, 2026 7:14am UTC

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Claude finished @ajitpratap0's task in 26s —— View job


PR #631 review

Verdict: looks good. I have no blocking issues. I read the diff of the non-test source files and did not run the test suite. I'm relying on the validator evidence in the PR description for the race gate.

What I checked

Minor observations (non-blocking)

  1. FORMAT <name> is still dropped. The PR already lists this. SQL() or the formatter on SELECT ... SETTINGS x=1 FORMAT JSON silently loses the format hint. If the AST output feeds ClickHouse clients, that changes behaviour, so a doc comment on QuerySetting or Settings would help.
  2. Settings on a non-rightmost operand are not handled in renderSetOperation. The parser only fills the rightmost SELECT, so this is fine today. An AST built by hand with Settings on a left operand would render ... SETTINGS ... UNION ..., which is invalid. Acceptable, since nothing produces that tree.
  3. isQuerySettingName accepts any token that starts with a letter or _. That includes reserved words, and the next token must be =, so that is fine. It would also accept a token like FORMAT as a setting name, e.g. SETTINGS a=1, FORMAT=2. That is an edge case, and ClickHouse itself is ambiguous there.
  4. ReadableStyle output for the known leftovers (NOT EXISTS, SHOW/DESCRIBE without ;, right-nested set-operation parentheses) is deferred. I agree these belong in follow-ups. For the right-nested case, renderSetOperation ignores the Right subtree's own precedence, so a UNION (b INTERSECT c) can change meaning when re-rendered. It is worth a tracking issue if one doesn't exist yet.
  5. Error wrapping: InvalidSyntaxError(...).WithCause(err) embeds %v of the cause in the message and also attaches it as the cause. The text is duplicated if both are printed. This is cosmetic.

Tests

Coverage is good. It has 24 round-trip cases, a corpus invariant, a pool release test, parser error cases and golden updates. The reported fail-on-parent / pass-on-HEAD check is the right way to validate this. The only test I'd add is a round-trip for a set operation inside a CTE inside INSERT...SELECT, so that nestedOpts is exercised across several levels at once.

Security and performance

There are no security concerns. The parser changes are bounded loops that always advance a token. There is no new allocation on hot paths beyond the Settings slice, which only ClickHouse queries with SETTINGS populate.

CHANGELOG is intentionally deferred to #598, as the PR notes.
· branch fix/formatter-semicolons-dropped-clauses

ajitpratap0 and others added 3 commits October 14, 2026 12:42
…yle (#602)

In readable style (AddSemicolon=true, the FormatString default) every nested
FormatStatement call received the caller's options unchanged, so each inner
statement appended its own ";". The output contained ";" inside CTE bodies and
IN/EXISTS/scalar subqueries, after every set-operation operand and level, and
";;" after INSERT ... SELECT and CREATE [MATERIALIZED] VIEW. None of it
re-parsed.

Add an unexported nestedOpts helper that clears AddSemicolon and use it at
every nested call site (renderInsert, renderCreateView,
renderCreateMaterializedView, renderIn, renderExists, renderSubquery,
renderWith, renderSetOperation operands). renderSetOperation now writes its
";" once after the whole chain instead of once per level, keeping the linear
spine walk. Compact style is unchanged.

Tests: table test with exact output, re-parse, idempotency and AST
equivalence; compact-style guard; a testdata corpus invariant that the only
";" in readable output is the final terminator; exported FormatExpression
check. The set-operation reference oracle now models a single trailing ";".

Fixes #602

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
…LICATE KEY UPDATE, INSERT OUTPUT and ClickHouse SELECT SETTINGS (#600)

tree.SQL() and pkg/formatter (compact and readable) dropped five
constructs, which silently changed the meaning of the rendered SQL:

- FunctionCall.NullTreatment (IGNORE/RESPECT NULLS) is now written after
  the argument list, before WITHIN GROUP/FILTER/OVER, matching the
  parser's order.
- *ast.NamedArgument had no SQL(), so `f(x => 1)` rendered as `f(x)`. It
  now renders `name => value`, quoting the name when needed.
- InsertStatement.Output (SQL Server OUTPUT) and
  InsertStatement.OnDuplicateKey (MySQL ON DUPLICATE KEY UPDATE) are now
  rendered by both InsertStatement.SQL() and the formatter's renderInsert.
- ClickHouse SELECT ... SETTINGS was skipped by the parser with no AST
  field to hold it. It is now parsed as `name = expr [, ...]` and stored
  on the rightmost SelectStatement (which covers set operations). It is
  rendered by SelectStatement.SQL() and renderSelect.

New additive API (no existing signature changes; legacy ast.Setting is
untouched):
- type ast.QuerySetting{Name, Value, Pos}: plain data, not a Node, with
  (*QuerySetting).SQL()
- field ast.SelectStatement.Settings []QuerySetting (ClickHouse only)
- method (*ast.NamedArgument).SQL()

SelectStatement.Children() now exposes setting values. Setting names are
not exposed, so they are never reported as columns. releaseSelectStatement
releases and zeroes the Settings entries.

Behaviour change: ClickHouse SELECT ... SETTINGS parsing is stricter. The
old loop swallowed every token up to EOF, ';' or ')'. A malformed list
(`SETTINGS`, `SETTINGS x`, `SETTINGS x =`, `SETTINGS = 4`, a trailing
comma) or trailing tokens such as INTO OUTFILE now return a syntax error.
A trailing `FORMAT <name>` is still accepted, but it is parse-only and
not modeled, as before. DDL SETTINGS parsing (ddl.go) is unchanged.

The sql_golden_test "function/full" expectation pinned the buggy output
without IGNORE NULLS even though NullTreatment was set. It is updated to
include it.

Tests: round-trip tests for every reproduction in the issue, across the
generic, Snowflake, PostgreSQL, MySQL, SQL Server and ClickHouse dialects
(SQL(), compact and readable FormatAST, re-parse). Also parser tests for
SETTINGS storage and errors, AST rendering/Children tests, and a pool
release test.

Fixes #600

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
Accept any word token as the format name in a trailing ClickHouse
`SELECT ... SETTINGS ... FORMAT <name>`, using the same rule as setting
names. `FORMAT Null` (also NULL/null) tokenizes as a NULL literal and was
rejected with E2002 "expected statement, got NULL", although the parent
commit accepted it. A format name is now required: a bare `FORMAT` is a
syntax error.

Tests: FORMAT with JSON, JSONEachRow, TabSeparated, Values, Null, NULL,
null and CSV, FORMAT Null followed by another statement, error cases for a
missing or quoted format name, and a formatter test that SETTINGS, OUTPUT
and ON DUPLICATE KEY UPDATE honour lowercase KeywordCase in compact and
readable styles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
@ajitpratap0
ajitpratap0 force-pushed the fix/formatter-semicolons-dropped-clauses branch from d6049e4 to 9c8640b Compare October 14, 2026 07:14
@ajitpratap0

Copy link
Copy Markdown
Owner Author

Merge train (batch 3): rebased onto main after #630 merged (9c8640b).

Conflict resolution: these resolutions match the trial integration of all 12 branches, where the full race gate passed.

Checks after the rebase: the full race gate passes locally, go vet is clean, and golangci-lint reports 0 issues.

Adversarial pass (Opus): no must-fix findings. One should-fix: ClickHouse SETTINGS … INTO OUTFILE is now rejected where it used to be silently swallowed. This is intentional and consistent with SELECT … INTO OUTFILE already being rejected on main. INTO OUTFILE support goes to a follow-up issue.

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Claude finished @ajitpratap0's task in 18s —— View job


PR #631 review

Verdict: looks good, no blocking issues. I read the non-test source diff (render.go, sql.go, clickhouse_select_settings.go, the pool release path). I did not run the test suite. I'm relying on the validator evidence in the PR description for the race gate.

#602 (semicolons)

  • nestedOpts is applied at all nested render sites: CTE bodies, IN, EXISTS, scalar subqueries, INSERT...SELECT, both view kinds, and set-operation operands. opts is taken by value, so the caller's options are never mutated.
  • renderSetOperation now writes a single ; after the chain. It still walks the spine in one linear pass, so the fix(tokenizer,parser,formatter): bound input-driven recursion, comment counts and set-operation rendering #597 linear-time behaviour is kept.
  • The old code rendered the innermost left operand and non-parenthesized right operands with opts. Both now use inner, which is the right fix.

#600 (dropped clauses)

  • Clause order in SQL() and the formatter matches the grammar:
    • IGNORE/RESPECT NULLS comes after ) and before WITHIN GROUP, FILTER and OVER.
    • OUTPUT comes before VALUES or the query.
    • ON DUPLICATE KEY UPDATE comes before RETURNING.
    • SETTINGS comes after FOR.
  • The parser replaces the old skip-to-EOF loop with real parsing. It attaches settings to the rightmost SELECT, and a malformed list or a bare FORMAT is now an error.
  • releaseSelectStatement releases and zeroes settings, and pool_settings_release_test.go covers it.

Non-blocking observations

  1. FORMAT <name> is still dropped. After SQL() or the formatter, SELECT ... SETTINGS x=1 FORMAT JSON loses the format hint. A doc comment on SelectStatement.Settings saying so would help.
  2. Settings on a non-rightmost set-operation operand. renderSetOperation would emit ... SETTINGS ... UNION ..., which is invalid. The parser never builds such a tree, but a hand-built AST could.
  3. isQuerySettingName is permissive. It accepts any word-like token, so FORMAT is accepted as a setting name in SETTINGS a=1, FORMAT=2. Because = must follow, this is only an edge case.
  4. Right-nested set operations. renderSetOperation ignores the precedence of the Right subtree, so a UNION (b INTERSECT c) may change meaning when re-rendered. It is already listed as deferred; I'd open a tracking issue if one doesn't exist.
  5. Duplicated error text. InvalidSyntaxError(...%v...).WithCause(err) embeds the cause in the message and also attaches it as the cause. The text is duplicated if both are printed. This is cosmetic.
  6. Possible test to add. A round-trip for a set operation inside a CTE inside INSERT...SELECT would exercise nestedOpts across several levels at once.

Security and performance

There are no security concerns. The new parser loops always advance a token. The only new allocation is the Settings slice, which only ClickHouse queries with SETTINGS populate.

CHANGELOG is deferred to #598, as the PR notes.
· branch fix/formatter-semicolons-dropped-clauses

@ajitpratap0
ajitpratap0 merged commit 1be0808 into main Oct 14, 2026
28 checks passed
ajitpratap0 added a commit that referenced this pull request Oct 14, 2026
…d costs

- #628: SQLite still rejects count(*) [cnt]; only SQL Server keeps it.
- #631: bare FORMAT / FORMAT <name> rules apply after SETTINGS.
- Performance: record the measured costs of the batch-3 correctness fixes
  (Identifier and TableReference growth, quoting in SQL(), lineStarts
  reallocation, sorted Extract results).
- #632: note the lıke, WITH CHECK OPTION and MINUS rendering changes.
- Fix the [Unreleased] compare link (v1.14.0...HEAD) and add [1.14.0].
- Keep each section a single tight list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
ajitpratap0 added a commit that referenced this pull request Oct 14, 2026
…xes; refresh performance baselines (#566) (#598)

* docs: consolidate CHANGELOG for the perf audit and batch-2 fixes; refresh performance baselines (#566)

CHANGELOG [Unreleased] now covers every PR merged since v1.14.0, with a
Behavior changes section for the observable changes in #597, #595, #584,
#585, #588, #581, #582/#570, #517/#518 and #511, plus Added, Changed,
Fixed, Performance, Security and Internal entries. The two existing
entries (#526 and the tokenizer-pool dialect fix) are kept verbatim.

performance_baselines.json is re-measured on an Apple M4 with
BenchmarkPerformanceBaseline -count=10 -benchmem (median). Baselines are
set at about 2.5x the M4 median rather than 1.5x: the CI Benchmark job on
ubuntu-latest measures these queries about 1.5-3.2x slower than M4 (last
9 Tests runs on main, 0715bb9..61ca6c8), so 1.5x with the unchanged
tolerances would fail CI's slowest runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve

* docs(changelog): add batch-3 follow-up fixes (#599-#620)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve

* docs(changelog): record #632 render-injection hardening and #624 quote guards

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve

* docs(changelog): correct batch-3 entries after review; record measured costs

- #628: SQLite still rejects count(*) [cnt]; only SQL Server keeps it.
- #631: bare FORMAT / FORMAT <name> rules apply after SETTINGS.
- Performance: record the measured costs of the batch-3 correctness fixes
  (Identifier and TableReference growth, quoting in SQL(), lineStarts
  reallocation, sorted Extract results).
- #632: note the lıke, WITH CHECK OPTION and MINUS rendering changes.
- Fix the [Unreleased] compare link (v1.14.0...HEAD) and add [1.14.0].
- Keep each section a single tight list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve

* docs(changelog): describe the release as source-compatible with runtime behaviour changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 9c8640bb Deployed Oct 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ast AST node changes parser

Projects

None yet

1 participant