Skip to content

fix(ast,formatter): NOT EXISTS, quoted identifiers, table hints and PIVOT render correctly (#601, #611, #604) - #632

Merged
ajitpratap0 merged 14 commits into
mainfrom
fix/ast-render-not-exists-quotes-hints
Oct 14, 2026
Merged

ajitpratap0 merged 14 commits into
mainfrom
fix/ast-render-not-exists-quotes-hints

Conversation

@ajitpratap0

@ajitpratap0 ajitpratap0 commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

#601 ast/formatter: NOT EXISTS (subquery) renders as NOT (EXISTS (...) NOT ), which does not re-parse

This fixes #601. BinaryExpression.writeSQL (pkg/sql/ast/sql.go) now renders the parser's NOT EXISTS shape (Operator "NOT", Right nil, Left ExistsExpression) as a prefix NOT EXISTS (...). Before, it produced the invalid NOT (EXISTS (...) NOT ), which failed to re-parse with E2002. AST.SQL(), formatter.FormatAST (compact and readable) and formatter.FormatString all get the fix through the same render path. Neither the parser nor the AST shape changed. Restructuring NOT EXISTS as UnaryExpression or as an ExistsExpression.Not flag was deliberately left out, because it would be an observable AST change for the linter, security scanner and transform consumers. New regression tests check round-trips on 10 query shapes across the default and 8 dialect settings, plus the AST shape. They fail on 61ca6c8 and pass after the fix. The full race suite, go vet and golangci-lint are clean.

#611 ast/formatter: quoted identifiers that are keywords or case-sensitive lose their quotes on render (SELECT "select", CTE/view column lists, table names)

Fixes #611. ast.Identifier gains an additive Quoted bool, set by the parser for ANSI double-quoted names and cleared on pool release. Identifier.SQL() re-quotes those names. CTE names and column lists, CREATE [MATERIALIZED] VIEW names and column lists, and FROM/JOIN table names now go through new ast.QuoteName / ast.QuoteQualifiedName helpers, in both pkg/sql/ast and pkg/formatter. The helpers keep SQL Server #temp, Snowflake @stage/path and Unicode combining-mark names bare. The 5 inputs from the issue now render exactly as written in all 9 dialects through SQL() and FormatAST compact. Each re-parses to the same SQL(). The new regression tests fail on the parent commit. A differential run over about 3,600 corpus queries × 9 dialects found 0 round-trip regressions and 435 newly round-tripping outputs. Known costs: parser B/op rises 4–6% (Identifier size class 48 to 64 B, same allocation count) and SQL() is about 8% slower. Fingerprint normalization now keeps double quotes, so "id" and id produce different fingerprints. Deferred follow-ups, each with a sound scope reason:

#604 ast/formatter: SQL Server table hints WITH (NOLOCK) are never rendered; SQL() also drops PIVOT

ast SQL() now renders SQL Server table hints as WITH (h1, h2) and renders PIVOT/UNPIVOT with the alias as AS alias, in parser order: alias, FINAL, FOR SYSTEM_TIME, hints, PIVOT/UNPIVOT, AS alias. Before this, SELECT * FROM s PIVOT (...) AS p rendered as SELECT * FROM s p and WITH (NOLOCK) was dropped. pkg/formatter (and so gosqlx.FormatAST) now emits hints between a plain alias and PIVOT/UNPIVOT, following the keyword-case setting. Output for table references without hints or pivots is byte-identical; the only expectation change is the select/everything golden gaining WITH (NOLOCK). Verification: four new regression tests fail on the parent commit and pass at HEAD. An independent check re-parsed the output of SQL(), compact, zero-opts and readable formatter and gosqlx.FormatAST for sqlserver, oracle and snowflake, and the full race gate, vet and golangci-lint are clean. Deferred, with sound reasons: the CLI renderer is fixed by #599, so link it or close #604 after #599 merges. Follow-ups: Oracle/Snowflake string literals lose their quotes in PIVOT IN lists (parser); a derived-table alias is overwritten by the pivot alias (needs an AST field); hints on UPDATE/DELETE/MERGE and parenthesized hints like INDEX(ix) don't parse; dialect-aware PIVOT alias and identifier quoting; the formatter's missing FINAL/FOR SYSTEM_TIME/TableFunc rendering (#607 and others).

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

19 files changed, 1585 insertions(+), 42 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.

#601 evidence (validator)

Not a perf item, so no benchmarks were run. Repro evidence: a scratch module that uses a replace directive pointing at the worktree checked 3 scout cases plus a control query at HEAD.
- **Scout cases:** `SELECT * FROM t WHERE NOT EXISTS (SELECT 1 FROM u)`, the `... AND a = 1` variant and the correlated `a = 1 AND NOT EXISTS (... WHERE u.x = t.x)` variant. For each one, tree.SQL() and FormatAST(CompactStyle) are byte-identical to the input. FormatString gives `SELECT *\nFROM t\nWHERE NOT EXISTS (SELECT 1 FROM u);` (and the matching form for the other two). All three outputs re-parse, and the re-parsed SQL() equals the first SQL().
- **Control query:** IS NOT NULL / NOT LIKE / NOT ILIKE / NOT active also renders unchanged and round-trips.
- **Pre-fix failure:** I ran git archive on parent 61ca6c8, added the 2 new test files and ran them. TestBinaryExpression_NotExistsSQL_Issue601 fails with SQL() = "NOT (EXISTS (SELECT 1 FROM u) NOT )". TestNotExists_RoundTrip_Issue601 and TestNotExists_FormatString_Issue601 also fail, 92 FAIL lines in total. At HEAD all of them pass.
- **CLI scout case:** it was already correct before the fix and is out of scope (the CLI formatter was fixed in #591/#587).

#601 race gate: I ran CGO_ENABLED=1 go test -race -timeout 300s $(go list ./... | grep -v /cbinding) at HEAD 4d1c4fb and it exited with code 0. 44 packages reported ok and none failed or flaked. The log is at /private/tmp/claude-501/-Users-ajitpratapsingh-dev-GoSQLX/b99cd3cf-cb92-4294-a0db-d6a7c9a86253/scratchpad/b3/601/race.txt.
#611 evidence (validator)

This is a bug fix, not a perf change, but I benchmarked it because the parser and Identifier are hot paths. Setup: interleaved runs, 9 samples each, parent 4d1c4fb vs HEAD cc23f9b, Apple M4.
- ParserSimpleSelect: time ~ (p=0.945); B/op 816 -> 848 (+3.9%); allocs 7 -> 7.
- ParserComplexSelect: time +0.88% (p=0.004); B/op 1.922Ki -> 2.031Ki (+5.7%); allocs 20 -> 20.
- ast SQL/7line: time 426n -> 464n (+8.9%); B/op and allocs unchanged (240 B, 1 alloc).
- ast SQL/15line: time 831n -> 889n (+7.0%); B/op and allocs unchanged (576 B, 1 alloc).

Causes:
- The B/op rise comes from the new Identifier.Quoted bool, which the plan approved. It pushes the struct from 48 B (Name + Table + Location{int,int}) into the 64 B size class. Allocation counts do not change.
- The SQL() slowdown comes from table names now going through writeQualifiedName / nameNeedsQuoting, a per-rune scan plus a reserved-word map lookup that does not allocate.

Round-trip and repro evidence:
- All 5 scout cases render exactly as the input through stmt.SQL() and FormatAST compact in all 9 dialects. Each re-parses, and SQL() of the re-parse is identical. Before the fix the outputs were 'WITH c (select) ...', 'WITH c (a b) ...', 'CREATE VIEW v (select) ...', 'SELECT select FROM t' and 'SELECT "a b", Mixed FROM my table'.
- The new regression tests fail on the parent. I ran git archive of 4d1c4fb, stubbed only the Quoted field so the tests compile, and dropped the unit tests that need the new helpers. TestQuotedIdentifierRender_Issue611, TestIdentifierQuotedFlag_Issue611 and TestQuotedIdentifier_FormatAST_Issue611 then FAIL. The no-change guard tests (DialectGuards, SnowflakeStagePath, CombiningMarks) PASS there, as they should.
- Corpus differential: 3,638 SQL strings taken from every pkg/**/_test.go string literal and .sql file, plus 65 edge cases, each parsed in 9 dialects. 921 outputs changed. Every changed input I traced wrote the affected names in double quotes, or in MySQL backticks / SQL Server bracke

#611 race gate: PASS. Command: CGO_ENABLED=1 go test -race -timeout 300s $(go list ./... | grep -v /cbinding) at HEAD cc23f9b. Exit 0; 44 packages ok, 0 FAIL, no flakes, so nothing was re-run. Log: /private/tmp/claude-501/-Users-ajitpratapsingh-dev-GoSQLX/b99cd3cf-cb92-4294-a0db-d6a7c9a86253/scratchpad/b3/611/race.txt. TestPerformanceRegression (pkg/sql/parser, run without -race) also passes.
#604 evidence (validator)

Not a perf item. Bug-fix evidence from an independent scratch module (replace directive pointing at the worktree, /private/tmp/claude-501/-Users-ajitpratapsingh-dev-GoSQLX/b99cd3cf-cb92-4294-a0db-d6a7c9a86253/scratchpad/b3/604/repro/main.go). Each case was rendered five ways (SQL(), formatter compact, formatter zero FormatOptions, formatter readable, gosqlx.FormatAST), then re-parsed and compared with the original SQL(). All renders round-trip (RT-OK) for: sqlserver `SELECT * FROM t WITH (NOLOCK)` -> `SELECT * FROM t WITH (NOLOCK)` (it was `SELECT * FROM t`); sqlserver `SELECT * FROM s PIVOT (SUM(amt) FOR q IN ([Q1], [Q2])) AS p` -> identical (it was `SELECT * FROM s p`); `t x WITH (NOLOCK)`; a LEFT JOIN right side with `WITH (READPAST, ROWLOCK)`; `t WITH (NOLOCK) PIVOT (...) AS p`; sqlserver UNPIVOT; oracle PIVOT/UNPIVOT; snowflake PIVOT/UNPIVOT; a derived table `(SELECT 1 AS a) d WITH (NOLOCK)`. Inputs without hints or pivots are unchanged (pg JOIN alias, mysql derived table). Parent check: git archive of cc23f9b (the parent of 9b56c7d) with the two new test files copied in. All 4 new tests FAIL there (TestTableHintsPivot_SQLRoundTrip_Issue604, TestTableRef_HintsPivot_HandBuilt_Issue604, TestTableHints_FormatAST_Issue604, TestTableHints_FormatAST_KeywordLower_Issue604), and all 4 PASS at HEAD.

#604 race gate: PASS. Ran CGO_ENABLED=1 go test -race -timeout 300s $(go list ./... | grep -v /cbinding) at HEAD 9b56c7d: EXIT=0, 44 packages ok, no FAIL, panic or DATA RACE. Log: /private/tmp/claude-501/-Users-ajitpratapsingh-dev-GoSQLX/b99cd3cf-cb92-4294-a0db-d6a7c9a86253/scratchpad/b3/604/race.txt

Breaking Changes

Observable behaviour changes in SQL(), FormatAST, FormatStatement, Normalize, Fingerprint and Transpile output:

  • Identifiers written with double quotes keep them.
  • CTE names and column lists, CREATE [MATERIALIZED] VIEW names and column lists, and FROM/JOIN table names are double-quoted when they contain non-identifier characters or are a reserved word the parser cannot accept bare there.
  • Unquoted input renders as before.
  • ast/formatter: SQL Server table hints WITH (NOLOCK) are never rendered; SQL() also drops PIVOT #604: No exported API changes. The observable change is additive: SQL() now emits WITH (hints) and the PIVOT/UNPIVOT clauses (with the pivot alias as AS alias), and the formatter now emits WITH (hints). Output for table references without hints or pivots is byte-identical. No new AST fields, so no pool-reset changes.

Render-time injection hardening (added during the merge train)

The adversarial and security review found that quoted names and some literal values were written into rendered SQL verbatim. So SELECT 1 AS "a; DROP TABLE x; --" FROM t came back from gosqlx.Format / SQL() as two statements. Main has the same exposure. This PR now closes the whole class:

  • Names: SELECT, table, derived-table, JOIN, PIVOT, UPDATE/DELETE and MERGE aliases, window names, DML targets and column lists, and DDL names (CREATE TABLE/INDEX, DROP, TRUNCATE, ALTER, REFRESH, SHOW, DESCRIBE) are quoted when they are not plain, or when they are reserved. Constraint names, function names, data types, INTERVAL values, EXTRACT fields and PRAGMA are covered too.
  • Tokenizer: a token opened with an ASCII ' or " now closes only on that same ASCII quote. Smart quotes such as ” and ’ inside it are ordinary content. They used to end the token on re-parse, which allowed injection through SELECT 1 AS x”; DROP…“``.
  • Keyword fields (join type, DROP object type, set operator, interval qualifier, null treatment, constraint/partition/ALTER/MERGE types, table options, view WITH option, SHOW kind, frame and lock types, FK actions, FETCH, SAMPLE) are checked against exact allow-lists. Every value the parser produces is on the lists, and a guard test enforces that. A hand-built value outside its list is dropped, or written so that the output fails to parse. It can never add a statement.
  • Formatter: the -- UNSUPPORTED: comment now comments out every line, so a newline in a literal can't start a live statement.

Validation (independent Opus validators, 4 rounds):

  • Template fuzzer: 549k hostile-name cases × 9 dialects × 4 renderers. 0 injected statements; main has 5,538.
  • Mutation fuzzer over hand-built ASTs, including word-only payloads: 0 injectable paths; 327 before the last round.
  • Tokenizer differential against main: 19.5k inputs × 9 dialects. The only differences are smart quotes inside ASCII-quoted tokens.
  • Render differential against the pre-allow-list head: 191k records, byte-identical. Tokenizer and parser benchmarks are within ±1% geomean, with allocations unchanged.

Additional behaviour changes:

  • T-SQL table hints must be bare words: WITH (INDEX = ix) is now a parse error. It used to be accepted and the hint silently dropped.
  • CONSTRAINT "x y" (double-quoted constraint names) now parses in SQL Server. A name quoted only for its case, such as "MixedCase", renders bare, consistent with other identifiers.
  • CAST(x AS "type") now parses. Quoted types that aren't plain keep their quotes.
  • Smart quotes inside ASCII-quoted strings and identifiers are kept as written, instead of being turned into ' / ".
  • A non-ASCII lıke is stored as LIKE. Window rendering no longer repeats the window name or wraps OVER w in parentheses. The formatter keeps WITH in WITH CHECK OPTION.

Follow-ups found during review, already on main and not changed here: ALTER TABLE ... ADD CONSTRAINT renders empty. SQL Server CROSS APPLY renders as CROSS APPLY JOIN. MySQL MATCH ... AGAINST duplicates AGAINST. In the default dialect, "#x" renders bare and fails to re-parse.

Notes

Fixes #601
Fixes #611
Fixes #604
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 11:31am UTC

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

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


PR review: #632 (NOT EXISTS, quoted identifiers, table hints/PIVOT render)

Verdict: looks good. I have a few non-blocking suggestions. I read the core changes and spot-checked the pooling and render paths. I did not run the test suite, and I didn't review every test file in detail. The PR description reports a clean race gate, vet and lint.

I couldn't diff against origin/main. The checkout has no merge base with it, so git diff origin/main...HEAD failed. I reviewed the files directly at HEAD instead.

What I checked

  • Pool reset (ast/formatter: quoted identifiers that are keywords or case-sensitive lose their quotes on render (SELECT "select", CTE/view column lists, table names) #611): Identifier.Quoted is cleared on release. PutExpression does *e = Identifier{}, so the new field needs no extra reset code. TestIdentifierPool_ResetsQuoted also covers it.
  • Quoting helpers (pkg/sql/ast/quote_names.go):
    • isReservedName uses a stack buffer and doesn't allocate.
    • QuoteName and QuoteQualifiedName return the input unchanged when nothing needs quoting.
    • Embedded " is doubled correctly.
    • The reservedNameWords list is guarded by TestReservedNameWordsRejectedBare, so it will fail if the parser starts accepting a word bare. That was the main risk of a hard-coded list.
  • Table ref rendering (pkg/sql/ast/sql.go:1969-2003, sql_table_hints_pivot.go): the order is alias, FINAL, FOR SYSTEM_TIME, hints, PIVOT/UNPIVOT, then AS alias. It matches the parser order described in the PR. Tables without hints or pivots render as before.

Suggestions (non-blocking)

  1. maxReservedNameLen = 16 is a silent coupling. If someone adds a reserved word longer than 16 characters (for example CONCURRENTLY is 12 and AUTOINCREMENT is 13, so the current margin is small), it will never match. A test that asserts len(w) <= maxReservedNameLen for every key in reservedNameWords would make that fail loudly.
  2. Dialect-specific over-quoting. The "reserved in every dialect" rule means a word is quoted even in dialects that would accept it bare. That is safe for round-tripping, because ANSI "..." parses everywhere according to your tests. It will look surprising in MySQL and SQL Server output, so a doc note would help. You already list dialect-aware output as a follow-up.
  3. Fingerprint behavior change. "id" and id now fingerprint differently, which you call out in the PR. Consider a CHANGELOG line for PR docs: consolidate CHANGELOG for the perf audit and batch-2/batch-3 fixes; refresh performance baselines (#566) #598 that names it explicitly, since downstream consumers who dedupe by fingerprint may see their bucket counts change.
  4. Performance.
    • The +4–6% B/op comes from Identifier growing from 48 to 64 B. If the struct has any padding to reclaim, reordering fields might avoid the size-class bump. I haven't checked the layout, so this is speculative.
    • The ~8% SQL() slowdown is acceptable for a correctness fix.
  5. PIVOT/UNPIVOT values are written verbatim (writePivotClause, writeUnpivotClause). That is fine because the parser preserves the delimiters through renderQuotedIdent. It does mean a hand-built AST with unquoted special characters won't be escaped. TestTableRef_HintsPivot_HandBuilt_Issue604 covers the hand-built case, but hint strings and PivotColumn are not validated. That's acceptable for an internal render path, but worth a doc comment.
  6. Hints are written verbatim (writeTableHints). This is fine for parser output. A hand-built AST with an attacker-controlled hint string would be injected as-is. This isn't a regression, since the renderer already trusts string fields elsewhere.

Test coverage

Coverage looks strong:

  • Round trips across the default dialect plus 8 dialects.
  • Pre-fix failure confirmation for the new tests.
  • Guards for dialects and the Snowflake stage and combining-mark cases.
  • The golden test change is minimal (one line).

Security

No new injection surface. Quoting only adds escaping, and " is doubled correctly.

The PR is fine to merge from my side once you've considered point 1.

--- · Branch: fix/ast-render-not-exists-quotes-hints

@github-actions github-actions Bot added parser ast AST node changes labels Oct 14, 2026
@ajitpratap0
ajitpratap0 force-pushed the fix/ast-render-not-exists-quotes-hints branch from 9b56c7d to 9038ed0 Compare October 14, 2026 07:30
@ajitpratap0

Copy link
Copy Markdown
Owner Author

Rebased onto main to clear conflicts; the only conflict (pkg/sql/ast/sql_golden_test.go select/everything) combines main's window-clause fix with this PR's WITH (NOLOCK). task check passes.

Note for whoever owns GoSQLX-worktrees/fix-ast-render-not-exists-quotes-hints: the untracked pkg/sql/ast/render_injection_632_test.go was NOT included because it fails (10 cases; the PIVOT/UNPIVOT quoting fix isn't written yet). Rebase onto the new head 9038ed0 before pushing.

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

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


PR review: #632 (NOT EXISTS, quoted identifiers, table hints/PIVOT render)

Verdict: looks good to merge. I have only minor, non-blocking notes. I read the changed code. I did not run the test suite. The PR description reports a clean race gate, vet and lint.

Checked

Notes

  1. maxReservedNameLen = 16: this is already guarded. quote_names_test.go:104 asserts every reserved word fits in it, so the earlier concern is covered.
  2. Over-quoting: a word reserved in any dialect is quoted in every dialect, including MySQL and SQL Server, where it would parse bare. Round-trips stay safe. A short doc note would help, and dialect-aware output is already a listed follow-up.
  3. Fingerprints: "id" and id now fingerprint differently. Name this explicitly in the CHANGELOG entry for docs: consolidate CHANGELOG for the perf audit and batch-2/batch-3 fixes; refresh performance baselines (#566) #598, because consumers who dedupe by fingerprint will see their bucket counts shift.
  4. Verbatim strings: table hints and PIVOT/UNPIVOT values are written verbatim. That is fine for parser output, but a hand-built AST with untrusted strings would be injected as-is. A doc comment saying the renderer trusts those fields would be enough.
  5. Performance:
    • The +4–6% B/op comes from Identifier going from 48 to 64 B. Reordering fields is unlikely to help, because adding a bool to string, string and Location{int,int} pushes the struct past 48 B regardless of order.
    • The ~8% SQL() slowdown is acceptable for a correctness fix.

Security

No new injection surface. Quoting only adds escaping.
· Branch: fix/ast-render-not-exists-quotes-hints

ajitpratap0 and others added 14 commits October 14, 2026 14:42
…tput re-parse (#601)

The parser represents NOT EXISTS (subquery) as a unary NOT:
BinaryExpression{Operator: "NOT", Not: true, Left: *ExistsExpression,
Right: nil}. BinaryExpression.writeSQL had no case for that shape and fell
into the generic Not branch, emitting `NOT (EXISTS (...) NOT )`, which does
not re-parse (E2002).

Add a guarded branch at the top of writeSQL that writes `NOT <left>` when
Operator is NOT and Right is nil, matching UnaryExpression NOT. The
formatter (FormatAST compact/readable, FormatString) reaches this code via
exprSQL, so all of them are fixed by the single change. No parser, AST
shape, pooling or API changes.

Regression tests cover WHERE/AND/OR/nested/CASE/parenthesized/HAVING/
UPDATE/DELETE across the default plus 8 dialects, asserting exact output,
re-parse idempotence and the re-parsed AST shape. They fail before the fix
(93 failures) and pass after.

Fixes #601

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
…ved words or case-sensitive on render (#611)

The AST kept no record of which identifiers were quoted, so SQL() and
pkg/formatter dropped the quotes from names such as "select", "key",
"null" and "Mixed". The output then failed to re-parse (E2011/E2002/E2001)
or re-parsed with a different meaning. CTE names and column lists, CREATE
[MATERIALIZED] VIEW names and column lists, and FROM/JOIN table names were
written with no quoting check at all, so "a b" or "my table" also broke.

- ast.Identifier gains an additive `Quoted bool` field. The parser sets it
  when the final name part is an ANSI double-quoted identifier: the primary
  identifier path, qualified and multi-part column references, and
  parseIdent. Identifier.SQL() always re-quotes a Quoted name. Backtick and
  bracket identifiers keep Quoted=false and render as before. The pool
  release path (`*e = Identifier{}`) clears the field, and a test proves it.
- Name positions stored as plain strings (CTE name/columns, view
  name/columns, TableReference.Name) now go through new helpers in
  pkg/sql/ast/quote_names.go. A name is double-quoted when it contains a
  non-identifier character or is a word the parser rejects bare in that
  position in every dialect. Such a parsed name must have been quoted in
  the source, so quoting it reproduces the source exactly. Table positions
  keep '$', '#' and '@' bare (SQL Server #temp, Snowflake @stage). The
  word list (120 for all positions, plus 13 rejected only in CTE names and
  column lists) is guarded by TestReservedNameWordsRejectedBare across all
  9 dialects.
- New exported helpers ast.QuoteName and ast.QuoteQualifiedName (ANSI
  double quotes, zero-allocation fast path) let pkg/formatter apply the
  same quoting in renderCreateView, renderCreateMaterializedView, the CTE
  renderer and tableRefSQL.

Observable change: a double-quoted identifier now keeps its quotes in
SQL(), FormatAST, Normalize, Fingerprint and Transpile output
(`SELECT "id" FROM t` stays quoted). Unquoted input renders unchanged.
One expectation updated: mysql_issue521_review_test.go now expects
`SELECT "my db".t."c" FROM t`, because the source quoted "c".

Tests: the 5 issue inputs plus CTE-name, materialized-view, JOIN "order",
qualified and "null"/"true" cases round-trip exactly through SQL() and
FormatAST (compact and readable) in all 9 dialects. No-change guards cover
bare mixed case, FROM key, t.key, #temp, @stage, DUAL, ClickHouse d.1 and
MySQL backticks. Also added: a parser Quoted-flag test, helper tables,
0-alloc checks and the reserved-word guard.

Deferred (follow-ups): case preservation for string-typed names (FROM
"Users", WITH c ("Mixed")), qualifier quoting/case (Identifier.Table),
Quoted for INSERT/UPDATE/USING/PIVOT/DDL column identifiers, alias
quoting, and dialect-aware quote style.

Fixes #611

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
Leave Snowflake stage references (names starting with '@') verbatim in
table positions. A stage with a /path suffix such as @mystage/data was
being double-quoted by SQL() and FormatAST, which re-parses as a table
literally named "@mystage/data" rather than a stage. Adds no-change
guards for SQL(), FormatAST (compact and readable) and
QuoteQualifiedName.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
Treat Mn and Mc combining marks and Pc connector punctuation as bare
identifier runes after the first rune in nameNeedsQuoting, mirroring
the tokenizer's unicodeIdentifierPartSlow. Bare table, CTE and view
names written in Indic scripts or with decomposed accents (for example
FROM उपयोगकर्ता or FROM Cafe + U+0301) were being double-quoted by SQL()
and FormatAST. That made a string literal in MySQL and stopped case
folding in PostgreSQL.

Adds no-change guards through SQL() in all nine dialects, FormatAST
(compact and readable) in MySQL, PostgreSQL and generic, and
QuoteName/QuoteQualifiedName table entries, including names that start
with a mark or Pc rune, which still get quoted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
…n SQL() and FormatAST (#604)

ast SQL() dropped TableReference.TableHints and PIVOT/UNPIVOT (keeping only
the alias, so "FROM s PIVOT (...) AS p" became "FROM s p"), and pkg/formatter
(and gosqlx.FormatAST) dropped the table hints.

- ast.writeTableRef now emits WITH (h1, h2), PIVOT (...) and UNPIVOT (...)
  after the alias/FINAL/FOR SYSTEM_TIME, with the PIVOT/UNPIVOT alias
  rendered as AS alias. Helpers live in sql_table_hints_pivot.go.
- formatter.tableRefSQL emits hints between a plain alias and PIVOT/UNPIVOT.
- Output for table references without hints or pivots is unchanged; the
  select/everything golden gains " WITH (NOLOCK)".

Round trips are covered for sqlserver, oracle and snowflake. The CLI
renderer (cmd/gosqlx) is handled by #599 switching to pkg/formatter.

Fixes #604

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
F1 (security, #604): SQL Server table hints were stored from any token
with its delimiters stripped and rendered verbatim, so
WITH ('x); DROP TABLE y; --') rendered as two statements through SQL()
and FormatAST. parseTableHints now accepts only unquoted bare-word
tokens ([A-Za-z_][A-Za-z0-9_]*) and rejects string literals, quoted or
bracketed identifiers and operators (INDEX = ix). writeTableHints and
the formatter double-quote any non-bare hint in a hand-built AST (new
ast.QuoteTableHint).

F2 (security, #604): PIVOT/UNPIVOT column names and IN-list entries were
written verbatim although the parser stripped quotes from string
literals and quoted identifiers, so crafted input split into several
statements and IN ('JAN', 'FEB') became identifiers. The parser now
stores IN-list string literals (values and UNPIVOT AS aliases) re-quoted
and escaped like LiteralValue.SQL(), and PIVOT IN aliases keep their
quote delimiters. SQL() and the formatter quote pivot/unpivot column
names that are not plain names (ast.QuotePivotColumn) and double-quote
any IN-list entry that is not a well-formed "atom [AS atom]"
(ast.QuotePivotItem).

F3 (regression, #611): quoted references were re-quoted for case while
the aliases and CTE/view column lists they refer to render bare, so
SELECT amount AS "Total" ... ORDER BY "Total" rendered an alias and a
reference that no longer match after case folding. Identifier.SQL() now
re-quotes a Quoted name only when it is a reserved word; case-only
quoting renders bare, consistent with the definitions, until string-typed
name positions record quoting.

Regression tests: re-parse-to-one-statement and exact-output cases for
SQL() and FormatAST, parser rejection of non-word hints, hand-built
unsafe hints/pivot items (no statement terminator in the output), and
alias/CTE-column consistency through SQL() and gosqlx.Format. Existing
#611 expectations for "Mixed" and "my db"."t"."c" updated accordingly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
…nder (#632)

SQL() and FormatAST wrote aliases and several user-supplied names
verbatim although the parser strips their quote delimiters, so
SELECT 1 AS "a; DROP TABLE x; --" FROM t rendered as two statements.
These positions now go through the same quoting rule as the names this
PR already covers (ANSI double quotes, doubled inner quotes, any dialect):

- select-item aliases (AliasedExpression) and ClickHouse ARRAY JOIN
  aliases: ast.QuoteName (non-identifier characters or a word rejected
  bare after AS, so AS "select" + ORDER BY "select" stay consistent)
- table aliases on FROM/JOIN tables, derived tables and PIVOT/UNPIVOT,
  UPDATE/DELETE aliases and MERGE target/source aliases: new
  ast.QuoteTableAlias (reserved only for words rejected bare in every
  position, so "source"/"target" aliases stay bare like their qualifiers)
- WINDOW definition names and OVER window references: ast.QuoteName
- INSERT/UPDATE/DELETE target tables: writeQualifiedName /
  ast.QuoteQualifiedName (#temp and @stage kept bare)
- qualifier parts that are words rejected bare in every position
  ("from".a) are quoted, matching the table alias they refer to

Two rendering bugs in the same code are fixed along the way: a WINDOW
definition repeated its own name inside the parentheses
(WINDOW w AS (w ORDER BY x)) and OVER w rendered as OVER (w); neither
re-parsed. Golden expectations "extra/named window" (now stable) and
"select/everything" are updated for that.

isReservedName gains a first-letter/length mask and isBareNameChars an
ASCII fast path so the extra checks stay alloc-free and cheap
(BenchmarkSQL within ~3-5%, allocations unchanged).

Regression tests parse each hostile position in default, PostgreSQL,
MySQL, SQL Server, Oracle, Snowflake, ClickHouse and SQLite, render via
SQL() and FormatAST (compact and readable), and require exactly one
statement carrying the same name on re-parse; benign aliases are pinned
unchanged and hand-built hostile aliases must not emit a terminator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
…n render (#632)

SQL() and FormatAST still wrote many parser-unquoted names verbatim, so
CREATE TABLE "t; DROP TABLE x; --" (a INT) rendered as three statements.
Every remaining such position now goes through the #632 quoting helpers
(ANSI double quotes, doubled inner quotes, any dialect):

- table-like names via writeQualifiedName / ast.QuoteQualifiedName:
  CREATE TABLE, CREATE INDEX name and table, REFERENCES table, INHERITS,
  DROP (all object kinds), TRUNCATE, REFRESH MATERIALIZED VIEW, ALTER
  TABLE, FOR UPDATE OF, DESCRIBE, REPLACE INTO, SHOW object/FROM,
  sequence names, MERGE UPDATE SET targets and Ident
- single-part DDL names via new writeColumnName / ast.QuoteColumnName
  (same rule as a table alias, so columns named key or default stay
  bare): column definitions, constraint names and column lists,
  REFERENCES columns, index columns and USING method, PARTITION BY
  columns, ON CONFLICT ON CONSTRAINT, MERGE INSERT columns, ALTER DROP
  COLUMN, PERIOD FOR, formatter TABLESPACE and COLLATE
- function names: a part is quoted only for non-identifier characters,
  so COUNT, LEFT, REPLACE, IF and s.fn render unchanged
- column and CAST data types and CREATE TABLE option values are written
  verbatim only when plainly shaped (words, balanced parentheses, closed
  single-quoted arguments without backslashes, no "--"); otherwise as one
  quoted identifier. CAST(x AS "t") now parses, as x::"t" already did
- INTERVAL values are escaped as string literals; quoted EXTRACT fields,
  PRAGMA names/arguments/values and SHOW kinds are quoted
- formatter "-- UNSUPPORTED:" comments continue across line breaks, so a
  string literal holding a newline can no longer emit a live statement

QuoteQualifiedName now keeps '#' bare only as a leading SQL Server temp
marker and '@' only for a stage reference made of path characters; a
mid-name '#', '$' or '@' (which the tokenizer rejects bare and MySQL reads
as a comment start) and an '@' name with other characters are quoted.

SHOW COLUMNS/INDEX FROM t rendered without FROM and did not re-parse; the
FROM is restored.

Regression tests parse each hostile position in default, PostgreSQL,
MySQL, SQL Server, Oracle, Snowflake, ClickHouse and SQLite, render via
SQL() and FormatAST (compact and readable), and require exactly one
statement carrying the same text on re-parse; benign DDL and function
calls are pinned unchanged and hand-built hostile nodes must not emit a
terminator or comment. No golden files change. BenchmarkSQL within ~2%,
allocations unchanged.

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

A token opened with an ASCII quote (' or ") was closed and escaped by any
Unicode quote that normalizeQuote maps to it (U+2018/2019/00AB/00BB for ',
U+201C/201D for "). A rendered identifier or string holding such a quote,
e.g. SELECT 1 AS "x”; DROP TABLE pwn; --“", therefore re-tokenized as
several statements. Now only the same ASCII quote (and its doubling)
closes or escapes such a token; Unicode quotes inside it are kept verbatim
as content. Tokens opened with a Unicode quote keep their normalizing close
rules. Applies to the fast and slow quoted-identifier and string readers
and to StringLiteralReader.

Golden digests for the 8 inside-ASCII-quote edge cases (edge:27,30-33,
52,54,55 and their ctx forms) are updated; quoted-literal table cases that
pinned normalization inside ASCII quotes now pin the content-preserving
behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
- Never write free-text operator/keyword fields raw: binary, ANY/ALL and
  set operators go through IsOperatorText (quoted otherwise); interval
  qualifier, NULL treatment and view WITH option are written only when
  plain keyword text. The formatter now also writes the missing WITH
  before a view's CHECK OPTION.
- Quote names that start with a digit ("1abc" no longer renders as
  1abc, which re-parsed as 1 AS abc); all-digit identifier parts such as
  ClickHouse d.1 stay bare.
- Keep quotes on data types that are not plain: a space outside
  parentheses is only allowed before known multi-word type words, and
  brackets only as an array suffix.
- Quote reserved-word write targets (INSERT columns, SET / DO UPDATE SET /
  ON DUPLICATE KEY UPDATE targets, ON CONFLICT targets), PIVOT/UNPIVOT
  columns, and reserved unqualified function names that the parser does
  not accept bare ("select"(x)); guarded by TestKeywordFunctionNames.
- Parser accepts a double-quoted table-constraint name, so SQL Server
  CONSTRAINT [#x] / [x y] renders as CONSTRAINT "#x" and re-parses.

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

- Restore bare rendering of ClickHouse column types carrying CODEC(...),
  which 919720d started quoting: CODEC is now a type modifier word when
  followed by its argument list. A corpus test checks every column and
  CAST type the parser produces (testdata x 8 dialects plus parser test
  literals) renders unquoted unless the source quoted it.
- Never write keyword-valued fields raw from hand-built ASTs. Join type,
  DROP object type, column/table constraint kind, table option name,
  PARTITION BY kind, MERGE WHEN kind and action, and ALTER TABLE action
  type are written only when plain keyword text; index direction,
  DROP/TRUNCATE CASCADE/RESTRICT, FOR lock strength, FETCH FIRST/NEXT,
  ON DELETE/UPDATE actions and window frame unit, bound and EXCLUDE are
  written only from their known keyword sets; SAMPLE ratio and offset
  only when numeric. Every value the parser stores still renders the same.
- Quote table constraint names that are keywords (key, index, default,
  table, owner, ...) or not lower-case plain ("MixedCase"), so they
  re-parse as the same name; TestConstraintNameWords keeps the extra
  keyword list in step with the parser.
- Store the canonical LIKE / ILIKE / REGEXP / RLIKE operator when the
  keyword only matched by Unicode case mapping (x NOT lıke 'a'), so it
  renders as x NOT LIKE 'a' instead of a quoted operator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
Quoting a constraint name only because of its case changed unquoted names
such as PK_USERS into "PK_USERS", which case-folding dialects read as a
different name. Quote constraint names only when they contain non-plain
characters or are keywords, matching Identifier.SQL().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
The parser accepts consecutive statements without ';', so the previous
"letters and single spaces" gate (KeywordText / IsPlainKeywordText) still
let a hand-built AST inject statements through word-only text, e.g.
JoinClause.Type = "CROSS JOIN c DROP TABLE pwn SELECT 1 FROM a INNER".

Every keyword field is now written only when it is exactly one of the
values the parser stores (any case, single spaces), plus a few equivalent
spellings: join type, DROP object type, null treatment, interval qualifier
(all units and <unit> TO <unit>), set operator, binary/ANY/ALL word
operators, column/table constraint kind, PARTITION BY method, ALTER TABLE
action, table option name, view WITH option, MERGE WHEN kind and action.
SHOW kinds stay open (the parser stores any single word) but are written
bare only as one word or CREATE plus one word; anything else is quoted.

Fallbacks: optional modifiers are dropped; an unknown join type leaves a
plain JOIN; unknown DROP object type, PARTITION BY method, MERGE kind or
action and table constraint kind are left out so the SQL fails to parse;
an unknown set operator is written as UNION plus a quoted token, which
fails to parse instead of splitting into two statements. MINUS is not
written (outside Oracle/Snowflake it re-parses as an alias).

Parsed SQL renders byte-identical to the previous commit over the corpus
in all dialects; a guard test checks the allow-lists cover every value the
parser stores from testdata/**/*.sql.

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

The allow-list sent a hand-built MINUS to the unparseable UNION "<text>"
fallback. MINUS and EXCEPT are equivalent and the parser already stores
MINUS as EXCEPT, so write EXCEPT, which every dialect parses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
@ajitpratap0
ajitpratap0 force-pushed the fix/ast-render-not-exists-quotes-hints branch from 9038ed0 to e6be50f Compare October 14, 2026 11:30
@github-actions github-actions Bot added the tokenizer Tokenizer / lexer changes label Oct 14, 2026
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

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


PR review: #632 (NOT EXISTS, quoted identifiers, table hints/PIVOT render)

Verdict: I could only do a limited review, so treat this as a partial read. The sandbox denied go test and go build, and I couldn't diff against origin/main. I ran no tests and didn't read the new code in detail. The two earlier bot reviews on this PR covered the core render changes. I have no new findings to add.

What I did not cover

  • The PR now has 14 commits and 45 files. The description still lists 5 commits and 19 files.
  • The tokenizer, parser and quote_names.go (1228 lines) changes aren't in the description. They include:
    • pkg/sql/tokenizer/tokenizer.go, string_literal.go, unicode.go, and a changed tokenizer golden file;
    • pkg/sql/parser/expressions_*.go, pivot.go, select_subquery.go.
  • I didn't check those files.

Suggestions

  1. Update the PR description. Add the tokenizer and parser changes. The tokenizer golden file (tokenizer_literal_golden.json) changed, and that is a behaviour change reviewers should see called out.
  2. Add a CHANGELOG note for PR docs: consolidate CHANGELOG for the perf audit and batch-2/batch-3 fixes; refresh performance baselines (#566) #598 on fingerprints. Fingerprints now keep double quotes, so "id" and id fingerprint differently. Anyone who dedupes by fingerprint will see their buckets shift.
  3. Consider a doc comment on the verbatim string fields. Table hints and PIVOT values are written as-is. That is fine for parser output. A hand-built AST with untrusted strings would be injected into the output.
  4. Over-quoting is intentional but worth documenting. Reserved words are quoted in every dialect, including MySQL and SQL Server, where they would parse bare. Round-trips stay safe, and dialect-aware output is already a listed follow-up.
  5. The Identifier size class is unavoidable. The 48 to 64 B growth comes from adding any field to the struct, so I wouldn't spend time reordering fields.

Before merge

  • Please confirm task check is green on the rebased head, since I couldn't run it.
  • The untracked render_injection_632_test.go was left out because the PIVOT/UNPIVOT quoting fix isn't written yet. Both pkg/formatter/render_injection_632_test.go and pkg/sql/ast/render_injection_632_test.go appear in the changed-files list. Please confirm they pass or are skipped, since the earlier comment says 10 cases fail.
    · Branch: fix/ast-render-not-exists-quotes-hints

@ajitpratap0

Copy link
Copy Markdown
Owner Author

Merge train (batch 3): #632

  • Rebase: onto main 95978a4, which comes after fix(parser): linear qualified-name joining and correct E2002 error context (#613, #616) #627. Conflicts in sql.go, render.go and sql_golden_test.go against fix(parser,ast,formatter): named window round-trip, GROUPS/EXCLUDE frames and INTERVAL bounds (#608, #609) #629 were resolved by keeping main's BaseWindow / INTERVAL Qualifier structure and applying this PR's quoting on top. An independent validator checked the resolution. The head is e6be50f.
  • Local gate on the final head: go vet ./..., golangci-lint (0 issues) and go test -race on every package except cbinding pass, as does the pre-commit hook. CI is 28/28 green.
  • Adversarial and security pass: the first round confirmed 3 must-fix findings: table-hint injection, PIVOT literal injection, and alias/reference quoting mismatch. While fixing them, the review found that every quoted-name position, plus several literal and keyword fields, rendered verbatim, so SELECT 1 AS "a; DROP TABLE x; --" came back as two statements. Main has the same exposure. The PR now closes that class; see the "Render-time injection hardening" section of the description. It took four Opus fix/validate rounds:
    • Round 1, aliases and DDL names: ASCII payloads were closed, but smart quotes still terminated ASCII-quoted tokens. Fixed at the root in the tokenizer.
    • Round 2: a ClickHouse CODEC(...) type regression, plus raw keyword fields in hand-built ASTs. Fixed.
    • Round 3: word-only payloads, such as a JoinClause.Type with no ;, could still start a statement, because the parser accepts unseparated statements. Replaced with exact per-field allow-lists and a guard test proving every parser-produced value is listed.
    • Round 4: pass.
      • Mutation fuzzer: 0 injectable paths across 36,100 dialect/field pairs.
      • Template fuzzer: 0 injected statements over 549k cases.
      • Render differential versus the previous head: 191,592 records, byte-identical.
      • Tokenizer differential versus main: differs only on smart quotes inside ASCII-quoted tokens.
      • Benchmarks are within ±1% geomean, with allocations unchanged.
  • Since round 4: e6be50f renders a hand-built MINUS as EXCEPT, which is equivalent and which the parser already uses, instead of the unparseable fallback. It has a test.
  • Bot review on this head:
    • The description is now updated.
    • Fingerprints: "id" and id still fingerprint identically (7d3a655067d7 for both). Only reserved-word names change: "select" now normalizes to "select" instead of the invalid bare select. This will be noted in the CHANGELOG (docs: consolidate CHANGELOG for the perf audit and batch-2/batch-3 fixes; refresh performance baselines (#566) #598).
    • Hint and PIVOT values are no longer verbatim: they go through QuoteTableHint, QuotePivotColumn and QuotePivotItem, and string literals are escaped.
    • The render_injection_632_test.go files are committed and pass.
    • Dialect-aware quoting is a listed follow-up.
  • Behaviour changes:
    • T-SQL WITH (INDEX = ix) is now a parse error. It used to be silently dropped.
    • SQL Server accepts CONSTRAINT "x y".
    • CAST(x AS "type") parses.
    • Smart quotes inside ASCII-quoted tokens are preserved.
  • Follow-ups (already on main):
    • ALTER TABLE ... ADD CONSTRAINT renders empty.
    • CROSS APPLY renders as CROSS APPLY JOIN.
    • MATCH ... AGAINST renders a duplicated AGAINST.
    • Default-dialect "#x" renders bare.

@ajitpratap0
ajitpratap0 merged commit 728c544 into main Oct 14, 2026
28 checks passed
ajitpratap0 added a commit that referenced this pull request Oct 14, 2026
…sts with #632/#623

Integration with #632 (now on main), which quotes reserved-word names on
render:
- ALTER TABLE ... ADD COLUMN "order" now formats correctly, so the guard
  test expects the quoted output instead of a refusal.
- In MySQL/MariaDB a backtick keyword such as `select` would render as
  "select", which MySQL reads as a string unless ANSI_QUOTES is set. The
  new checkMySQLDoubleQuotes refuses output that adds double-quoted tokens
  the input did not have, keeping the CLI's previous safe refusal.

Integration with #623: pkg/formatter writes table aliases without AS, so
the UPDATE ... FROM list expectation becomes "u v".

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
Review follow-ups for the rebase integration:
- checkMySQLDoubleQuotes compared only counts, so a double-quoted MySQL
  string written bare could cancel out an identifier newly written in ANSI
  quotes. It now requires the output to keep exactly the input's
  double-quoted tokens, which also refuses WHERE name = "bob" -> name = bob
  (a string silently becoming a column reference). Input that uses "..." as
  a name where MySQL cannot accept a string ("users"."id", FROM "users") is
  ANSI_QUOTES-style and is not checked; no MySQL/MariaDB corpus file is
  refused.
- TestSQLFormatter_NotExistsDoesNotPanic: the #601 Skipf is unreachable now
  that #632 is on main; fail instead.
- The corpus round-trip test now fails when a known-gap file starts to
  format, so the list cannot go stale.

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
…ound-tripping output (#599) (#624)

* fix(cli): render gosqlx format through pkg/formatter and refuse non-round-tripping output (#599)

`gosqlx format` used its own 1,476-line renderer in
cmd/gosqlx/cmd/sql_formatter.go. Any expression type it did not handle fell
back to TokenLiteral(), and formatInsert dropped every clause after
VALUES/SELECT. The command exited 0 with SQL of a different meaning, or with
SQL that does not parse (CAST -> `CAST`, `a = ANY(...)` -> `ANY`, tuples ->
`TUPLE`, `arr[1]` -> `[]`, ROLLUP/CUBE/GROUPING SETS, WITHIN GROUP,
`EXISTS (SELECT 1 UNION SELECT 2)` -> `EXISTS ()`, ON CONFLICT/RETURNING
dropped, PIVOT dropped, lossy ALTER TABLE).

- sql_formatter.go is now a thin adapter. SQLFormatter, NewSQLFormatter,
  FormatterOptions and Format keep their signatures. Each statement is
  rendered with pkg/formatter.FormatStatement, with indent, compact and
  keyword case mapped to ast.FormatOptions. AddSemicolon is off and the CLI
  keeps its "; " / ";\n\n" separators. FormatterOptions gains an additive
  Dialect field. AlignColumns is retained but ignored. All parallel rendering
  code is deleted.
- formatSQL now verifies its output. It re-parses the rendering with the same
  dialect and requires the same statement count and a case-insensitive equal
  SQL(). Otherwise it returns a "formatted output does not re-parse / changes
  the query" error, so stdout, -o and -i never receive corrupted SQL and
  --check counts the file as failed. Lowercase-keyword output is verified via
  its uppercase twin, because the parser rejects some lowercase keyword
  sequences.
- pkg/formatter renders *ast.AlterStatement ALTER TABLE operations (ADD
  COLUMN, ADD CONSTRAINT, DROP COLUMN/CONSTRAINT [CASCADE], RENAME TO, RENAME
  COLUMN, ALTER COLUMN) in a new render_alter.go. Before, it rendered a bare
  "ALTER". Non-table ALTER keeps its previous output. render.go gains one
  switch case.

Tests:
- TestFormatSQL_Issue599 covers every reproduction in the issue (compact
  output must match exactly; readable output must round-trip and be
  idempotent). Rows that pkg/formatter itself still loses skip with the
  blocking issue and self-heal when the fix lands.
- TestFormatSQL_CorpusRoundTrip formats all 134 parseable testdata/**/*.sql
  files in three modes, re-parses them, compares SQL() and checks
  idempotence. 8 documented known gaps may error but never emit changed SQL.
- TestFormatStatement_AlterTable* cover the new ALTER rendering.

Blocked on library fixes (these now match pkg/formatter and are fixed
automatically when they land):
- #600: named args, IGNORE NULLS, SETTINGS, ON DUPLICATE KEY UPDATE, OUTPUT
- #603: JOIN USING
- #604: WITH (NOLOCK)
- #601: NOT EXISTS (now an explicit error)
- #611: quoted aliases and identifiers (now an explicit error)

Follow-ups to file:
- CROSS/OUTER APPLY rendering
- MATCH ... AGAINST rendering
- Oracle CONNECT BY rendering
- Snowflake QUALIFY rendering
- backslash literals in dialects that keep them literal
- ALTER ROLE/POLICY/CONNECTOR rendering
- --no-uppercase flag has no effect
- parser case-sensitivity for lowercase `returning` / `cross join lateral`

CLI behaviour change: readable output layout now follows pkg/formatter, and
output that would not round-trip is now an error with a non-zero exit instead
of exit 0.

Fixes #599

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

* fix(cli): drop duplicated "formatting failed" prefix in gosqlx format errors (#599)

formatSQL wrapped its errors with "formatting failed:" and both format
command paths wrapped them again, so users saw
"formatting failed: formatting failed: ...". formatSQL now uses
step-specific wrapping and returns the self-describing round-trip errors
unchanged; the command keeps the single prefix.

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

* fix(cli): address adversarial review for #624

F1 (must-fix, regression): gosqlx format rewrote a non-executing EXPLAIN
into executable SQL. The parser splits "EXPLAIN VERBOSE|ANALYZE <stmt>"
into DescribeStatement{TableName: "VERBOSE"} plus <stmt>; the old CLI
renderer refused DescribeStatement, but rendering through pkg/formatter
accepted it, and the SQL() round-trip check is equally lossy, so
`format -i` turned "EXPLAIN VERBOSE DELETE FROM users;" into
"DESCRIBE VERBOSE; DELETE FROM users" with exit 0 (same for UPDATE,
DROP, TRUNCATE, INSERT, EXPLAIN ANALYZE and MySQL EXPLAIN ANALYZE).

formatSQL now refuses, before rendering, DescribeStatement (drops
EXPLAIN options, the explained query and DESCRIBE object kinds) and
UnsupportedStatement (rendered as a comment), restoring the base
"unsupported statement type" error and leaving input unchanged. As a
general guard it also refuses any parse with more statements than
semicolon-separated segments in the input, since a statement split by
the parser cannot be detected by re-parsing the output.

The underlying parser bug (EXPLAIN VERBOSE/ANALYZE splitting into two
statements, DescribeStatement losing options and object kind) still
needs its own issue.

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

* fix(cli): address adversarial review follow-ups for #624

F2: the format round-trip check passed trivially for statements without
a SQL() method (ALTER, sequences), since AST.SQL() skips them and ""
equals "". verifyFormattedSQL now also compares a canonical
pkg/formatter rendering of every statement, and compares the quoted
tokens of the input and output: each quoted identifier must stay quoted
or mean the same identifier bare (ASCII word, not a keyword for the
dialect, and unchanged by the dialect's case folding), so
`ADD COLUMN "order"`, "Mixed" and MySQL `select` are refused instead
of written bare with exit 0. This also refuses SELECT "Mixed" and
corpus files gorm_joins.sql (alias "CompanyName"), oracle/14_pivot.sql
and 15_unpivot.sql (PIVOT IN 'North' rendered as the identifier North),
which were previously written changed; they are listed as known gaps.

F3 (guard only): every input string literal value must appear in the
output, so backslash literals in dialects where backslash is not an
escape are refused with an explicit "changes string literal" error.
The renderer still re-escapes backslashes MySQL-style; making
LiteralValue rendering dialect-aware needs the dialect threaded into
pkg/sql/ast SQL() and is left as a follow-up.

F5: UnaryExpression.SQL() wrote nested unary minus as "--", which starts
a line comment; it now writes "- -1". Tests in pkg/sql/ast and
pkg/formatter.

F6: DESCRIBE/DESC were already refused by checkFormattable (previous
commit); added Snowflake DESCRIBE STAGE / VIEW / DESC TABLE regression
tests.

F7: doc.go, formatter.go and sql_formatter.go comments now describe the
check as a comparison of the input's parse with the output's parse, and
state that what the parser drops (comments, skipped clauses) is not
detected.

F9: format --check no longer prints "All N files are properly
formatted" when some files failed.

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

* test(cli): expect pkg/formatter readable layout in JOIN USING format test

TestFormatCommand_JoinUsing_Issue603 (#603) pinned the old CLI renderer's
readable layout ("SELECT\n  *\nFROM ..."). #599 routes `gosqlx format`
through pkg/formatter, whose readable layout keeps a single-item select
list on the SELECT line ("SELECT *\nFROM ..."). Only the readable
expectation changes; the USING assertions (no "ON" rendering, re-parsed
Using columns, nil Condition) are unchanged.

Integration fix for #603 x #599; port into whichever of the two lands
second.

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

* test(cli): drop corpus known-gap entries fixed by #601

With #601 (NOT EXISTS renders as a prefix NOT) and #599 (gosqlx format via
pkg/formatter) both applied, corpus/tpch/q21_suppliers_kept_waiting.sql
and mysql/19_not_exists.sql round-trip in every mode, so they no longer
belong in corpusFormatKnownGaps. Keeping them would let a regression of

The ent_queries.sql reason now names what is still missing after #611:
the quoted qualifier in "groups"."id" (Identifier.Table quoting was
deferred by #611).

Integration fix for #599 x #601/#611; port into whichever lands second.

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

* fix(cli): refuse MySQL output with added ANSI double quotes; align tests with #632/#623

Integration with #632 (now on main), which quotes reserved-word names on
render:
- ALTER TABLE ... ADD COLUMN "order" now formats correctly, so the guard
  test expects the quoted output instead of a refusal.
- In MySQL/MariaDB a backtick keyword such as `select` would render as
  "select", which MySQL reads as a string unless ANSI_QUOTES is set. The
  new checkMySQLDoubleQuotes refuses output that adds double-quoted tokens
  the input did not have, keeping the CLI's previous safe refusal.

Integration with #623: pkg/formatter writes table aliases without AS, so
the UPDATE ... FROM list expectation becomes "u v".

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

* fix(cli): require MySQL output to keep the input's double-quoted tokens

Review follow-ups for the rebase integration:
- checkMySQLDoubleQuotes compared only counts, so a double-quoted MySQL
  string written bare could cancel out an identifier newly written in ANSI
  quotes. It now requires the output to keep exactly the input's
  double-quoted tokens, which also refuses WHERE name = "bob" -> name = bob
  (a string silently becoming a column reference). Input that uses "..." as
  a name where MySQL cannot accept a string ("users"."id", FROM "users") is
  ANSI_QUOTES-style and is not checked; no MySQL/MariaDB corpus file is
  refused.
- TestSQLFormatter_NotExistsDoesNotPanic: the #601 Skipf is unreachable now
  that #632 is on main; fail instead.
- The corpus round-trip test now fails when a known-gap file starts to
  format, so the list cannot go stale.

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>
ajitpratap0 added a commit that referenced this pull request Oct 14, 2026
…e guards

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
…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>
ajitpratap0 added a commit that referenced this pull request Oct 26, 2026
…l 8 dialects (#867)

* fix(parser): model EXPLAIN <stmt> as ExplainStatement

EXPLAIN SELECT ... discarded the query and formatted as DESCRIBE "SELECT";
EXPLAIN ANALYZE|VERBOSE <stmt> split into DESCRIBE ANALYZE; <stmt>, so
formatting turned a non-executing EXPLAIN into the executable statement.

Add ast.ExplainStatement {Keyword, Options, Statement} covering EXPLAIN,
DESCRIBE and DESC with options (ANALYZE, (FORMAT JSON), QUERY PLAN,
FORMAT=JSON, PIPELINE, PLAN FOR), render it in the formatter, and let the
CLI format it instead of refusing it.

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

* test(realworld): add 2.7k-statement multi-dialect real-world corpus with ratchet

Adds testdata/realworld/<dialect>/ corpora (PostgreSQL, MySQL, MariaDB,
SQL Server, Oracle, SQLite, Snowflake, ClickHouse) of production-style app,
reporting, ETL, DDL and dialect-specific SQL, plus:

- TestRealWorldCorpus: split -> parse -> format -> reparse -> idempotency
  -> dropped-word check for every statement, ratcheted against
  testdata/realworld/known_failures.txt (-update-known-failures regenerates)
- scripts/dialect-audit: CLI harness for the same pipeline

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

* fix(parser): operators and predicates (IS forms, ANY/ALL arrays, AT TIME ZONE, COLLATE, bitwise, ESCAPE)

Close the operator and predicate gaps found by the real-world corpus.

Parser
- IS [NOT] TRUE/FALSE/UNKNOWN, IS [NOT] DISTINCT FROM, SQLite "a IS [NOT] expr",
  PostgreSQL/SQLite ISNULL/NOTNULL (normalised to IS [NOT] NULL).
- = ANY/ALL/SOME over arrays and placeholders (new AnyAllExpression); the
  subquery forms keep AnyExpression/AllExpression.
- Postfix AT TIME ZONE and COLLATE (new AtTimeZoneExpression and
  CollateExpression), binding tighter than arithmetic; COLLATE on CREATE
  INDEX columns fills IndexColumn.Collate.
- Binary operators @@, &&, &, |, #, <<, >>, <->, <=>, == via a precedence
  climbing level between comparison and ||, unary ~ and MySQL !, MySQL
  DIV/MOD/XOR/SOUNDS LIKE/&&/:=, SQLite GLOB/MATCH, SIMILAR TO, NOT REGEXP/
  RLIKE. Word operators are gated to the dialect that defines them. <=>, <<,
  >>, <->, == are read from adjacent tokens until the tokenizer emits them.
- LIKE/ILIKE/SIMILAR TO ... ESCAPE (BinaryExpression.Escape).
- Predicates and comparisons chain left to right (a IN (1, 2) = TRUE); SQLite
  IN () and IN tbl; ClickHouse GLOBAL IN keeps the GLOBAL flag; ClickHouse
  cond ? a : b (TernaryExpression).
- Data types: array dimensions (int[3][4]), schema-qualified, multi-word
  (DOUBLE PRECISION, CHARACTER VARYING, TIMESTAMP WITH TIME ZONE, SIGNED
  INTEGER) and CHAR(n) CHARACTER SET x, shared by CAST and ::.
- AND and OR are stored in canonical upper case.

Formatter and AST
- Render the new nodes and fields; extend the operator and data-type
  allow-lists so they are written verbatim.
- format_sql (MCP) now upper-cases AND/OR by default; tests and docs updated.

Real-world corpus: 59 statements now pass, 6 move to a later failure stage.

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

* fix(parser): complete DML grammar for UPDATE, DELETE, INSERT, REPLACE and MERGE

UPDATE and DELETE read a single table name and then expected SET/WHERE, and
the MySQL row limit was parsed but had no AST field, so the formatter
silently dropped LIMIT and the reformatted statement touched every row.

UPDATE / DELETE:
- targets with AS / bare aliases, PostgreSQL ONLY, Oracle derived-table
  targets and FROM-less DELETE, SQL Server TOP (n) [PERCENT]
- MySQL/MariaDB multi-table forms (UPDATE t JOIN u / UPDATE t1, t2,
  DELETE t1[, t2] FROM ..., DELETE FROM t1, t2 USING ...), SQL Server
  DELETE t FROM ... and UPDATE ... FROM t JOIN u
- DELETE ... USING routed into DeleteStatement.Using
- statement modifiers (LOW_PRIORITY, QUICK, IGNORE; SQLite OR ...)
- ORDER BY ... LIMIT n stored on the AST and rendered
- SET targets: qualified columns, (a, b) tuples, @variables, DEFAULT values
- SQL Server OUTPUT [INTO], RETURNING aliases and Oracle RETURNING ... INTO

INSERT: MySQL/SQLite/Snowflake modifiers, INTO-less SQL Server/MySQL form,
VALUES () and DEFAULT items, MySQL row alias, OVERRIDING SYSTEM|USER VALUE,
ON CONFLICT expression targets and partial-index WHERE, OUTPUT ... INTO,
ClickHouse SETTINGS / FUNCTION / FORMAT (FORMAT was dropped before), Oracle
INSERT ALL / INSERT FIRST. REPLACE gains SELECT, SET and RETURNING forms.

MERGE: derived-table sources, DO NOTHING, NOT MATCHED BY TARGET, RETURNING,
Oracle UPDATE ... WHERE / DELETE WHERE and INSERT ... WHERE; the formatter
now renders the OUTPUT clause it used to drop.

New dialect fields live in embedded structs (ast.DMLTargetFields,
ast.InsertDialectFields) so single-table code that reads TableName/Alias is
unchanged; keyword fields are allow-listed on render and reset by the pool
release functions. 91 real-world corpus statements now pass (one moves from
parse to the pre-existing INTERVAL drop).

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

* fix(parser): parse utility statements and keep identifier quoting

parseStatement was a closed switch, so BEGIN, COMMIT, SET, USE, CALL,
GRANT, SHOW ... LIKE, T-SQL DECLARE/EXEC/IF and the rest of the utility
family failed with "expected statement". Backtick- and bracket-quoted
keywords lost their quoting (`key` became the KEY token, [Group] formatted
as a bare GROUP), and keywords such as share, filter and owner were
rejected as aliases or columns.

- Add ast.TransactionStatement, SetStatement, UseStatement, CallStatement
  and a verbatim UtilityStatement whose SQL() reproduces the statement,
  rebuilt from its tokens with delimiters and escapes restored. Dispatch
  them by value after the typed statements, only at a statement boundary
  so the tail of a half-parsed statement is never split off. BEGIN/END
  are gated per dialect (T-SQL and PL/SQL blocks, END only in PostgreSQL
  and SQLite).
- SHOW forms ShowStatement cannot model, Snowflake USE and stage
  operations are now verbatim statements that re-parse (they formatted
  as an "-- UNSUPPORTED" comment); MariaDB ANALYZE <stmt> is an
  ExplainStatement.
- DescribeStatement keeps DESC vs DESCRIBE and the object kind; PRAGMA
  accepts a schema-qualified name.
- Delimited identifiers are never remapped to keywords; Identifier gains
  QuoteStyle so SQL() re-quotes with the source delimiters (`from`,
  [Group]); @/@@ variables are never quoted.
- Accept keyword words as aliases after AS and the role-option keywords
  (OWNER, URL, ...) as column names; allow a delimited implicit alias.

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

* fix(tokenizer): dialect-correct string escapes and missing lexical forms

String literals no longer corrupt on format. The tokenizer and the renderer
disagreed about backslashes: PostgreSQL, Oracle, SQLite, SQL Server and generic
treat a backslash as an ordinary character, MySQL/MariaDB decode escapes
(keeping \% and \_), and ClickHouse/Snowflake decode escapes but keep unknown
ones. The tokenizer now follows dialect.StringEscape, and LiteralValue records
RawBackslash so the renderer writes a literal back under the same rules instead
of doubling every backslash on each format pass (non-idempotent, data
corrupting for regexes and paths). Unknown escapes no longer fail tokenizing.

Lexical coverage:
- operators ^ ^@ <=> <-> << >> == := and { }
- 0x hex numbers, leading-dot (.5) and trailing-dot (5.) numbers
- prefixed strings N'' X'' B'' E'' q'[]' and MySQL _charset'' (kept on the AST
  as LiteralValue.Prefix and written back)
- $ inside identifiers (Oracle/Snowflake/PostgreSQL), # in Oracle identifiers
- SQLite :name, ?NNN and [bracket] identifiers, ClickHouse {p:Type} parameters
- a leading UTF-8 byte order mark is skipped

The real-world corpus ratchet drops 64 known failures.

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

* fix(parser): accept keywords used as column names

FIRST, LAST, NEXT, FILTER, SHARE, SOURCE, TARGET, MATCHED, FINAL,
TEMPORARY and the role-option words were rejected where a column
reference starts (SELECT first, key FROM t), although no clause gives
them a keyword meaning there. Accept them as column names and qualified
name parts; KEY, INDEX and VIEW stay rejected in MySQL, MariaDB, SQL
Server and Oracle, where they are reserved, and none of the words
becomes a function name.

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

* fix(parser): parse and render SELECT-body dialect clauses without dropping them

LIMIT/OFFSET/FETCH values are now expressions when they are not plain
integers ($1, ?, @n, -1, 1 + 1, subqueries), with LIMIT ALL, OFFSET before
LIMIT, OFFSET n ROWS, FETCH FIRST ROW ONLY and SQLite LIMIT a, b. The new
SelectStatement fields (LimitExpr, OffsetExpr, LimitAll, OffsetRows, ...)
sit beside the existing *int fields, which stay filled for integers.

The formatter and SelectStatement.SQL() now render every SELECT field:
QUALIFY, START WITH / CONNECT BY, PREWHERE, ARRAY JOIN, TOP inside derived
tables (keeping TOP (n) parentheses), OFFSET ... ROWS before FETCH, and
CROSS/OUTER APPLY without a trailing JOIN. A reflection test fails when a
new SelectStatement field is not rendered by both renderers.

New syntax, dialect-gated:
- MySQL select modifiers (SQL_CALC_FOUND_ROWS, STRAIGHT_JOIN, ...),
  STRAIGHT_JOIN joins, JOIN without ON, LOCK IN SHARE MODE, INTO
  @vars / OUTFILE / DUMPFILE; MariaDB/Oracle FOR UPDATE WAIT n
- SELECT ... INTO table (SQL Server, PostgreSQL) and WHERE / GROUP BY /
  HAVING without FROM
- GROUP BY ... WITH ROLLUP / CUBE / TOTALS kept as written (MySQL rejects
  ROLLUP(...)), GROUP BY ALL (Snowflake, ClickHouse, BigQuery)
- ClickHouse LIMIT n BY, LIMIT n WITH TIES, FORMAT, INTERPOLATE, GLOBAL and
  ANY/ALL/ASOF/SEMI/ANTI join strictness, * EXCEPT / REPLACE
- Snowflake TOP n, * EXCLUDE / REPLACE / RENAME / ILIKE, ASOF JOIN ...
  MATCH_CONDITION
- SQL Server FOR JSON / FOR XML / FOR BROWSE, OPTION (...) query hints,
  HASH/LOOP/MERGE/REMOTE join hints; Oracle CROSS/OUTER APPLY, ORDER
  SIBLINGS BY, START WITH after CONNECT BY; PostgreSQL ORDER BY ... USING op
- a comma-separated table after an explicit join

Non-integer LIMIT literals such as 10.5 are no longer truncated to an int;
they are kept verbatim in LimitExpr so formatting never changes the row
count. The real-world corpus ratchet drops 95 known failures.

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

* fix(parser): reject utility keywords without arguments

A bare GRANT, KILL or EXEC was accepted as a complete verbatim
statement. Require at least one argument token except for the
statements that are complete on their own (VACUUM, ANALYZE, REINDEX,
CLUSTER, CHECKPOINT, and T-SQL THROW, RETURN, REVERT).

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

* fix(parser): parse keyword-named, qualified and special-form function calls and typed literals

Primary expressions and function-call forms that failed to parse or
silently changed meaning on format now round-trip:

- Keyword-named functions: LEFT/RIGHT, GROUPING, TRUNCATE, DEFAULT, ROW,
  INSERT, ClickHouse any/range, Snowflake HASH, MySQL INTERVAL(n, ...).
- Qualified function calls (pg_catalog.now(), auth.uid(), pkg.fn()) and
  the Oracle (+) outer-join marker (OuterJoinMarker).
- Typed literals DATE/TIME/TIMESTAMP '...' (any type name in PostgreSQL)
  as TypedLiteral; SQL Server N'...' strings (Type "nstring"); adjacent
  string literals concatenated in MySQL, MariaDB and PostgreSQL.
- SQL-standard special forms via KeywordArgument: SUBSTRING FROM/FOR,
  POSITION IN, TRIM BOTH/LEADING/TRAILING ... FROM, OVERLAY PLACING,
  CONVERT USING, TRY_PARSE/PARSE AS, COLUMN_GET AS, LISTAGG ON OVERFLOW.
- GROUP_CONCAT SEPARATOR is kept in FunctionCall.Separator instead of
  being rendered as an extra argument (which changed results).
- Oracle KEEP (DENSE_RANK FIRST|LAST ORDER BY ...) as FunctionCall.Keep;
  CONNECT_BY_ROOT and PRIOR in the select list.
- INTERVAL <expr> <unit> keeps the value unquoted (IntervalExpression
  ValueExpr/Unit), so MySQL INTERVAL 1 DAY no longer formats as the
  invalid INTERVAL '1 DAY'; legacy Value text is preserved.
- MATCH ... AGAINST is a MatchAgainstExpression (was rendered as
  AGAINST AGAINST(...)).
- NEXT VALUE FOR, MySQL @@[scope.]var and @v := expr.
- ROW(...), nested ARRAY[[...]], ARRAY(<set operation>), bare bracket
  array literals with subscripts (ClickHouse, Snowflake), ClickHouse
  tuple element access (t).1, CAST(x, 'Type') and extract(s, re).
- TRY_CAST is rendered as TRY_CAST, CAST target types accept array
  suffixes and multi-word types (DOUBLE PRECISION, SIGNED INTEGER).

The real-world corpus ratchet drops 123 known failures; three move to a
later stage.

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

* fix(formatter): map LIMIT expressions to TOP/FETCH and allow APPLY table functions

Formatting for SQL Server or Oracle now turns LIMIT / OFFSET expression
values ($1, @n, :n) into TOP (expr) or OFFSET expr ROWS FETCH NEXT expr ROWS
ONLY, as it already did for integers, and drops LIMIT ALL. The right side of
CROSS APPLY / OUTER APPLY may be a table-valued function call such as
sys.dm_exec_sql_text(qs.sql_handle) in dialects whose FROM clause does not
otherwise take function calls.

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

* fix(parser,ast): keep dotted quoted collations and index COLLATE; walk new operator nodes

- COLLATE "en_US.utf8" was written back as en_US."utf8" (a schema-qualified
  name): CollateExpression now carries the qualifier in a Schema field, so a
  dot inside a quoted name stays part of the name.
- CREATE INDEX ... (a COLLATE x): CreateIndexStatement.SQL() dropped the
  collation, and the formatter wrote COLLATE "C" as bare C (PostgreSQL folds
  it to c). IndexColumn gains CollateQuoted; both writers emit the collation.
- transform's expression walker now descends into AT TIME ZONE, COLLATE,
  ANY/ALL/SOME over arrays, the ClickHouse conditional and LIKE ... ESCAPE, so
  QualifyColumns / ReplaceTable reach their operands (incl. = SOME (SELECT)).

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

* fix(parser,ast): tighten LIMIT BY, ASOF and star-modifier edge cases

- ClickHouse: reject a second LIMIT ... BY, LIMIT n WITH TIES BY, and an
  OFFSET written before LIMIT n BY instead of silently dropping or moving
  a clause.
- Snowflake: ASOF JOIN ... MATCH_CONDITION (...) no longer requires ON.
- Render qualified EXCLUDE / EXCEPT names part by part so t.a does not
  become the single identifier "t.a".
- Release the pooled children of a StarExpression (pass-through case).

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

* fix(parser): reject bare CALL, SET = and savepoint statements without a name

CALL with no procedure fell back to a verbatim UtilityStatement, SET = 1
recorded "=" as the variable name, and SAVEPOINT / RELEASE [SAVEPOINT] /
ROLLBACK TO [SAVEPOINT] / SAVE TRAN without a name parsed as valid. They
now report errors like GRANT and KILL with no arguments.

isKeywordColumnName now matches the token type before peeking at the next
token, so literal operands no longer pay for a token copy.

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

* fix(parser,ast,security): release and scan new primary-expression nodes

Review follow-ups for the primary-expression and special-form work:

- Accept the ClickHouse NANOSECOND unit in INTERVAL <expr> <unit>; the
  stricter unit check had started rejecting it.
- Release pooled children under IntervalExpression.ValueExpr, FunctionCall
  KEEP ORDER BY, ARRAY(<set operation>) and the new unpooled nodes
  (KeywordArgument, MatchAgainstExpression, OuterJoinMarker,
  ConnectByRootExpression, AssignmentExpression).
- Have the AST security scanner descend into the new nodes. MATCH ...
  AGAINST (SLEEP(5)) was reported while it parsed as a BinaryExpression and
  was missed once it became a MatchAgainstExpression.
- Match schema- and package-qualified calls (pg_catalog.pg_sleep,
  DBMS_LOCK.SLEEP, UTL_HTTP.REQUEST, sys.xp_cmdshell) in the scanner, now
  that qualified calls parse in expressions.

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

* test(bench): add real-world corpus parse and parse+format benchmarks

BenchmarkRealWorldParse / BenchmarkRealWorldParseFormat run every
parseable statement of testdata/realworld through ParseWithDialect (and
FormatAST) in its own dialect, giving a production-shaped throughput and
allocation baseline across all eight dialects.

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

* fix(gosqlx): keep procedural bodies and honour GO/DELIMITER in SplitStatements

SplitStatements split on every top-level ';', so trigger and procedure
bodies were cut at the first inner ';' and multi-batch T-SQL scripts using
GO merged into one statement.

The splitter now tracks BEGIN ... END nesting (plus CASE, IF/LOOP/WHILE/
REPEAT inside a block, Oracle DECLARE and IS/AS sections, PostgreSQL BEGIN
ATOMIC) and keeps the whole compound statement together. Transaction
statements (BEGIN; BEGIN TRANSACTION; BEGIN IMMEDIATE; ...) are not blocks,
and a script whose blocks do not balance falls back to plain semicolon
splitting. Client batch separators are honoured and consumed: a GO [n]
line for SQL Server, the DELIMITER directive for MySQL/MariaDB and a lone
"/" line for Oracle. Plain scripts split byte-for-byte as before, and the
splitter still allocates only its result slice.

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

* fix(parser): end T-SQL utility statements at the next statement and reject truncated ones

SQL Server batches without semicolons ("SET NOCOUNT ON SELECT * FROM t")
swallowed every statement after a SET, USE, transaction, PRINT, EXEC,
DECLARE, THROW, RAISERROR, WAITFOR, DBCC or RETURN into its text, so the
linter, scanner and analyzer never saw them. Such a statement now ends at
a top-level word that starts a statement (SELECT, INSERT, UPDATE, DELETE,
MERGE, WITH CTE, CREATE, ALTER, DROP, SET, EXEC, IF, BEGIN, ...), outside
parentheses and not after FOR / AS (cursor declarations) or a "." Options
such as WITH MARK, WITH RECOMPILE and WITH NOWAIT still continue the
statement. Statements that own a body (IF, WHILE, BEGIN blocks, END) and
GRANT / REVOKE keep running to the semicolon. Other dialects are unchanged.

The verbatim fallback no longer accepts truncated input: SET with nothing
after the variable name (SET GLOBAL, SET @x), SHOW alone or with only a
modifier, and statements that end on LIKE, WHERE, FROM, IN, TO, a comma or
an assignment operator are now parse errors, so gosqlx validate reports them.

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

* fix(parser,ast): MERGE OUTPUT INTO, MySQL DELETE rejection, MERGE/REPLACE pool release

- SQL Server MERGE ... OUTPUT ... INTO @t now uses parseDMLOutputClause;
  MergeStatement gains OutputInto, rendered by the AST writer and formatter.
- MySQL and MariaDB reject DELETE t WHERE ... (FROM is required) and
  DELETE FROM t1, t2 WHERE ... (several targets need USING); both used to
  be accepted and rewritten or passed through. Oracle and SQL Server keep
  FROM-less DELETE.
- PutMergeStatement releases a derived-table source or target subquery and
  the OUTPUT INTO columns. Merge and Replace release now follow the #556
  releaseDrawn policy: ReleaseAST puts only the node types the parser draws
  (the parser allocates MergeStatement with a literal), while the exported
  Put functions still return everything.

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

* fix(transform): ReplaceTable and AddTableAlias handle multi-table UPDATE and DELETE

The transforms only touched UpdateStatement/DeleteStatement.TableName and
WHERE, so MySQL multi-table forms, UPDATE ... FROM and DELETE ... FROM /
USING produced SQL that still named the old table, with no error.

ReplaceTable now renames the table in further targets, JOINs, FROM / USING
lists and in column qualifiers of SET, OUTPUT, ORDER BY and RETURNING.
AddTableAlias applies the alias wherever the statement names the table,
points the qualifiers at the alias (a table with an alias cannot be named
any more) and renames DELETE t FROM ... targets to the alias. A table that
appears twice (self join) returns an error instead of taking one alias for
both instances.

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

* fix(tokenizer,ast,parser): never change a string literal's value on format

Follow-ups to the dialect string/escape work:

- Unconfigured tokenizer (no dialect) is strict again: \\ \' \" \` \n \r \t
  decode, any other backslash escape is an error (as before the escape
  rework), so gosqlx.Parse/Format and the CLI 'format' without --dialect
  refuse 'C:\dir' instead of rewriting it to a different PostgreSQL value.
  New zero-value mode dialect.StringEscapeStrict; unknown dialects map to it.
- NUL is never silently dropped: escaping literals write it as \0 (E-strings
  as \000). A raw NUL/Ctrl-Z inside a string, or an escape decoding to one,
  is rejected where the formatter cannot write it back (no-escape dialects,
  unconfigured, Snowflake/Redshift; Ctrl-Z in ClickHouse).
- Snowflake and Redshift get StringEscapeDropUnknown: the backslash before
  an unrecognised character is ignored (Snowflake docs: "the backslash is
  ignored"; Redshift's documented LIKE default escape is written '\\\\'),
  with complete octal/\xhh/\uhhhh escapes; dialect-dependent short forms
  are rejected.
- ClickHouse no longer decodes \Z (kept as written, like other unknown
  escapes) and decodes \a \v \f and ASCII \xHH.
- Triple-quoted strings are BigQuery-only, so '''a' tokenizes (value 'a)
  and '''abc''' keeps its quotes in every other dialect.
- Hex literals (0x1E) are typed int, not float.
- A delimited identifier containing '$' keeps its delimiters (standard SQL
  and BigQuery do not accept '$' in bare names); bare ones stay bare.

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

* fix(transpiler): re-render string literals for the target dialect

Transpile wrote each string literal with the SOURCE dialect's escaping and
introducers. A PostgreSQL 'x\' followed by more text closed early in MySQL
(the rest of the statement ran as code), 'C:\dir' lost its backslash, and
E'..', q'[..]' and _utf8mb4'..' reached targets that reject them.

After the rewrite rules, every string literal (found by a reflective walk,
so a node with an incomplete Children list cannot hide one) gets the
target's backslash mode, and introducers the target lacks are dropped:
E'..' outside PostgreSQL, q'[..]' outside Oracle, a charset introducer
outside the MySQL family. The decoded value is unchanged and re-escaped
for the target. A NUL or Ctrl-Z the target cannot spell (NUL where
backslashes do not escape; \0/\Z outside MySQL where they read
differently) is an error instead of being dropped or changed.

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

* feat(parser): parse ALTER TABLE across dialects and ALTER <object> statements

ALTER TABLE previously accepted one unqualified table name and one of
ADD COLUMN / ADD CONSTRAINT / DROP COLUMN / DROP CONSTRAINT / RENAME / ALTER,
with ALTER COLUMN reading its sub-action as a column definition (so DROP
DEFAULT formatted as a column of type "DROP").

- Qualified names, IF EXISTS, ONLY, ON CLUSTER (ClickHouse) and the MariaDB
  ONLINE / IGNORE modifiers; comma-separated action lists in
  AlterStatement.Operations (Operation stays the first action).
- ADD [COLUMN] [IF NOT EXISTS] with the optional COLUMN keyword, Oracle and
  MySQL parenthesized lists, SQL Server column lists, FIRST / AFTER; ADD
  [CONSTRAINT] PRIMARY KEY / UNIQUE / FOREIGN KEY / CHECK with CLUSTERED /
  NONCLUSTERED, NOT VALID and other trailing attributes, WITH [NO]CHECK;
  ADD / DROP INDEX / KEY (MySQL, MariaDB, ClickHouse).
- DROP COLUMN [IF EXISTS] lists, CASCADE / RESTRICT, DROP PRIMARY KEY /
  FOREIGN KEY / CHECK; MODIFY, CHANGE, RENAME COLUMN / CONSTRAINT / TO / AS.
- ALTER COLUMN SET / DROP DEFAULT with any expression, SET / DROP NOT NULL,
  [SET DATA] TYPE t [USING e] and other sub-actions; full definitions for
  SQL Server / Oracle.
- Dialect-gated long tail (ENABLE / DISABLE / VALIDATE, OWNER TO, SWAP WITH,
  CLUSTER BY, SET props, MySQL table options, partition operations,
  ClickHouse MODIFY TTL / SETTING and partition actions) kept as a keyword
  phrase plus text as written; ClickHouse UPDATE / DELETE mutations parsed.
- ALTER INDEX / SESSION / SYSTEM / USER / DATABASE / SCHEMA / VIEW /
  WAREHOUSE / TASK / ... are kept as written in a UtilityStatement.
- The formatter renders every action; pkg/schema applies multi-action and
  multi-column ALTERs and CHANGE renames.

Removes 130 statements from the real-world known-failures list.

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

* fix(parser,ast,formatter): set-op precedence, CTE and table-factor modifiers

Set operations:
- INTERSECT binds tighter than UNION/EXCEPT/MINUS (precedence climbing);
  SQLite and Oracle keep equal-precedence left-to-right grouping
- record explicit DISTINCT quantifiers and MINUS spelling (rendered as
  EXCEPT only for a target dialect without MINUS)
- VALUES operands; right-nested INTERSECT renders without parentheses so a
  trailing ORDER BY/LIMIT keeps applying to the whole query

CTEs: render MATERIALIZED / NOT MATERIALIZED and ClickHouse scalar CTEs in
the formatter; accept VALUES and parenthesized query bodies.

Table factors:
- render ClickHouse FINAL, Snowflake AT/BEFORE/CHANGES, FOR SYSTEM_TIME and
  MATCH_RECOGNIZE in the formatter (previously dropped)
- store TABLESAMPLE / SAMPLE (method, args, PERCENT/ROWS, REPEATABLE/SEED)
- PostgreSQL ONLY, Oracle @dblink and AS OF / VERSIONS BETWEEN flashback,
  MySQL PARTITION (...) and index hints, SQLite INDEXED BY / NOT INDEXED,
  SQL Server bare (NOLOCK) hints, INDEX(ix) hints and FOR SYSTEM_TIME
  (incl. CONTAINED IN)
- VALUES derived tables, column alias lists on any derived table or table
  (typed lists for PostgreSQL record functions), table functions for every
  named dialect, keyword-named function arguments (FLATTEN outer =>)

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

* feat(parser,ast,formatter): column definitions and table constraints across dialects

Column types: multi-word types (DOUBLE PRECISION, CHARACTER VARYING,
[TIMESTAMP|TIME] WITH[OUT] [LOCAL] TIME ZONE, INTERVAL fields, SQLite
UNSIGNED BIG INT), schema-qualified types, MySQL UNSIGNED/SIGNED/ZEROFILL,
PostgreSQL array suffixes (text[], int[3], int ARRAY), and typeless columns
for SQLite, ClickHouse and SQL Server computed columns.

Column constraints are modelled as typed ColumnConstraint kinds instead of
type-string suffixes: COMMENT, CHARACTER SET/CHARSET, COLLATE, ON UPDATE,
INVISIBLE/VISIBLE, SRID, T-SQL IDENTITY and NOT FOR REPLICATION, Snowflake
AUTOINCREMENT/IDENTITY START..INCREMENT, GENERATED {ALWAYS|BY DEFAULT [ON
NULL]} AS IDENTITY, GENERATED ALWAYS AS (expr) / AS (expr) with
STORED/VIRTUAL/PERSISTENT/PERSISTED, DEFERRABLE/INITIALLY, CLUSTERED, named
CONSTRAINT <name> prefixes, and ClickHouse MATERIALIZED/ALIAS/EPHEMERAL/
CODEC/TTL. Dialect-specific syntax is gated by dialect.

Table elements: UNIQUE [KEY|INDEX] [name], KEY/INDEX/FULLTEXT/SPATIAL with
USING, prefix lengths, ASC/DESC and functional key parts, PRIMARY KEY
CLUSTERED, DEFERRABLE timing, ClickHouse INDEX .. TYPE .. GRANULARITY,
PROJECTION, CHECK without parentheses and ASSUME, and PostgreSQL/MySQL LIKE
source INCLUDING/EXCLUDING.

SQLite AUTOINCREMENT is no longer rewritten to the invalid AUTO_INCREMENT:
the spelling the source used is kept. The formatter now delegates column and
constraint rendering to the AST renderer so the two cannot drift; free-form
fields are validated and dropped (never written) when not plainly shaped.

Corpus ratchet: 41 known failures removed, 6 ClickHouse statements advance
from parse to drop (the remaining loss there is table-level ORDER BY).

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

* fix(formatter): render ALTER ROLE, POLICY and CONNECTOR instead of a bare ALTER

These three statements parsed into an AlterStatement whose rendering fell
back to the token literal, so the formatter wrote "ALTER" and dropped the
rest. They are now rendered in full, ALTER POLICY keeps the table it is on
(AlterPolicyOperation.Table), ALTER CONNECTOR reads SET OWNER USER / ROLE by
word, and ALTER ROLE in dialects whose tokenizer has no ROLE keyword
(PostgreSQL, MySQL, Oracle, SQL Server, Snowflake) is kept as written.

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

* feat(parser,ast,formatter): model CREATE TABLE AS, table options, engine clauses and DDL objects

CREATE TABLE ... AS query no longer drops its query: CreateTableStatement
gains AsQuery/WithData (CTAS in every dialect, including WITH and
ClickHouse ENGINE ... AS SELECT) and the formatter no longer emits an
empty column list.

- CREATE TABLE: OR REPLACE, table kinds (TEMP, GLOBAL TEMPORARY, UNLOGGED,
  TRANSIENT, ...), ON CLUSTER, LIKE, Snowflake CLONE [AT|BEFORE],
  PostgreSQL PARTITION OF ... FOR VALUES / DEFAULT, ClickHouse AS table;
  a generic ordered table-option list (MySQL, PostgreSQL WITH (...),
  ON COMMIT, Oracle storage, SQLite STRICT / WITHOUT ROWID, Snowflake
  CLUSTER BY / COMMENT / retention); ClickHouse PARTITION BY, PRIMARY KEY,
  ORDER BY, SAMPLE BY, TTL, SETTINGS and COMMENT captured instead of
  skipped; WITH SYSTEM VERSIONING, PERIOD FOR and partition definitions
  rendered; partitioning by expressions, LIST values, HASH PARTITIONS n,
  LINEAR, Oracle INTERVAL and MariaDB SYSTEM_TIME.
- CREATE INDEX: expression elements, opclasses, NULLS FIRST, prefix
  lengths, CONCURRENTLY, ON ONLY, unnamed indexes, USING hash, INCLUDE,
  WITH (...) and Oracle/MySQL trailing options, and FULLTEXT / SPATIAL /
  BITMAP / [NON]CLUSTERED kinds.
- Views: OR ALTER, SECURE / FORCE / TEMP modifiers, WITH READ ONLY,
  WITH ... SELECT queries, MV WITH [NO] DATA after a FROM-less query,
  ClickHouse MV TO (no longer stored as TABLESPACE), typed columns,
  ENGINE clauses, POPULATE and ON CLUSTER, Oracle MV BUILD / REFRESH.
- DROP INDEX ... ON t, CONCURRENTLY, DROP TEMPORARY TABLE, CASCADE
  CONSTRAINTS, PURGE, ClickHouse ON CLUSTER / SYNC; TRUNCATE ... DROP |
  REUSE STORAGE.
- CREATE / DROP of other objects (SCHEMA, DATABASE, EXTENSION, TYPE,
  FUNCTION, PROCEDURE, TRIGGER, USER, ROLE, SEQUENCE, SYNONYM, STAGE,
  ...) are kept verbatim as UtilityStatement instead of failing or
  rendering as an UNSUPPORTED comment.

The clause writers are shared by SQL() and the formatter through
ast.DDLStyle. New pooled fields are reset in the Put functions.
Ratchet: 222 corpus statements fixed, none added.

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

* fix(gosqlx,linter): report syntax errors instead of "no violations"

gosqlx.Lint and LintWithDialect ignored tokenizer and parser failures, so
invalid SQL came back as a clean result with a nil error.

linter.FileResult now records TokenizeError and ParseError (SyntaxError()
returns whichever is set) and FormatResult prints them. Lint and
LintWithDialect return the violations of the text and token rules, which
still run on invalid input, together with an error wrapping ErrTokenize or
ErrSyntax. Empty, whitespace-only and comment-only content has no
statements and is not a syntax error. The playground bridge keeps reporting
invalid SQL through ParseError and no longer drops the violations.

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

* fix(formatter,gosqlx): keep comments and optimizer hints when formatting

gosqlx.Format parsed without the tokenizer's comments, so "format" deleted
every comment and optimizer hint (changing query plans for /*+ FULL(t) */).

formatter.JoinWithComments re-attaches comments around already-rendered
statements using the token spans: leading and trailing comments stay with
their statement, comments inside a statement move to the line above it, and
a hint directly after the statement's first keyword stays there. A hint or
MySQL executable comment that cannot be put back faithfully fails with
ErrUnplaceableComment instead of being dropped or moved. gosqlx.Format uses
it whenever the SQL has comments, and the new WithComments parse option
attaches comments to ast.AST.Comments for FormatTree and FormatAST.

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

* fix(gosqlx): ParseReaderMultiple skips comment-only segments

A script ending in "SELECT 1; -- done" produced a final comment-only
segment that failed to parse. Segments holding only whitespace and comments
are skipped like empty ones.

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

* fix(cli): exit non-zero on syntax errors and fix flag, dialect and I/O handling

- lint reports tokenizer and parser errors and exits 1 for them; --auto-fix
  judges the fixed text instead of the pre-fix report.
- format --no-uppercase shared the --uppercase variable, so it set
  uppercase to true; it now lower-cases keywords, and giving both flags is
  an error.
- format keeps comments and optimizer hints (JoinWithComments), refuses
  input whose hints cannot be placed, verifies that the output holds exactly
  the input's comments, and preserves a leading UTF-8 byte order mark.
- One alias table (postgres, pg, mssql, tsql, sqlite3, sql-server) is applied
  by validate, format, lint, parse, analyze, highlight and transpile.
- Errors are printed once, by main; usage is printed for flag and argument
  errors but not for failures of the work. validate --quiet exits 1 silently.
- validate reads stdin in memory and names it <stdin> instead of a temp
  path. A file that fails as a whole is re-checked statement by statement, so
  GO and DELIMITER scripts validate and files past the token limit work.
- Empty, whitespace-only and comment-only input is valid for validate, lint
  and format; parse and analyze say there is no statement.
- The global -f/-o flags are honoured (parse and transpile -o, validate -f
  json / -o) or rejected where unsupported; format -o with several inputs
  is rejected.

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

* docs: remove APIs and settings that do not exist; document CLI changes

USAGE_GUIDE referenced parser.NewTokenConverter (never existed; the
tokenizer's output goes straight to ParseFromModelTokens) and one tokenizer
example lacked the pkg/models import. TROUBLESHOOTING referenced
Parser.SetMaxTokens, SetMaxRecursionDepth and parser.max_tokens /
max_recursion_depth config keys; the limits are compile-time constants
(MaxRecursionDepth is 100, not 200). The token-limit error hint no longer
tells users to adjust MaxTokens. CLI_GUIDE, LINTING_RULES and the CHANGELOG
describe the new lint/format/validate behaviour.

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

* fix(parser,ast,formatter): CTE keyword names, SEARCH/CYCLE, PIVOT forms

- accept non-reserved keywords as CTE names (dbt's source, target, final);
  FINAL is a plain name outside ClickHouse; CTE names quote like table
  aliases
- SQL:1999 SEARCH {DEPTH|BREADTH} FIRST BY ... SET and CYCLE ... SET
  [TO ... DEFAULT ...] [USING ...] on recursive CTEs (PostgreSQL, Oracle)
- PIVOT: several aliased aggregates, PIVOT XML, IN (ANY [ORDER BY]),
  IN (subquery), alias column list, and the source alias of the pivoted
  table (SQL Server (SELECT ...) s PIVOT (...) p no longer drops s)
- UNPIVOT INCLUDE / EXCLUDE NULLS and Oracle multi-column UNPIVOT
- Snowflake FROM VALUES without parentheses and stage paths with file
  names (@stage/data.csv (FILE_FORMAT => ...))

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

* fix(ast,parser,formatter,transpiler): keep needed identifier quotes and use target delimiters

The AST now records how names stored as plain strings were delimited
(ast.NameQuote: which dotted parts must stay delimited, the source
delimiter, and an Oracle bare-'#' mark). The parser sets it for
identifiers and their qualifiers, select-item and table aliases, table,
function, CTE and view names and column lists, INSERT/UPDATE/DELETE and
MERGE targets and aliases, SET and MERGE columns, JOIN USING and ON
CONFLICT columns, and CREATE TABLE/VIEW names and column definitions.
A delimited name is recorded when bare it would mean something else:
letter case in a folding dialect (upper case in PostgreSQL/generic,
lower case in Oracle/Snowflake), a keyword ("table", "source"), or a
niladic/contextual word ("user", "PRIOR").

SQL() and the formatter keep those quotes in the source's delimiters, so
"CreatedAt", "Company" aliases, "table", s."select"(1) and a MySQL
`exists` alias round-trip; a formatter with a MySQL/MariaDB dialect
writes content-required delimiters as backticks. Oracle bare names such
as s.serial# and emp# stay bare instead of becoming case-sensitive
"serial#". Transpile rewrites every recorded delimiter to the target's
(backticks for MySQL/MariaDB, brackets for SQL Server, double quotes
otherwise), so MySQL backticks and T-SQL brackets no longer leak into
PostgreSQL output.

Removes the fixed corpusFormatKnownGaps entries (gorm_joins, mysql and
oracle 01_app_crud, postgresql 08_supabase_rls_misc) and updates tests
that pinned the old "case-only quotes render bare" behaviour.

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

* fix(gosqlx,lsp,cbinding): keep comments in dialect formatting; splitter BOM

The LSP and C-binding formatted non-default dialects through FormatAST on a
tree parsed without comments, so format-on-save deleted every comment and
hint. gosqlx.FormatWithDialect parses with the dialect, renders
dialect-specific constructs for it and preserves comments exactly like
Format; both callers use it.

SplitStatements ignores a leading UTF-8 byte order mark when looking for GO
and other directive lines, and the splitter tests and fuzz target accept
that directive lines are consumed (segments are then in-order substrings of
the source instead of an exact tiling).

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

* fix(parser,ast): qualified positional columns, db..t, OPENJSON schema

- Snowflake t.$1 positional column references after a qualifier
- SQL Server db..t (empty default-schema part), kept empty on output
- SQL Server OPENJSON(...) WITH (col type ['path'] [AS JSON], ...) column
  schema instead of a table-hint parse error
- Oracle PARTITION (p) partition-extended table names

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

* perf(ast): keep TableReference compact by grouping rare modifiers

Move TableSample and Flashback into the TableModifiers pointer so the
table-factor work adds one pointer (plus the VALUES body) to the
TableReference copied by value on the parser hot path, and add a
field-coverage test for every TableModifiers field.

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

* fix(ast,parser,formatter): keep needed quotes on DDL object names

Extends the NameQuote recording to the DDL positions that still wrote
quoted names bare: CREATE INDEX name, table and columns, table and
column constraint names, column lists and REFERENCES targets, DROP and
TRUNCATE names, CREATE MATERIALIZED VIEW names and columns, and the
ALTER TABLE target, column, constraint and RENAME TO names (Ident and
ObjectName gain a Quote field). Prisma/EF-style PostgreSQL migrations
such as CREATE UNIQUE INDEX "User_email_key" ON "User"("email") and
ALTER TABLE "Post" ADD CONSTRAINT "Post_fk" FOREIGN KEY ("authorId")
REFERENCES "User"("id") now format instead of being refused.

The ALTER TABLE renderer wrote the table and column names verbatim, so
`ALTER TABLE "my table" DROP COLUMN "a b"` came out unquoted; those
names now go through the same quoting as other names. Transpile
restyles the delimiters of all these names for the target dialect.

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

* fix(ast,parser,formatter): keep needed quotes on window names

Records NameQuote for named windows: the WINDOW clause definition name,
the base window a spec refines (OVER ("W" ORDER BY y), "W2" AS ("W")),
and a bare OVER "W" reference, so a window quoted for its case keeps its
quotes on both the definition and the references. Transpile restyles
them for the target dialect.

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

* perf(parser,ast): keep name-quote recording off the hot path

Identifier and FunctionCall keep their sizes (56 and 192 bytes) by
placing the new NameQuote fields in existing padding, and the parser
checks an inlinable nameQuoteRelevant guard before recording a name
part, so bare names outside Oracle skip the call entirely. Parser
benchmarks are back within noise of the base with unchanged
allocations.

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

* fix(parser): keep ClickHouse projection/index columns and quoted literals intact

- A column named "projection" with a parameterised type
  (projection FixedString(16)) was parsed as a PROJECTION element and moved
  after the columns. PROJECTION now also requires "(SELECT".
- The ClickHouse INDEX lookahead matched a 'type' string literal
  (index String DEFAULT 'type') and then failed to parse. Only an unquoted
  TYPE word counts now.
- parseTypeArgsString wrote string literals back without doubling embedded
  quotes, so a projection body with 'it''s' became unbalanced and the
  renderer dropped it, leaving "PROJECTION p", which reparses as a column.

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

* fix(gosqlx,formatter): do not merge statements after a begin column; refuse moving --+ hints

- SplitStatements treated a column named begin (CREATE TABLE t (begin INT),
  ALTER TABLE t ADD COLUMN begin int, MODIFY begin TIME) as a block opener.
  A later unrelated END word (SET end = 1, ORDER BY end) then closed it, so
  plain scripts in every dialect were silently merged into one segment. A
  BEGIN after "(", "," or a column-clause word is no longer a block.
- JoinWithComments moved an Oracle --+ line hint inside a statement to the
  line above it, dropping the hint. --+ comments are now significant: kept
  when leading or trailing, refused (ErrUnplaceableComment) when inside a
  statement, since they cannot be written inline after a keyword.

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

* fix(tokenizer): decode ClickHouse \/ and \= escapes; refuse \e and \N

ClickHouse drops the backslash before / and = (as it does before \\ \'
\"), so 'http:\/\/x' is http://x. The Lenient mode kept the backslash,
and formatting wrote 'http:\\/\\/x', a different value. Decode both, and
refuse \e and \N, whose ClickHouse meanings (ESC, nothing) are not
guessed. BigQuery shares the mode but rejects all four escapes, so no
valid BigQuery literal changes.

Also rewrite a test doc comment that gofmt had turned into curly quotes.

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

* fix(parser,formatter): end ALTER TABLE at the next statement; allow-list ALTER keyword fields

ALTER TABLE now ends at a statement keyword as well as at ';' or EOF, so
semicolon-less scripts (common in T-SQL) such as
"ALTER TABLE t DROP COLUMN c SELECT 1" parse as two statements again
instead of failing with "expected end of ALTER TABLE action".

The formatter no longer writes the keyword fields of a hand-built ALTER
TABLE verbatim (Modifiers, Modifier, Keyword, IndexKind, IndexKeyword,
ConstraintKind): a value that is not a bare keyword phrase is dropped, and
an ON CLUSTER target that is not a well-formed name or string is quoted,
following the #632 allow-list policy.

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

* fix(formatter,parser): keep ';' after verbatim statements; reject bare CREATE/DROP modifiers

With AddSemicolon (ReadableStyle, gosqlx.Format AddSemicolon, the MCP
format tool) FormatAST joined statements with a newline on the assumption
that each renderer writes its own ';'. Statements kept as written
(UtilityStatement and the stmtSQL fallback) do not, so the next statement
was read back as part of their text: "CREATE SCHEMA s; CREATE TABLE s.t
(a INT)" re-parsed as one CREATE SCHEMA. FormatAST now terminates any
statement that lacks the ';'.

createVerbatimKind and dropVerbatimKind accepted modifier words on their
own, so "CREATE OR FUNCTION f", "CREATE REPLACE FUNCTION f", "DROP
REPLACE x" and "DROP TEMP x" validated as statements. OR must now be
followed by REPLACE or ALTER, and a DROP may only start with PUBLIC /
GLOBAL / UNIQUE before an object kind.

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

* fix(parser,ast): query tail after nested paren operand, looser left operand parens

With INTERSECT binding tighter, the last operand of A UNION B INTERSECT (C)
sits in a right-nested set operation, so a following ORDER BY / LIMIT was
rejected with E4001 (accepted on the base commit). attachParenthesizedQueryTail
now descends the unparenthesized right spine to find it.

SetOperandNeedsParens now parenthesizes a left set-operation operand that
binds looser than its parent under standard precedence, so a tree parsed
with Oracle / SQLite equal precedence ((A UNION B) INTERSECT C) keeps its
meaning when written for another dialect or by SQL(). A leading WITH clause
still keeps the operand unparenthesized.

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

* fix(parser,transform): keep cursor set operations whole, scope-aware DML alias

DECLARE c CURSOR FOR SELECT ... UNION SELECT ... was split at the second
SELECT into "... UNION;" and a separate SELECT, which the formatter wrote
with a ';' after UNION. A statement-starting word right after UNION, EXCEPT,
INTERSECT, ALL or DISTINCT now continues the utility statement.

AddTableAlias on UPDATE / DELETE renamed column qualifiers inside subqueries
that name the table in their own FROM (UPDATE t SET a = (SELECT max(t.b)
FROM t)), turning them into correlated references to the outer alias.
Qualifiers inside such a subquery are now left alone; correlated references
are still renamed.

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

* fix(transpiler): render output for the target dialect

SetOperation now keeps the MINUS spelling and renderers write it unless the
target dialect lacks MINUS, but Transpile formatted with no dialect, so
Snowflake / Oracle MINUS came out as MINUS for PostgreSQL and MySQL targets
(the base commit wrote EXCEPT). Transpile now sets the target dialect on the
format style, which also gives Oracle set-operation chains the grouping the
target's precedence needs.

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

* fix(transform): drop recorded quotes of names a table transform replaces

ReplaceTable and AddTableAlias replace names and aliases in place, but the
quote-preservation work records each name's delimiters next to it. The old
name's marks then carried over: a renamed "Users" became the case-sensitive
"Accounts", and an UPDATE alias U was written next to a still-quoted "U"
qualifier, which names a different relation in PostgreSQL. Replaced names
and qualifiers now drop the delimited-part marks (keeping the source's
delimiter style), so the new name is written as the caller gave it.

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

* chore(lint): remove dead helpers left by the dialect merges; scope errcheck

Remove functions the parallel fix packages left unused (rejectGlobalOutput,
writeNameList, TableModifiers.isEmpty, Parser.parseObjectName), simplify a
single-case switch, and exclude ast.textWriter's WriteString/WriteByte from
errcheck: the interface is implemented only by *bytes.Buffer and
*strings.Builder, whose writes never fail (errcheck already exempts those
concrete methods). golangci-lint: 229 issues -> 0.

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

* fix(ast,parser,formatter): AlterStatement.SQL, keep quote kinds in type args, record more name quotes

- ast.AlterStatement now has SQL(); the ALTER writers move from the
  formatter into ast (AlterSQL with DDLStyle) so SQL() and the formatter share
  one implementation. tree.SQL() no longer drops ALTER statements.
- parseTypeArgsString keeps the delimiter kind of double-quoted and backticked
  words (ClickHouse PROJECTION bodies, engine and type arguments), no longer
  turning "x" into the string 'x'; the verbatim-text checks accept delimited
  identifiers.
- Record the delimiters of ALTER ROLE/POLICY/CONNECTOR names, ALTER TABLE index
  names, index names inside table constraints, partition definition names and
  INHERITS parents.

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

* fix(formatter,parser,cli): dialect-aware Format, hoist WITH over set operations, place nested hints

- formatter.Format tokenizes and parses with Options.Dialect, so dialect-only
  literals and syntax (PostgreSQL 'C:\dir', MySQL 'it\'s', ClickHouse ENGINE)
  no longer fail in the no-dialect front end. An unknown dialect is ignored.
- A tree parsed with equal set-operator precedence (Oracle, SQLite) whose first
  SELECT has a WITH clause changed meaning when written for standard precedence
  (A UNION B INTERSECT C regrouped, or the CTEs went out of scope). Renderers
  now write the WITH clause before the whole chain and parenthesize the left
  operand (ast.HoistLeadingWith). The parser accepts WITH ... (query) ...
  and records it in SetOperation.LeftWithHoisted so it round-trips.
- Optimizer hints after a nested SELECT/INSERT/UPDATE/DELETE/MERGE keyword (CTE
  bodies, subqueries, union branches) are put back after the same keyword in
  the rendered statement when its occurrence count matches; otherwise the CLI
  still refuses. Fixes realworld/oracle/08_complex_nested.sql.
- CLI string-literal guard understands that MySQL, MariaDB and PostgreSQL read
  adjacent literals ('a' 'b') as one. Fixes realworld/mysql/10_misc_functions.sql.

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

* perf(tokenizer,parser,gosqlx): tokenize internal parse paths into a reusable scratch buffer

The package-level parse/validate/format entry points tokenized into a fresh
[]TokenWithSpan (plus fresh Word chunks) on every call, only for the parser
to copy the tokens into its pooled tokenBuf during preprocessing. That slice
was ~43% of alloc_space in the real-world parse+format profile.

Add internal/tokscratch: GoSQLX's own tokenize-then-parse paths (gosqlx
Parse/ParseBytes/ParseWithContext/ParseMultiple/ValidateMultiple/Format/
ParseWithRecovery/ParseTree, parser Validate*/ParseBytes/ParseWithDialect*)
now tokenize into a tokenizer-owned slice and Word slab that live only until
the next tokenize call, Reset or PutTokenizer. Reset zeroes the used parts
and drops a token buffer above 4096 tokens (as Parser.tokenBuf does). The
public Tokenize* methods still return caller-owned slices and Words.

The string entry points (Validate, ValidateWithDialect, ParseWithDialect)
also tokenize the caller's string in place instead of round-tripping
through []byte.

RealWorldParse: 12.4 MB -> 4.95 MB/op, 58.6k -> 47.7k allocs/op.
ParseBytesWithDialect: 7682 -> 3192 B/op, 26 -> 24 allocs/op.

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

* fix(parser): statement boundaries in scripts without semicolons; whole routine bodies

T-SQL scripts and migrations often omit ';'. A statement now ends where
the next one starts:

- The statement loops (Parse, ParseContext, ParseWithRecovery) mark a
  following statement start as a boundary, so COMMIT, SET, PRINT, EXEC
  ... after a DML statement parse ("BEGIN TRANSACTION / UPDATE ... /
  COMMIT", "SET NOCOUNT ON / SELECT 1 / SET NOCOUNT OFF"). GO lines
  separate batches. Other dialects accept only a transaction statement
  on a line of its own; "SELECT 1 FROM t garbage garbage2" stays an error.
- T-SQL statement words are never implicit aliases.
- T-SQL table variables (@t) are INSERT / UPDATE / DELETE / MERGE
  targets and FROM / JOIN items.
- Verbatim CREATE / ALTER / DROP and ALTER TABLE text actions end at the
  next statement start in T-SQL (CREATE SCHEMA keeps its elements,
  routine bodies run to the end of the batch).
- IF / WHILE bodies are parsed to find their end, including
  "IF ... stmt; ELSE stmt"; GRANT / REVOKE / DENY end after their
  principal list; T-SQL DENY is a utility statement.
- Transaction statements in other dialects end at a statement keyword
  starting the next line (START TRANSACTION / UPDATE / COMMIT).

Routine, trigger and PL/SQL bodies are one statement: verbatim
statements track BEGIN / CASE / IF / LOOP ... END nesting with the same
rules as gosqlx.SplitStatements, so "CREATE TRIGGER ... BEGIN UPDATE
...; END" and Oracle "BEGIN NULL; END;" no longer split at the inner ';'.
An unclosed block falls back to ';' boundaries, as the splitter does.

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

* fix(gosqlx): keep T-SQL "stmt; ELSE" in one SplitStatements piece; check agreement with the parser

In T-SQL a ';' may end the first branch of an IF before its ELSE
("IF @a = 1 PRINT 'a'; ELSE PRINT 'b'"); the splitter no longer cuts the
IF there. BEGIN followed by :NEW, "x" or <<label>> opens a PL/SQL block,
as in the parser.

TestSplitStatements_AgreesWithParser parses every piece of the
procedural split cases and of the real-world corpus and requires each
piece that parses to hold exactly one statement, so the splitter and
the parser keep agreeing on statement ends.

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

* test(realworld): T-SQL batches without semicolons and PL/SQL blocks

sqlserver/11_batches_no_semicolons.sql holds GO-separated batches whose
statements follow each other without ';' (transactions around DML, SET
around queries, table variables, IF / ELSE and WHILE bodies, ALTER
TABLE text actions, GRANT / DENY / REVOKE, a procedure, TRY / CATCH).
oracle/09_plsql_blocks.sql holds anonymous blocks, routines, triggers
and a package separated by ';' and SQL*Plus "/" lines. Both pass every
stage; known_failures.txt is unchanged.

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

* perf(ast)!: move the rare dialect SELECT clauses behind SelectStatement.Extras

The dialect work grew SelectStatement from 384 to 528 bytes; above 512
bytes a pointerful object also carries an 8-byte malloc header, so every
parsed SELECT cost a 576-byte allocation. Move the seven rarely set clause
fields added since v1.16.0 (SelectModifiers, Into, Interpolate, LimitBy,
ForOutput, QueryHints, OutputFormat) into a new SelectExtras struct held by
pointer, like TableReference.Modifiers. SelectStatement is now 440 bytes
(448 class): 128 bytes less per SELECT. EnsureExtras allocates it for
writers; renderers read through a nil-safe helper.

BREAKING CHANGE (unreleased API only): these fields were added after
v1.16.0 and now live on SelectStatement.Extras.

ParserSimpleSelect: 1072 -> 944 B/op; FullPipeline/simple_select:
4292 -> 4163 B/op.

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

* fix(parser,ast): parse VALUES, ^, BINARY, PG prefix operators, string aliases and more

Clears 27 of the 43 known real-world corpus failures.

Parser:
- Stand-alone VALUES (...), (...) statement, also with set operations.
- ^ operator: power in PostgreSQL, XOR in MySQL/MariaDB (tighter than *),
  bitwise XOR at the additive level in SQL Server.
- PostgreSQL prefix operators |/ ||/ !! @ (operand is an additive
  expression, like any other operator); the renderer keeps them apart from
  an operand that starts with an operator character.
- MySQL/MariaDB BINARY expr prefix operator (ast.MySQLBinary) and DISTINCTROW.
- REFERENCES takes schema-qualified, quoted table names in table and column
  constraints (was a syntax error in every dialect).
- ClickHouse ORDER BY ALL.
- MySQL/MariaDB/SQLite AS 'alias'; the CLI quote guard treats it as a name.
- Oracle VERSIONS BETWEEN bounds accept + and - (SYSTIMESTAMP - INTERVAL ...).
- SQL Server UPDATE STATISTICS and $5.00 money literals.

Corpus detector: the "drop" stage no longer reports exact synonyms the
formatter writes canonically (SELECT ALL, DISTINCTROW, postfix ISNULL and
NOTNULL) or dollar-quoted strings, which it cannot lex. These were not
semantic losses.

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

* perf(tokenizer): build a compound keyword's joined Value only when needed

readIdentifier always concatenated ident + " " + next for a matched
compound keyword (GROUP BY, ORDER BY, LEFT JOIN, ...) and then replaced it
with the zero-copy input substring in the common single-space case, so
every such keyword cost a string allocation. Concatenate only when the
separator must be normalized.

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

* fix(parser): bound wasted block scans; END/ABORT/RELEASE end a txn script line

A script of unclosed blocks (T-SQL "BEGIN SELECT 1; BEGIN SELECT 1; ...",
Oracle "BEGIN NULL; BEGIN NULL; ...") made every statement's block-tracking
scan run to the end of the input before falling back to ';', which is
quadratic (1600 blocks: 100 ms vs 0.7 ms on the base). Once such scans have
cost four passes over the input, the remaining statements use plain ';'
boundaries directly. Corpus results are unchanged.

Outside SQL Server, END [TRANSACTION] (PostgreSQL, SQLite), ABORT and
RELEASE SAVEPOINT on a line of their own now also start a statement after
one written without ';' ("BEGIN TRANSACTION\nUPDATE ...\nEND" was an error).

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

* perf(formatter,gosqlx): presize the SELECT render buffer; skip the option bag without options

- renderSelect grows its builder to 128 bytes up front (75% of rendered
  real-world SELECTs are <= 111 bytes), replacing the 8-16-32-64-128
  growth steps with one allocation.
- FormatAST moved its option bag to the heap on every call because &cfg
  was passed to option closures; options are now applied in a separate
  function only when some are given.

RealWorldParseFormat: 76.7k -> 72.9k allocs/op, 6.02 -> 5.98 MB/op.

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

* fix(parser,ast,formatter): close the real-world corpus failures (join groups, JSON_TABLE, query tail, ...)

The known-failure list is now empty (16 entries closed, plus 6 that the Oracle
splitter fix below uncovered).

Parser and AST
- A parenthesized joined table as a JOIN operand is kept whole as
  TableModifiers.Group (a JOIN (b JOIN c ON ...) ON ..., t1 LEFT JOIN
  (t2, t3) ON ...): flattening it would change which tables an ON condition
  sees. Rendered by SQL() and the formatter, walked by Walk, renamed by
  transform.ReplaceTable and listed by ExtractTables. An alias after a group is
  refused.
- SetOperation.Tail: an ORDER BY / LIMIT / OFFSET / FETCH after a set operation
  whose last operand is parenthesized and has clauses of its own (and after a
  parenthesized set operation). A clause that already applies to the whole
  operation still returns E4001.
- JSON_TABLE (MySQL, MariaDB, Oracle) and XMLTABLE (Oracle, PostgreSQL) keep
  their COLUMNS / PASSING clause as text (ast.ClauseArgument); JSON_OBJECT
  KEY / VALUE / name: value entries; XMLATTRIBUTES / XMLFOREST AS names;
  CURSOR(SELECT ...) and MULTISET(SELECT ...); SELECT UNIQUE; PARTITION FOR;
  INSERT ... LOG ERRORS.
- MariaDB: WITH / WITHOUT SYSTEM VERSIONING columns, DELETE HISTORY ... BEFORE
  SYSTEM_TIME, LIMIT ... ROWS EXAMINED.
- ClickHouse COLUMNS('re') / * APPLY (fn); Snowflake {'k': v} object constants;
  SQL Server OPENROWSET(BULK 'file', ...) and (expr).method(args) calls.
- The Oracle statement splitter understands q'[...]' / nq'[...]' literals, so a
  quote or ';' inside one no longer swallows the following statements.

Tests that pinned the old refusals were updated; every new form has a
behaviour test, and the CLI corpus gains one known gap (mysql/05 contains
DESCRIBE, which has no renderer).

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

* perf(parser): keep the new syntax checks off the common parse path

Guard the BINARY and DISTINCTROW lookups by length, test the token type
before the dialect for the PostgreSQL prefix operators, check the
special-argument functions (CURSOR, JSON_OBJECT, XMLTABLE, ...) once per call,
pass the first table reference to parseTableListAndJoins by pointer and keep
the ^ operator fast path inline. Simple SELECT parses within about 2% of the
base commit (min of 16 alternating runs); allocations are unchanged apart from
InsertStatement growing by one pointer for LOG ERRORS.

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

* fix(dialect): preserve GO batches and finish audit validation

Retain SQL Server GO boundaries and repeat counts through parsing,
recovery, library rendering, transpilation and CLI formatting. Reject
unsupported target batch scopes and invalid GO repeat counts.
Preserve raw-clause
l…
ajitpratap0 added a commit that referenced this pull request Oct 26, 2026
…or (#868)

* fix(parser): model EXPLAIN <stmt> as ExplainStatement

EXPLAIN SELECT ... discarded the query and formatted as DESCRIBE "SELECT";
EXPLAIN ANALYZE|VERBOSE <stmt> split into DESCRIBE ANALYZE; <stmt>, so
formatting turned a non-executing EXPLAIN into the executable statement.

Add ast.ExplainStatement {Keyword, Options, Statement} covering EXPLAIN,
DESCRIBE and DESC with options (ANALYZE, (FORMAT JSON), QUERY PLAN,
FORMAT=JSON, PIPELINE, PLAN FOR), render it in the formatter, and let the
CLI format it instead of refusing it.

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

* test(realworld): add 2.7k-statement multi-dialect real-world corpus with ratchet

Adds testdata/realworld/<dialect>/ corpora (PostgreSQL, MySQL, MariaDB,
SQL Server, Oracle, SQLite, Snowflake, ClickHouse) of production-style app,
reporting, ETL, DDL and dialect-specific SQL, plus:

- TestRealWorldCorpus: split -> parse -> format -> reparse -> idempotency
  -> dropped-word check for every statement, ratcheted against
  testdata/realworld/known_failures.txt (-update-known-failures regenerates)
- scripts/dialect-audit: CLI harness for the same pipeline

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

* fix(parser): operators and predicates (IS forms, ANY/ALL arrays, AT TIME ZONE, COLLATE, bitwise, ESCAPE)

Close the operator and predicate gaps found by the real-world corpus.

Parser
- IS [NOT] TRUE/FALSE/UNKNOWN, IS [NOT] DISTINCT FROM, SQLite "a IS [NOT] expr",
  PostgreSQL/SQLite ISNULL/NOTNULL (normalised to IS [NOT] NULL).
- = ANY/ALL/SOME over arrays and placeholders (new AnyAllExpression); the
  subquery forms keep AnyExpression/AllExpression.
- Postfix AT TIME ZONE and COLLATE (new AtTimeZoneExpression and
  CollateExpression), binding tighter than arithmetic; COLLATE on CREATE
  INDEX columns fills IndexColumn.Collate.
- Binary operators @@, &&, &, |, #, <<, >>, <->, <=>, == via a precedence
  climbing level between comparison and ||, unary ~ and MySQL !, MySQL
  DIV/MOD/XOR/SOUNDS LIKE/&&/:=, SQLite GLOB/MATCH, SIMILAR TO, NOT REGEXP/
  RLIKE. Word operators are gated to the dialect that defines them. <=>, <<,
  >>, <->, == are read from adjacent tokens until the tokenizer emits them.
- LIKE/ILIKE/SIMILAR TO ... ESCAPE (BinaryExpression.Escape).
- Predicates and comparisons chain left to right (a IN (1, 2) = TRUE); SQLite
  IN () and IN tbl; ClickHouse GLOBAL IN keeps the GLOBAL flag; ClickHouse
  cond ? a : b (TernaryExpression).
- Data types: array dimensions (int[3][4]), schema-qualified, multi-word
  (DOUBLE PRECISION, CHARACTER VARYING, TIMESTAMP WITH TIME ZONE, SIGNED
  INTEGER) and CHAR(n) CHARACTER SET x, shared by CAST and ::.
- AND and OR are stored in canonical upper case.

Formatter and AST
- Render the new nodes and fields; extend the operator and data-type
  allow-lists so they are written verbatim.
- format_sql (MCP) now upper-cases AND/OR by default; tests and docs updated.

Real-world corpus: 59 statements now pass, 6 move to a later failure stage.

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

* fix(parser): complete DML grammar for UPDATE, DELETE, INSERT, REPLACE and MERGE

UPDATE and DELETE read a single table name and then expected SET/WHERE, and
the MySQL row limit was parsed but had no AST field, so the formatter
silently dropped LIMIT and the reformatted statement touched every row.

UPDATE / DELETE:
- targets with AS / bare aliases, PostgreSQL ONLY, Oracle derived-table
  targets and FROM-less DELETE, SQL Server TOP (n) [PERCENT]
- MySQL/MariaDB multi-table forms (UPDATE t JOIN u / UPDATE t1, t2,
  DELETE t1[, t2] FROM ..., DELETE FROM t1, t2 USING ...), SQL Server
  DELETE t FROM ... and UPDATE ... FROM t JOIN u
- DELETE ... USING routed into DeleteStatement.Using
- statement modifiers (LOW_PRIORITY, QUICK, IGNORE; SQLite OR ...)
- ORDER BY ... LIMIT n stored on the AST and rendered
- SET targets: qualified columns, (a, b) tuples, @variables, DEFAULT values
- SQL Server OUTPUT [INTO], RETURNING aliases and Oracle RETURNING ... INTO

INSERT: MySQL/SQLite/Snowflake modifiers, INTO-less SQL Server/MySQL form,
VALUES () and DEFAULT items, MySQL row alias, OVERRIDING SYSTEM|USER VALUE,
ON CONFLICT expression targets and partial-index WHERE, OUTPUT ... INTO,
ClickHouse SETTINGS / FUNCTION / FORMAT (FORMAT was dropped before), Oracle
INSERT ALL / INSERT FIRST. REPLACE gains SELECT, SET and RETURNING forms.

MERGE: derived-table sources, DO NOTHING, NOT MATCHED BY TARGET, RETURNING,
Oracle UPDATE ... WHERE / DELETE WHERE and INSERT ... WHERE; the formatter
now renders the OUTPUT clause it used to drop.

New dialect fields live in embedded structs (ast.DMLTargetFields,
ast.InsertDialectFields) so single-table code that reads TableName/Alias is
unchanged; keyword fields are allow-listed on render and reset by the pool
release functions. 91 real-world corpus statements now pass (one moves from
parse to the pre-existing INTERVAL drop).

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

* fix(parser): parse utility statements and keep identifier quoting

parseStatement was a closed switch, so BEGIN, COMMIT, SET, USE, CALL,
GRANT, SHOW ... LIKE, T-SQL DECLARE/EXEC/IF and the rest of the utility
family failed with "expected statement". Backtick- and bracket-quoted
keywords lost their quoting (`key` became the KEY token, [Group] formatted
as a bare GROUP), and keywords such as share, filter and owner were
rejected as aliases or columns.

- Add ast.TransactionStatement, SetStatement, UseStatement, CallStatement
  and a verbatim UtilityStatement whose SQL() reproduces the statement,
  rebuilt from its tokens with delimiters and escapes restored. Dispatch
  them by value after the typed statements, only at a statement boundary
  so the tail of a half-parsed statement is never split off. BEGIN/END
  are gated per dialect (T-SQL and PL/SQL blocks, END only in PostgreSQL
  and SQLite).
- SHOW forms ShowStatement cannot model, Snowflake USE and stage
  operations are now verbatim statements that re-parse (they formatted
  as an "-- UNSUPPORTED" comment); MariaDB ANALYZE <stmt> is an
  ExplainStatement.
- DescribeStatement keeps DESC vs DESCRIBE and the object kind; PRAGMA
  accepts a schema-qualified name.
- Delimited identifiers are never remapped to keywords; Identifier gains
  QuoteStyle so SQL() re-quotes with the source delimiters (`from`,
  [Group]); @/@@ variables are never quoted.
- Accept keyword words as aliases after AS and the role-option keywords
  (OWNER, URL, ...) as column names; allow a delimited implicit alias.

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

* fix(tokenizer): dialect-correct string escapes and missing lexical forms

String literals no longer corrupt on format. The tokenizer and the renderer
disagreed about backslashes: PostgreSQL, Oracle, SQLite, SQL Server and generic
treat a backslash as an ordinary character, MySQL/MariaDB decode escapes
(keeping \% and \_), and ClickHouse/Snowflake decode escapes but keep unknown
ones. The tokenizer now follows dialect.StringEscape, and LiteralValue records
RawBackslash so the renderer writes a literal back under the same rules instead
of doubling every backslash on each format pass (non-idempotent, data
corrupting for regexes and paths). Unknown escapes no longer fail tokenizing.

Lexical coverage:
- operators ^ ^@ <=> <-> << >> == := and { }
- 0x hex numbers, leading-dot (.5) and trailing-dot (5.) numbers
- prefixed strings N'' X'' B'' E'' q'[]' and MySQL _charset'' (kept on the AST
  as LiteralValue.Prefix and written back)
- $ inside identifiers (Oracle/Snowflake/PostgreSQL), # in Oracle identifiers
- SQLite :name, ?NNN and [bracket] identifiers, ClickHouse {p:Type} parameters
- a leading UTF-8 byte order mark is skipped

The real-world corpus ratchet drops 64 known failures.

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

* fix(parser): accept keywords used as column names

FIRST, LAST, NEXT, FILTER, SHARE, SOURCE, TARGET, MATCHED, FINAL,
TEMPORARY and the role-option words were rejected where a column
reference starts (SELECT first, key FROM t), although no clause gives
them a keyword meaning there. Accept them as column names and qualified
name parts; KEY, INDEX and VIEW stay rejected in MySQL, MariaDB, SQL
Server and Oracle, where they are reserved, and none of the words
becomes a function name.

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

* fix(parser): parse and render SELECT-body dialect clauses without dropping them

LIMIT/OFFSET/FETCH values are now expressions when they are not plain
integers ($1, ?, @n, -1, 1 + 1, subqueries), with LIMIT ALL, OFFSET before
LIMIT, OFFSET n ROWS, FETCH FIRST ROW ONLY and SQLite LIMIT a, b. The new
SelectStatement fields (LimitExpr, OffsetExpr, LimitAll, OffsetRows, ...)
sit beside the existing *int fields, which stay filled for integers.

The formatter and SelectStatement.SQL() now render every SELECT field:
QUALIFY, START WITH / CONNECT BY, PREWHERE, ARRAY JOIN, TOP inside derived
tables (keeping TOP (n) parentheses), OFFSET ... ROWS before FETCH, and
CROSS/OUTER APPLY without a trailing JOIN. A reflection test fails when a
new SelectStatement field is not rendered by both renderers.

New syntax, dialect-gated:
- MySQL select modifiers (SQL_CALC_FOUND_ROWS, STRAIGHT_JOIN, ...),
  STRAIGHT_JOIN joins, JOIN without ON, LOCK IN SHARE MODE, INTO
  @vars / OUTFILE / DUMPFILE; MariaDB/Oracle FOR UPDATE WAIT n
- SELECT ... INTO table (SQL Server, PostgreSQL) and WHERE / GROUP BY /
  HAVING without FROM
- GROUP BY ... WITH ROLLUP / CUBE / TOTALS kept as written (MySQL rejects
  ROLLUP(...)), GROUP BY ALL (Snowflake, ClickHouse, BigQuery)
- ClickHouse LIMIT n BY, LIMIT n WITH TIES, FORMAT, INTERPOLATE, GLOBAL and
  ANY/ALL/ASOF/SEMI/ANTI join strictness, * EXCEPT / REPLACE
- Snowflake TOP n, * EXCLUDE / REPLACE / RENAME / ILIKE, ASOF JOIN ...
  MATCH_CONDITION
- SQL Server FOR JSON / FOR XML / FOR BROWSE, OPTION (...) query hints,
  HASH/LOOP/MERGE/REMOTE join hints; Oracle CROSS/OUTER APPLY, ORDER
  SIBLINGS BY, START WITH after CONNECT BY; PostgreSQL ORDER BY ... USING op
- a comma-separated table after an explicit join

Non-integer LIMIT literals such as 10.5 are no longer truncated to an int;
they are kept verbatim in LimitExpr so formatting never changes the row
count. The real-world corpus ratchet drops 95 known failures.

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

* fix(parser): reject utility keywords without arguments

A bare GRANT, KILL or EXEC was accepted as a complete verbatim
statement. Require at least one argument token except for the
statements that are complete on their own (VACUUM, ANALYZE, REINDEX,
CLUSTER, CHECKPOINT, and T-SQL THROW, RETURN, REVERT).

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

* fix(parser): parse keyword-named, qualified and special-form function calls and typed literals

Primary expressions and function-call forms that failed to parse or
silently changed meaning on format now round-trip:

- Keyword-named functions: LEFT/RIGHT, GROUPING, TRUNCATE, DEFAULT, ROW,
  INSERT, ClickHouse any/range, Snowflake HASH, MySQL INTERVAL(n, ...).
- Qualified function calls (pg_catalog.now(), auth.uid(), pkg.fn()) and
  the Oracle (+) outer-join marker (OuterJoinMarker).
- Typed literals DATE/TIME/TIMESTAMP '...' (any type name in PostgreSQL)
  as TypedLiteral; SQL Server N'...' strings (Type "nstring"); adjacent
  string literals concatenated in MySQL, MariaDB and PostgreSQL.
- SQL-standard special forms via KeywordArgument: SUBSTRING FROM/FOR,
  POSITION IN, TRIM BOTH/LEADING/TRAILING ... FROM, OVERLAY PLACING,
  CONVERT USING, TRY_PARSE/PARSE AS, COLUMN_GET AS, LISTAGG ON OVERFLOW.
- GROUP_CONCAT SEPARATOR is kept in FunctionCall.Separator instead of
  being rendered as an extra argument (which changed results).
- Oracle KEEP (DENSE_RANK FIRST|LAST ORDER BY ...) as FunctionCall.Keep;
  CONNECT_BY_ROOT and PRIOR in the select list.
- INTERVAL <expr> <unit> keeps the value unquoted (IntervalExpression
  ValueExpr/Unit), so MySQL INTERVAL 1 DAY no longer formats as the
  invalid INTERVAL '1 DAY'; legacy Value text is preserved.
- MATCH ... AGAINST is a MatchAgainstExpression (was rendered as
  AGAINST AGAINST(...)).
- NEXT VALUE FOR, MySQL @@[scope.]var and @v := expr.
- ROW(...), nested ARRAY[[...]], ARRAY(<set operation>), bare bracket
  array literals with subscripts (ClickHouse, Snowflake), ClickHouse
  tuple element access (t).1, CAST(x, 'Type') and extract(s, re).
- TRY_CAST is rendered as TRY_CAST, CAST target types accept array
  suffixes and multi-word types (DOUBLE PRECISION, SIGNED INTEGER).

The real-world corpus ratchet drops 123 known failures; three move to a
later stage.

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

* fix(formatter): map LIMIT expressions to TOP/FETCH and allow APPLY table functions

Formatting for SQL Server or Oracle now turns LIMIT / OFFSET expression
values ($1, @n, :n) into TOP (expr) or OFFSET expr ROWS FETCH NEXT expr ROWS
ONLY, as it already did for integers, and drops LIMIT ALL. The right side of
CROSS APPLY / OUTER APPLY may be a table-valued function call such as
sys.dm_exec_sql_text(qs.sql_handle) in dialects whose FROM clause does not
otherwise take function calls.

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

* fix(parser,ast): keep dotted quoted collations and index COLLATE; walk new operator nodes

- COLLATE "en_US.utf8" was written back as en_US."utf8" (a schema-qualified
  name): CollateExpression now carries the qualifier in a Schema field, so a
  dot inside a quoted name stays part of the name.
- CREATE INDEX ... (a COLLATE x): CreateIndexStatement.SQL() dropped the
  collation, and the formatter wrote COLLATE "C" as bare C (PostgreSQL folds
  it to c). IndexColumn gains CollateQuoted; both writers emit the collation.
- transform's expression walker now descends into AT TIME ZONE, COLLATE,
  ANY/ALL/SOME over arrays, the ClickHouse conditional and LIKE ... ESCAPE, so
  QualifyColumns / ReplaceTable reach their operands (incl. = SOME (SELECT)).

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

* fix(parser,ast): tighten LIMIT BY, ASOF and star-modifier edge cases

- ClickHouse: reject a second LIMIT ... BY, LIMIT n WITH TIES BY, and an
  OFFSET written before LIMIT n BY instead of silently dropping or moving
  a clause.
- Snowflake: ASOF JOIN ... MATCH_CONDITION (...) no longer requires ON.
- Render qualified EXCLUDE / EXCEPT names part by part so t.a does not
  become the single identifier "t.a".
- Release the pooled children of a StarExpression (pass-through case).

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

* fix(parser): reject bare CALL, SET = and savepoint statements without a name

CALL with no procedure fell back to a verbatim UtilityStatement, SET = 1
recorded "=" as the variable name, and SAVEPOINT / RELEASE [SAVEPOINT] /
ROLLBACK TO [SAVEPOINT] / SAVE TRAN without a name parsed as valid. They
now report errors like GRANT and KILL with no arguments.

isKeywordColumnName now matches the token type before peeking at the next
token, so literal operands no longer pay for a token copy.

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

* fix(parser,ast,security): release and scan new primary-expression nodes

Review follow-ups for the primary-expression and special-form work:

- Accept the ClickHouse NANOSECOND unit in INTERVAL <expr> <unit>; the
  stricter unit check had started rejecting it.
- Release pooled children under IntervalExpression.ValueExpr, FunctionCall
  KEEP ORDER BY, ARRAY(<set operation>) and the new unpooled nodes
  (KeywordArgument, MatchAgainstExpression, OuterJoinMarker,
  ConnectByRootExpression, AssignmentExpression).
- Have the AST security scanner descend into the new nodes. MATCH ...
  AGAINST (SLEEP(5)) was reported while it parsed as a BinaryExpression and
  was missed once it became a MatchAgainstExpression.
- Match schema- and package-qualified calls (pg_catalog.pg_sleep,
  DBMS_LOCK.SLEEP, UTL_HTTP.REQUEST, sys.xp_cmdshell) in the scanner, now
  that qualified calls parse in expressions.

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

* test(bench): add real-world corpus parse and parse+format benchmarks

BenchmarkRealWorldParse / BenchmarkRealWorldParseFormat run every
parseable statement of testdata/realworld through ParseWithDialect (and
FormatAST) in its own dialect, giving a production-shaped throughput and
allocation baseline across all eight dialects.

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

* fix(gosqlx): keep procedural bodies and honour GO/DELIMITER in SplitStatements

SplitStatements split on every top-level ';', so trigger and procedure
bodies were cut at the first inner ';' and multi-batch T-SQL scripts using
GO merged into one statement.

The splitter now tracks BEGIN ... END nesting (plus CASE, IF/LOOP/WHILE/
REPEAT inside a block, Oracle DECLARE and IS/AS sections, PostgreSQL BEGIN
ATOMIC) and keeps the whole compound statement together. Transaction
statements (BEGIN; BEGIN TRANSACTION; BEGIN IMMEDIATE; ...) are not blocks,
and a script whose blocks do not balance falls back to plain semicolon
splitting. Client batch separators are honoured and consumed: a GO [n]
line for SQL Server, the DELIMITER directive for MySQL/MariaDB and a lone
"/" line for Oracle. Plain scripts split byte-for-byte as before, and the
splitter still allocates only its result slice.

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

* fix(parser): end T-SQL utility statements at the next statement and reject truncated ones

SQL Server batches without semicolons ("SET NOCOUNT ON SELECT * FROM t")
swallowed every statement after a SET, USE, transaction, PRINT, EXEC,
DECLARE, THROW, RAISERROR, WAITFOR, DBCC or RETURN into its text, so the
linter, scanner and analyzer never saw them. Such a statement now ends at
a top-level word that starts a statement (SELECT, INSERT, UPDATE, DELETE,
MERGE, WITH CTE, CREATE, ALTER, DROP, SET, EXEC, IF, BEGIN, ...), outside
parentheses and not after FOR / AS (cursor declarations) or a "." Options
such as WITH MARK, WITH RECOMPILE and WITH NOWAIT still continue the
statement. Statements that own a body (IF, WHILE, BEGIN blocks, END) and
GRANT / REVOKE keep running to the semicolon. Other dialects are unchanged.

The verbatim fallback no longer accepts truncated input: SET with nothing
after the variable name (SET GLOBAL, SET @x), SHOW alone or with only a
modifier, and statements that end on LIKE, WHERE, FROM, IN, TO, a comma or
an assignment operator are now parse errors, so gosqlx validate reports them.

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

* fix(parser,ast): MERGE OUTPUT INTO, MySQL DELETE rejection, MERGE/REPLACE pool release

- SQL Server MERGE ... OUTPUT ... INTO @t now uses parseDMLOutputClause;
  MergeStatement gains OutputInto, rendered by the AST writer and formatter.
- MySQL and MariaDB reject DELETE t WHERE ... (FROM is required) and
  DELETE FROM t1, t2 WHERE ... (several targets need USING); both used to
  be accepted and rewritten or passed through. Oracle and SQL Server keep
  FROM-less DELETE.
- PutMergeStatement releases a derived-table source or target subquery and
  the OUTPUT INTO columns. Merge and Replace release now follow the #556
  releaseDrawn policy: ReleaseAST puts only the node types the parser draws
  (the parser allocates MergeStatement with a literal), while the exported
  Put functions still return everything.

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

* fix(transform): ReplaceTable and AddTableAlias handle multi-table UPDATE and DELETE

The transforms only touched UpdateStatement/DeleteStatement.TableName and
WHERE, so MySQL multi-table forms, UPDATE ... FROM and DELETE ... FROM /
USING produced SQL that still named the old table, with no error.

ReplaceTable now renames the table in further targets, JOINs, FROM / USING
lists and in column qualifiers of SET, OUTPUT, ORDER BY and RETURNING.
AddTableAlias applies the alias wherever the statement names the table,
points the qualifiers at the alias (a table with an alias cannot be named
any more) and renames DELETE t FROM ... targets to the alias. A table that
appears twice (self join) returns an error instead of taking one alias for
both instances.

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

* fix(tokenizer,ast,parser): never change a string literal's value on format

Follow-ups to the dialect string/escape work:

- Unconfigured tokenizer (no dialect) is strict again: \\ \' \" \` \n \r \t
  decode, any other backslash escape is an error (as before the escape
  rework), so gosqlx.Parse/Format and the CLI 'format' without --dialect
  refuse 'C:\dir' instead of rewriting it to a different PostgreSQL value.
  New zero-value mode dialect.StringEscapeStrict; unknown dialects map to it.
- NUL is never silently dropped: escaping literals write it as \0 (E-strings
  as \000). A raw NUL/Ctrl-Z inside a string, or an escape decoding to one,
  is rejected where the formatter cannot write it back (no-escape dialects,
  unconfigured, Snowflake/Redshift; Ctrl-Z in ClickHouse).
- Snowflake and Redshift get StringEscapeDropUnknown: the backslash before
  an unrecognised character is ignored (Snowflake docs: "the backslash is
  ignored"; Redshift's documented LIKE default escape is written '\\\\'),
  with complete octal/\xhh/\uhhhh escapes; dialect-dependent short forms
  are rejected.
- ClickHouse no longer decodes \Z (kept as written, like other unknown
  escapes) and decodes \a \v \f and ASCII \xHH.
- Triple-quoted strings are BigQuery-only, so '''a' tokenizes (value 'a)
  and '''abc''' keeps its quotes in every other dialect.
- Hex literals (0x1E) are typed int, not float.
- A delimited identifier containing '$' keeps its delimiters (standard SQL
  and BigQuery do not accept '$' in bare names); bare ones stay bare.

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

* fix(transpiler): re-render string literals for the target dialect

Transpile wrote each string literal with the SOURCE dialect's escaping and
introducers. A PostgreSQL 'x\' followed by more text closed early in MySQL
(the rest of the statement ran as code), 'C:\dir' lost its backslash, and
E'..', q'[..]' and _utf8mb4'..' reached targets that reject them.

After the rewrite rules, every string literal (found by a reflective walk,
so a node with an incomplete Children list cannot hide one) gets the
target's backslash mode, and introducers the target lacks are dropped:
E'..' outside PostgreSQL, q'[..]' outside Oracle, a charset introducer
outside the MySQL family. The decoded value is unchanged and re-escaped
for the target. A NUL or Ctrl-Z the target cannot spell (NUL where
backslashes do not escape; \0/\Z outside MySQL where they read
differently) is an error instead of being dropped or changed.

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

* feat(parser): parse ALTER TABLE across dialects and ALTER <object> statements

ALTER TABLE previously accepted one unqualified table name and one of
ADD COLUMN / ADD CONSTRAINT / DROP COLUMN / DROP CONSTRAINT / RENAME / ALTER,
with ALTER COLUMN reading its sub-action as a column definition (so DROP
DEFAULT formatted as a column of type "DROP").

- Qualified names, IF EXISTS, ONLY, ON CLUSTER (ClickHouse) and the MariaDB
  ONLINE / IGNORE modifiers; comma-separated action lists in
  AlterStatement.Operations (Operation stays the first action).
- ADD [COLUMN] [IF NOT EXISTS] with the optional COLUMN keyword, Oracle and
  MySQL parenthesized lists, SQL Server column lists, FIRST / AFTER; ADD
  [CONSTRAINT] PRIMARY KEY / UNIQUE / FOREIGN KEY / CHECK with CLUSTERED /
  NONCLUSTERED, NOT VALID and other trailing attributes, WITH [NO]CHECK;
  ADD / DROP INDEX / KEY (MySQL, MariaDB, ClickHouse).
- DROP COLUMN [IF EXISTS] lists, CASCADE / RESTRICT, DROP PRIMARY KEY /
  FOREIGN KEY / CHECK; MODIFY, CHANGE, RENAME COLUMN / CONSTRAINT / TO / AS.
- ALTER COLUMN SET / DROP DEFAULT with any expression, SET / DROP NOT NULL,
  [SET DATA] TYPE t [USING e] and other sub-actions; full definitions for
  SQL Server / Oracle.
- Dialect-gated long tail (ENABLE / DISABLE / VALIDATE, OWNER TO, SWAP WITH,
  CLUSTER BY, SET props, MySQL table options, partition operations,
  ClickHouse MODIFY TTL / SETTING and partition actions) kept as a keyword
  phrase plus text as written; ClickHouse UPDATE / DELETE mutations parsed.
- ALTER INDEX / SESSION / SYSTEM / USER / DATABASE / SCHEMA / VIEW /
  WAREHOUSE / TASK / ... are kept as written in a UtilityStatement.
- The formatter renders every action; pkg/schema applies multi-action and
  multi-column ALTERs and CHANGE renames.

Removes 130 statements from the real-world known-failures list.

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

* fix(parser,ast,formatter): set-op precedence, CTE and table-factor modifiers

Set operations:
- INTERSECT binds tighter than UNION/EXCEPT/MINUS (precedence climbing);
  SQLite and Oracle keep equal-precedence left-to-right grouping
- record explicit DISTINCT quantifiers and MINUS spelling (rendered as
  EXCEPT only for a target dialect without MINUS)
- VALUES operands; right-nested INTERSECT renders without parentheses so a
  trailing ORDER BY/LIMIT keeps applying to the whole query

CTEs: render MATERIALIZED / NOT MATERIALIZED and ClickHouse scalar CTEs in
the formatter; accept VALUES and parenthesized query bodies.

Table factors:
- render ClickHouse FINAL, Snowflake AT/BEFORE/CHANGES, FOR SYSTEM_TIME and
  MATCH_RECOGNIZE in the formatter (previously dropped)
- store TABLESAMPLE / SAMPLE (method, args, PERCENT/ROWS, REPEATABLE/SEED)
- PostgreSQL ONLY, Oracle @dblink and AS OF / VERSIONS BETWEEN flashback,
  MySQL PARTITION (...) and index hints, SQLite INDEXED BY / NOT INDEXED,
  SQL Server bare (NOLOCK) hints, INDEX(ix) hints and FOR SYSTEM_TIME
  (incl. CONTAINED IN)
- VALUES derived tables, column alias lists on any derived table or table
  (typed lists for PostgreSQL record functions), table functions for every
  named dialect, keyword-named function arguments (FLATTEN outer =>)

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

* feat(parser,ast,formatter): column definitions and table constraints across dialects

Column types: multi-word types (DOUBLE PRECISION, CHARACTER VARYING,
[TIMESTAMP|TIME] WITH[OUT] [LOCAL] TIME ZONE, INTERVAL fields, SQLite
UNSIGNED BIG INT), schema-qualified types, MySQL UNSIGNED/SIGNED/ZEROFILL,
PostgreSQL array suffixes (text[], int[3], int ARRAY), and typeless columns
for SQLite, ClickHouse and SQL Server computed columns.

Column constraints are modelled as typed ColumnConstraint kinds instead of
type-string suffixes: COMMENT, CHARACTER SET/CHARSET, COLLATE, ON UPDATE,
INVISIBLE/VISIBLE, SRID, T-SQL IDENTITY and NOT FOR REPLICATION, Snowflake
AUTOINCREMENT/IDENTITY START..INCREMENT, GENERATED {ALWAYS|BY DEFAULT [ON
NULL]} AS IDENTITY, GENERATED ALWAYS AS (expr) / AS (expr) with
STORED/VIRTUAL/PERSISTENT/PERSISTED, DEFERRABLE/INITIALLY, CLUSTERED, named
CONSTRAINT <name> prefixes, and ClickHouse MATERIALIZED/ALIAS/EPHEMERAL/
CODEC/TTL. Dialect-specific syntax is gated by dialect.

Table elements: UNIQUE [KEY|INDEX] [name], KEY/INDEX/FULLTEXT/SPATIAL with
USING, prefix lengths, ASC/DESC and functional key parts, PRIMARY KEY
CLUSTERED, DEFERRABLE timing, ClickHouse INDEX .. TYPE .. GRANULARITY,
PROJECTION, CHECK without parentheses and ASSUME, and PostgreSQL/MySQL LIKE
source INCLUDING/EXCLUDING.

SQLite AUTOINCREMENT is no longer rewritten to the invalid AUTO_INCREMENT:
the spelling the source used is kept. The formatter now delegates column and
constraint rendering to the AST renderer so the two cannot drift; free-form
fields are validated and dropped (never written) when not plainly shaped.

Corpus ratchet: 41 known failures removed, 6 ClickHouse statements advance
from parse to drop (the remaining loss there is table-level ORDER BY).

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

* fix(formatter): render ALTER ROLE, POLICY and CONNECTOR instead of a bare ALTER

These three statements parsed into an AlterStatement whose rendering fell
back to the token literal, so the formatter wrote "ALTER" and dropped the
rest. They are now rendered in full, ALTER POLICY keeps the table it is on
(AlterPolicyOperation.Table), ALTER CONNECTOR reads SET OWNER USER / ROLE by
word, and ALTER ROLE in dialects whose tokenizer has no ROLE keyword
(PostgreSQL, MySQL, Oracle, SQL Server, Snowflake) is kept as written.

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

* feat(parser,ast,formatter): model CREATE TABLE AS, table options, engine clauses and DDL objects

CREATE TABLE ... AS query no longer drops its query: CreateTableStatement
gains AsQuery/WithData (CTAS in every dialect, including WITH and
ClickHouse ENGINE ... AS SELECT) and the formatter no longer emits an
empty column list.

- CREATE TABLE: OR REPLACE, table kinds (TEMP, GLOBAL TEMPORARY, UNLOGGED,
  TRANSIENT, ...), ON CLUSTER, LIKE, Snowflake CLONE [AT|BEFORE],
  PostgreSQL PARTITION OF ... FOR VALUES / DEFAULT, ClickHouse AS table;
  a generic ordered table-option list (MySQL, PostgreSQL WITH (...),
  ON COMMIT, Oracle storage, SQLite STRICT / WITHOUT ROWID, Snowflake
  CLUSTER BY / COMMENT / retention); ClickHouse PARTITION BY, PRIMARY KEY,
  ORDER BY, SAMPLE BY, TTL, SETTINGS and COMMENT captured instead of
  skipped; WITH SYSTEM VERSIONING, PERIOD FOR and partition definitions
  rendered; partitioning by expressions, LIST values, HASH PARTITIONS n,
  LINEAR, Oracle INTERVAL and MariaDB SYSTEM_TIME.
- CREATE INDEX: expression elements, opclasses, NULLS FIRST, prefix
  lengths, CONCURRENTLY, ON ONLY, unnamed indexes, USING hash, INCLUDE,
  WITH (...) and Oracle/MySQL trailing options, and FULLTEXT / SPATIAL /
  BITMAP / [NON]CLUSTERED kinds.
- Views: OR ALTER, SECURE / FORCE / TEMP modifiers, WITH READ ONLY,
  WITH ... SELECT queries, MV WITH [NO] DATA after a FROM-less query,
  ClickHouse MV TO (no longer stored as TABLESPACE), typed columns,
  ENGINE clauses, POPULATE and ON CLUSTER, Oracle MV BUILD / REFRESH.
- DROP INDEX ... ON t, CONCURRENTLY, DROP TEMPORARY TABLE, CASCADE
  CONSTRAINTS, PURGE, ClickHouse ON CLUSTER / SYNC; TRUNCATE ... DROP |
  REUSE STORAGE.
- CREATE / DROP of other objects (SCHEMA, DATABASE, EXTENSION, TYPE,
  FUNCTION, PROCEDURE, TRIGGER, USER, ROLE, SEQUENCE, SYNONYM, STAGE,
  ...) are kept verbatim as UtilityStatement instead of failing or
  rendering as an UNSUPPORTED comment.

The clause writers are shared by SQL() and the formatter through
ast.DDLStyle. New pooled fields are reset in the Put functions.
Ratchet: 222 corpus statements fixed, none added.

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

* fix(gosqlx,linter): report syntax errors instead of "no violations"

gosqlx.Lint and LintWithDialect ignored tokenizer and parser failures, so
invalid SQL came back as a clean result with a nil error.

linter.FileResult now records TokenizeError and ParseError (SyntaxError()
returns whichever is set) and FormatResult prints them. Lint and
LintWithDialect return the violations of the text and token rules, which
still run on invalid input, together with an error wrapping ErrTokenize or
ErrSyntax. Empty, whitespace-only and comment-only content has no
statements and is not a syntax error. The playground bridge keeps reporting
invalid SQL through ParseError and no longer drops the violations.

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

* fix(formatter,gosqlx): keep comments and optimizer hints when formatting

gosqlx.Format parsed without the tokenizer's comments, so "format" deleted
every comment and optimizer hint (changing query plans for /*+ FULL(t) */).

formatter.JoinWithComments re-attaches comments around already-rendered
statements using the token spans: leading and trailing comments stay with
their statement, comments inside a statement move to the line above it, and
a hint directly after the statement's first keyword stays there. A hint or
MySQL executable comment that cannot be put back faithfully fails with
ErrUnplaceableComment instead of being dropped or moved. gosqlx.Format uses
it whenever the SQL has comments, and the new WithComments parse option
attaches comments to ast.AST.Comments for FormatTree and FormatAST.

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

* fix(gosqlx): ParseReaderMultiple skips comment-only segments

A script ending in "SELECT 1; -- done" produced a final comment-only
segment that failed to parse. Segments holding only whitespace and comments
are skipped like empty ones.

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

* fix(cli): exit non-zero on syntax errors and fix flag, dialect and I/O handling

- lint reports tokenizer and parser errors and exits 1 for them; --auto-fix
  judges the fixed text instead of the pre-fix report.
- format --no-uppercase shared the --uppercase variable, so it set
  uppercase to true; it now lower-cases keywords, and giving both flags is
  an error.
- format keeps comments and optimizer hints (JoinWithComments), refuses
  input whose hints cannot be placed, verifies that the output holds exactly
  the input's comments, and preserves a leading UTF-8 byte order mark.
- One alias table (postgres, pg, mssql, tsql, sqlite3, sql-server) is applied
  by validate, format, lint, parse, analyze, highlight and transpile.
- Errors are printed once, by main; usage is printed for flag and argument
  errors but not for failures of the work. validate --quiet exits 1 silently.
- validate reads stdin in memory and names it <stdin> instead of a temp
  path. A file that fails as a whole is re-checked statement by statement, so
  GO and DELIMITER scripts validate and files past the token limit work.
- Empty, whitespace-only and comment-only input is valid for validate, lint
  and format; parse and analyze say there is no statement.
- The global -f/-o flags are honoured (parse and transpile -o, validate -f
  json / -o) or rejected where unsupported; format -o with several inputs
  is rejected.

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

* docs: remove APIs and settings that do not exist; document CLI changes

USAGE_GUIDE referenced parser.NewTokenConverter (never existed; the
tokenizer's output goes straight to ParseFromModelTokens) and one tokenizer
example lacked the pkg/models import. TROUBLESHOOTING referenced
Parser.SetMaxTokens, SetMaxRecursionDepth and parser.max_tokens /
max_recursion_depth config keys; the limits are compile-time constants
(MaxRecursionDepth is 100, not 200). The token-limit error hint no longer
tells users to adjust MaxTokens. CLI_GUIDE, LINTING_RULES and the CHANGELOG
describe the new lint/format/validate behaviour.

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

* fix(parser,ast,formatter): CTE keyword names, SEARCH/CYCLE, PIVOT forms

- accept non-reserved keywords as CTE names (dbt's source, target, final);
  FINAL is a plain name outside ClickHouse; CTE names quote like table
  aliases
- SQL:1999 SEARCH {DEPTH|BREADTH} FIRST BY ... SET and CYCLE ... SET
  [TO ... DEFAULT ...] [USING ...] on recursive CTEs (PostgreSQL, Oracle)
- PIVOT: several aliased aggregates, PIVOT XML, IN (ANY [ORDER BY]),
  IN (subquery), alias column list, and the source alias of the pivoted
  table (SQL Server (SELECT ...) s PIVOT (...) p no longer drops s)
- UNPIVOT INCLUDE / EXCLUDE NULLS and Oracle multi-column UNPIVOT
- Snowflake FROM VALUES without parentheses and stage paths with file
  names (@stage/data.csv (FILE_FORMAT => ...))

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

* fix(ast,parser,formatter,transpiler): keep needed identifier quotes and use target delimiters

The AST now records how names stored as plain strings were delimited
(ast.NameQuote: which dotted parts must stay delimited, the source
delimiter, and an Oracle bare-'#' mark). The parser sets it for
identifiers and their qualifiers, select-item and table aliases, table,
function, CTE and view names and column lists, INSERT/UPDATE/DELETE and
MERGE targets and aliases, SET and MERGE columns, JOIN USING and ON
CONFLICT columns, and CREATE TABLE/VIEW names and column definitions.
A delimited name is recorded when bare it would mean something else:
letter case in a folding dialect (upper case in PostgreSQL/generic,
lower case in Oracle/Snowflake), a keyword ("table", "source"), or a
niladic/contextual word ("user", "PRIOR").

SQL() and the formatter keep those quotes in the source's delimiters, so
"CreatedAt", "Company" aliases, "table", s."select"(1) and a MySQL
`exists` alias round-trip; a formatter with a MySQL/MariaDB dialect
writes content-required delimiters as backticks. Oracle bare names such
as s.serial# and emp# stay bare instead of becoming case-sensitive
"serial#". Transpile rewrites every recorded delimiter to the target's
(backticks for MySQL/MariaDB, brackets for SQL Server, double quotes
otherwise), so MySQL backticks and T-SQL brackets no longer leak into
PostgreSQL output.

Removes the fixed corpusFormatKnownGaps entries (gorm_joins, mysql and
oracle 01_app_crud, postgresql 08_supabase_rls_misc) and updates tests
that pinned the old "case-only quotes render bare" behaviour.

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

* fix(gosqlx,lsp,cbinding): keep comments in dialect formatting; splitter BOM

The LSP and C-binding formatted non-default dialects through FormatAST on a
tree parsed without comments, so format-on-save deleted every comment and
hint. gosqlx.FormatWithDialect parses with the dialect, renders
dialect-specific constructs for it and preserves comments exactly like
Format; both callers use it.

SplitStatements ignores a leading UTF-8 byte order mark when looking for GO
and other directive lines, and the splitter tests and fuzz target accept
that directive lines are consumed (segments are then in-order substrings of
the source instead of an exact tiling).

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

* fix(parser,ast): qualified positional columns, db..t, OPENJSON schema

- Snowflake t.$1 positional column references after a qualifier
- SQL Server db..t (empty default-schema part), kept empty on output
- SQL Server OPENJSON(...) WITH (col type ['path'] [AS JSON], ...) column
  schema instead of a table-hint parse error
- Oracle PARTITION (p) partition-extended table names

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

* perf(ast): keep TableReference compact by grouping rare modifiers

Move TableSample and Flashback into the TableModifiers pointer so the
table-factor work adds one pointer (plus the VALUES body) to the
TableReference copied by value on the parser hot path, and add a
field-coverage test for every TableModifiers field.

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

* fix(ast,parser,formatter): keep needed quotes on DDL object names

Extends the NameQuote recording to the DDL positions that still wrote
quoted names bare: CREATE INDEX name, table and columns, table and
column constraint names, column lists and REFERENCES targets, DROP and
TRUNCATE names, CREATE MATERIALIZED VIEW names and columns, and the
ALTER TABLE target, column, constraint and RENAME TO names (Ident and
ObjectName gain a Quote field). Prisma/EF-style PostgreSQL migrations
such as CREATE UNIQUE INDEX "User_email_key" ON "User"("email") and
ALTER TABLE "Post" ADD CONSTRAINT "Post_fk" FOREIGN KEY ("authorId")
REFERENCES "User"("id") now format instead of being refused.

The ALTER TABLE renderer wrote the table and column names verbatim, so
`ALTER TABLE "my table" DROP COLUMN "a b"` came out unquoted; those
names now go through the same quoting as other names. Transpile
restyles the delimiters of all these names for the target dialect.

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

* fix(ast,parser,formatter): keep needed quotes on window names

Records NameQuote for named windows: the WINDOW clause definition name,
the base window a spec refines (OVER ("W" ORDER BY y), "W2" AS ("W")),
and a bare OVER "W" reference, so a window quoted for its case keeps its
quotes on both the definition and the references. Transpile restyles
them for the target dialect.

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

* perf(parser,ast): keep name-quote recording off the hot path

Identifier and FunctionCall keep their sizes (56 and 192 bytes) by
placing the new NameQuote fields in existing padding, and the parser
checks an inlinable nameQuoteRelevant guard before recording a name
part, so bare names outside Oracle skip the call entirely. Parser
benchmarks are back within noise of the base with unchanged
allocations.

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

* fix(parser): keep ClickHouse projection/index columns and quoted literals intact

- A column named "projection" with a parameterised type
  (projection FixedString(16)) was parsed as a PROJECTION element and moved
  after the columns. PROJECTION now also requires "(SELECT".
- The ClickHouse INDEX lookahead matched a 'type' string literal
  (index String DEFAULT 'type') and then failed to parse. Only an unquoted
  TYPE word counts now.
- parseTypeArgsString wrote string literals back without doubling embedded
  quotes, so a projection body with 'it''s' became unbalanced and the
  renderer dropped it, leaving "PROJECTION p", which reparses as a column.

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

* fix(gosqlx,formatter): do not merge statements after a begin column; refuse moving --+ hints

- SplitStatements treated a column named begin (CREATE TABLE t (begin INT),
  ALTER TABLE t ADD COLUMN begin int, MODIFY begin TIME) as a block opener.
  A later unrelated END word (SET end = 1, ORDER BY end) then closed it, so
  plain scripts in every dialect were silently merged into one segment. A
  BEGIN after "(", "," or a column-clause word is no longer a block.
- JoinWithComments moved an Oracle --+ line hint inside a statement to the
  line above it, dropping the hint. --+ comments are now significant: kept
  when leading or trailing, refused (ErrUnplaceableComment) when inside a
  statement, since they cannot be written inline after a keyword.

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

* fix(tokenizer): decode ClickHouse \/ and \= escapes; refuse \e and \N

ClickHouse drops the backslash before / and = (as it does before \\ \'
\"), so 'http:\/\/x' is http://x. The Lenient mode kept the backslash,
and formatting wrote 'http:\\/\\/x', a different value. Decode both, and
refuse \e and \N, whose ClickHouse meanings (ESC, nothing) are not
guessed. BigQuery shares the mode but rejects all four escapes, so no
valid BigQuery literal changes.

Also rewrite a test doc comment that gofmt had turned into curly quotes.

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

* fix(parser,formatter): end ALTER TABLE at the next statement; allow-list ALTER keyword fields

ALTER TABLE now ends at a statement keyword as well as at ';' or EOF, so
semicolon-less scripts (common in T-SQL) such as
"ALTER TABLE t DROP COLUMN c SELECT 1" parse as two statements again
instead of failing with "expected end of ALTER TABLE action".

The formatter no longer writes the keyword fields of a hand-built ALTER
TABLE verbatim (Modifiers, Modifier, Keyword, IndexKind, IndexKeyword,
ConstraintKind): a value that is not a bare keyword phrase is dropped, and
an ON CLUSTER target that is not a well-formed name or string is quoted,
following the #632 allow-list policy.

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

* fix(formatter,parser): keep ';' after verbatim statements; reject bare CREATE/DROP modifiers

With AddSemicolon (ReadableStyle, gosqlx.Format AddSemicolon, the MCP
format tool) FormatAST joined statements with a newline on the assumption
that each renderer writes its own ';'. Statements kept as written
(UtilityStatement and the stmtSQL fallback) do not, so the next statement
was read back as part of their text: "CREATE SCHEMA s; CREATE TABLE s.t
(a INT)" re-parsed as one CREATE SCHEMA. FormatAST now terminates any
statement that lacks the ';'.

createVerbatimKind and dropVerbatimKind accepted modifier words on their
own, so "CREATE OR FUNCTION f", "CREATE REPLACE FUNCTION f", "DROP
REPLACE x" and "DROP TEMP x" validated as statements. OR must now be
followed by REPLACE or ALTER, and a DROP may only start with PUBLIC /
GLOBAL / UNIQUE before an object kind.

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

* fix(parser,ast): query tail after nested paren operand, looser left operand parens

With INTERSECT binding tighter, the last operand of A UNION B INTERSECT (C)
sits in a right-nested set operation, so a following ORDER BY / LIMIT was
rejected with E4001 (accepted on the base commit). attachParenthesizedQueryTail
now descends the unparenthesized right spine to find it.

SetOperandNeedsParens now parenthesizes a left set-operation operand that
binds looser than its parent under standard precedence, so a tree parsed
with Oracle / SQLite equal precedence ((A UNION B) INTERSECT C) keeps its
meaning when written for another dialect or by SQL(). A leading WITH clause
still keeps the operand unparenthesized.

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

* fix(parser,transform): keep cursor set operations whole, scope-aware DML alias

DECLARE c CURSOR FOR SELECT ... UNION SELECT ... was split at the second
SELECT into "... UNION;" and a separate SELECT, which the formatter wrote
with a ';' after UNION. A statement-starting word right after UNION, EXCEPT,
INTERSECT, ALL or DISTINCT now continues the utility statement.

AddTableAlias on UPDATE / DELETE renamed column qualifiers inside subqueries
that name the table in their own FROM (UPDATE t SET a = (SELECT max(t.b)
FROM t)), turning them into correlated references to the outer alias.
Qualifiers inside such a subquery are now left alone; correlated references
are still renamed.

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

* fix(transpiler): render output for the target dialect

SetOperation now keeps the MINUS spelling and renderers write it unless the
target dialect lacks MINUS, but Transpile formatted with no dialect, so
Snowflake / Oracle MINUS came out as MINUS for PostgreSQL and MySQL targets
(the base commit wrote EXCEPT). Transpile now sets the target dialect on the
format style, which also gives Oracle set-operation chains the grouping the
target's precedence needs.

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

* fix(transform): drop recorded quotes of names a table transform replaces

ReplaceTable and AddTableAlias replace names and aliases in place, but the
quote-preservation work records each name's delimiters next to it. The old
name's marks then carried over: a renamed "Users" became the case-sensitive
"Accounts", and an UPDATE alias U was written next to a still-quoted "U"
qualifier, which names a different relation in PostgreSQL. Replaced names
and qualifiers now drop the delimited-part marks (keeping the source's
delimiter style), so the new name is written as the caller gave it.

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

* chore(lint): remove dead helpers left by the dialect merges; scope errcheck

Remove functions the parallel fix packages left unused (rejectGlobalOutput,
writeNameList, TableModifiers.isEmpty, Parser.parseObjectName), simplify a
single-case switch, and exclude ast.textWriter's WriteString/WriteByte from
errcheck: the interface is implemented only by *bytes.Buffer and
*strings.Builder, whose writes never fail (errcheck already exempts those
concrete methods). golangci-lint: 229 issues -> 0.

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

* fix(ast,parser,formatter): AlterStatement.SQL, keep quote kinds in type args, record more name quotes

- ast.AlterStatement now has SQL(); the ALTER writers move from the
  formatter into ast (AlterSQL with DDLStyle) so SQL() and the formatter share
  one implementation. tree.SQL() no longer drops ALTER statements.
- parseTypeArgsString keeps the delimiter kind of double-quoted and backticked
  words (ClickHouse PROJECTION bodies, engine and type arguments), no longer
  turning "x" into the string 'x'; the verbatim-text checks accept delimited
  identifiers.
- Record the delimiters of ALTER ROLE/POLICY/CONNECTOR names, ALTER TABLE index
  names, index names inside table constraints, partition definition names and
  INHERITS parents.

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

* fix(formatter,parser,cli): dialect-aware Format, hoist WITH over set operations, place nested hints

- formatter.Format tokenizes and parses with Options.Dialect, so dialect-only
  literals and syntax (PostgreSQL 'C:\dir', MySQL 'it\'s', ClickHouse ENGINE)
  no longer fail in the no-dialect front end. An unknown dialect is ignored.
- A tree parsed with equal set-operator precedence (Oracle, SQLite) whose first
  SELECT has a WITH clause changed meaning when written for standard precedence
  (A UNION B INTERSECT C regrouped, or the CTEs went out of scope). Renderers
  now write the WITH clause before the whole chain and parenthesize the left
  operand (ast.HoistLeadingWith). The parser accepts WITH ... (query) ...
  and records it in SetOperation.LeftWithHoisted so it round-trips.
- Optimizer hints after a nested SELECT/INSERT/UPDATE/DELETE/MERGE keyword (CTE
  bodies, subqueries, union branches) are put back after the same keyword in
  the rendered statement when its occurrence count matches; otherwise the CLI
  still refuses. Fixes realworld/oracle/08_complex_nested.sql.
- CLI string-literal guard understands that MySQL, MariaDB and PostgreSQL read
  adjacent literals ('a' 'b') as one. Fixes realworld/mysql/10_misc_functions.sql.

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

* perf(tokenizer,parser,gosqlx): tokenize internal parse paths into a reusable scratch buffer

The package-level parse/validate/format entry points tokenized into a fresh
[]TokenWithSpan (plus fresh Word chunks) on every call, only for the parser
to copy the tokens into its pooled tokenBuf during preprocessing. That slice
was ~43% of alloc_space in the real-world parse+format profile.

Add internal/tokscratch: GoSQLX's own tokenize-then-parse paths (gosqlx
Parse/ParseBytes/ParseWithContext/ParseMultiple/ValidateMultiple/Format/
ParseWithRecovery/ParseTree, parser Validate*/ParseBytes/ParseWithDialect*)
now tokenize into a tokenizer-owned slice and Word slab that live only until
the next tokenize call, Reset or PutTokenizer. Reset zeroes the used parts
and drops a token buffer above 4096 tokens (as Parser.tokenBuf does). The
public Tokenize* methods still return caller-owned slices and Words.

The string entry points (Validate, ValidateWithDialect, ParseWithDialect)
also tokenize the caller's string in place instead of round-tripping
through []byte.

RealWorldParse: 12.4 MB -> 4.95 MB/op, 58.6k -> 47.7k allocs/op.
ParseBytesWithDialect: 7682 -> 3192 B/op, 26 -> 24 allocs/op.

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

* fix(parser): statement boundaries in scripts without semicolons; whole routine bodies

T-SQL scripts and migrations often omit ';'. A statement now ends where
the next one starts:

- The statement loops (Parse, ParseContext, ParseWithRecovery) mark a
  following statement start as a boundary, so COMMIT, SET, PRINT, EXEC
  ... after a DML statement parse ("BEGIN TRANSACTION / UPDATE ... /
  COMMIT", "SET NOCOUNT ON / SELECT 1 / SET NOCOUNT OFF"). GO lines
  separate batches. Other dialects accept only a transaction statement
  on a line of its own; "SELECT 1 FROM t garbage garbage2" stays an error.
- T-SQL statement words are never implicit aliases.
- T-SQL table variables (@t) are INSERT / UPDATE / DELETE / MERGE
  targets and FROM / JOIN items.
- Verbatim CREATE / ALTER / DROP and ALTER TABLE text actions end at the
  next statement start in T-SQL (CREATE SCHEMA keeps its elements,
  routine bodies run to the end of the batch).
- IF / WHILE bodies are parsed to find their end, including
  "IF ... stmt; ELSE stmt"; GRANT / REVOKE / DENY end after their
  principal list; T-SQL DENY is a utility statement.
- Transaction statements in other dialects end at a statement keyword
  starting the next line (START TRANSACTION / UPDATE / COMMIT).

Routine, trigger and PL/SQL bodies are one statement: verbatim
statements track BEGIN / CASE / IF / LOOP ... END nesting with the same
rules as gosqlx.SplitStatements, so "CREATE TRIGGER ... BEGIN UPDATE
...; END" and Oracle "BEGIN NULL; END;" no longer split at the inner ';'.
An unclosed block falls back to ';' boundaries, as the splitter does.

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

* fix(gosqlx): keep T-SQL "stmt; ELSE" in one SplitStatements piece; check agreement with the parser

In T-SQL a ';' may end the first branch of an IF before its ELSE
("IF @a = 1 PRINT 'a'; ELSE PRINT 'b'"); the splitter no longer cuts the
IF there. BEGIN followed by :NEW, "x" or <<label>> opens a PL/SQL block,
as in the parser.

TestSplitStatements_AgreesWithParser parses every piece of the
procedural split cases and of the real-world corpus and requires each
piece that parses to hold exactly one statement, so the splitter and
the parser keep agreeing on statement ends.

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

* test(realworld): T-SQL batches without semicolons and PL/SQL blocks

sqlserver/11_batches_no_semicolons.sql holds GO-separated batches whose
statements follow each other without ';' (transactions around DML, SET
around queries, table variables, IF / ELSE and WHILE bodies, ALTER
TABLE text actions, GRANT / DENY / REVOKE, a procedure, TRY / CATCH).
oracle/09_plsql_blocks.sql holds anonymous blocks, routines, triggers
and a package separated by ';' and SQL*Plus "/" lines. Both pass every
stage; known_failures.txt is unchanged.

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

* perf(ast)!: move the rare dialect SELECT clauses behind SelectStatement.Extras

The dialect work grew SelectStatement from 384 to 528 bytes; above 512
bytes a pointerful object also carries an 8-byte malloc header, so every
parsed SELECT cost a 576-byte allocation. Move the seven rarely set clause
fields added since v1.16.0 (SelectModifiers, Into, Interpolate, LimitBy,
ForOutput, QueryHints, OutputFormat) into a new SelectExtras struct held by
pointer, like TableReference.Modifiers. SelectStatement is now 440 bytes
(448 class): 128 bytes less per SELECT. EnsureExtras allocates it for
writers; renderers read through a nil-safe helper.

BREAKING CHANGE (unreleased API only): these fields were added after
v1.16.0 and now live on SelectStatement.Extras.

ParserSimpleSelect: 1072 -> 944 B/op; FullPipeline/simple_select:
4292 -> 4163 B/op.

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

* fix(parser,ast): parse VALUES, ^, BINARY, PG prefix operators, string aliases and more

Clears 27 of the 43 known real-world corpus failures.

Parser:
- Stand-alone VALUES (...), (...) statement, also with set operations.
- ^ operator: power in PostgreSQL, XOR in MySQL/MariaDB (tighter than *),
  bitwise XOR at the additive level in SQL Server.
- PostgreSQL prefix operators |/ ||/ !! @ (operand is an additive
  expression, like any other operator); the renderer keeps them apart from
  an operand that starts with an operator character.
- MySQL/MariaDB BINARY expr prefix operator (ast.MySQLBinary) and DISTINCTROW.
- REFERENCES takes schema-qualified, quoted table names in table and column
  constraints (was a syntax error in every dialect).
- ClickHouse ORDER BY ALL.
- MySQL/MariaDB/SQLite AS 'alias'; the CLI quote guard treats it as a name.
- Oracle VERSIONS BETWEEN bounds accept + and - (SYSTIMESTAMP - INTERVAL ...).
- SQL Server UPDATE STATISTICS and $5.00 money literals.

Corpus detector: the "drop" stage no longer reports exact synonyms the
formatter writes canonically (SELECT ALL, DISTINCTROW, postfix ISNULL and
NOTNULL) or dollar-quoted strings, which it cannot lex. These were not
semantic losses.

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

* perf(tokenizer): build a compound keyword's joined Value only when needed

readIdentifier always concatenated ident + " " + next for a matched
compound keyword (GROUP BY, ORDER BY, LEFT JOIN, ...) and then replaced it
with the zero-copy input substring in the common single-space case, so
every such keyword cost a string allocation. Concatenate only when the
separator must be normalized.

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

* fix(parser): bound wasted block scans; END/ABORT/RELEASE end a txn script line

A script of unclosed blocks (T-SQL "BEGIN SELECT 1; BEGIN SELECT 1; ...",
Oracle "BEGIN NULL; BEGIN NULL; ...") made every statement's block-tracking
scan run to the end of the input before falling back to ';', which is
quadratic (1600 blocks: 100 ms vs 0.7 ms on the base). Once such scans have
cost four passes over the input, the remaining statements use plain ';'
boundaries directly. Corpus results are unchanged.

Outside SQL Server, END [TRANSACTION] (PostgreSQL, SQLite), ABORT and
RELEASE SAVEPOINT on a line of their own now also start a statement after
one written without ';' ("BEGIN TRANSACTION\nUPDATE ...\nEND" was an error).

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

* perf(formatter,gosqlx): presize the SELECT render buffer; skip the option bag without options

- renderSelect grows its builder to 128 bytes up front (75% of rendered
  real-world SELECTs are <= 111 bytes), replacing the 8-16-32-64-128
  growth steps with one allocation.
- FormatAST moved its option bag to the heap on every call because &cfg
  was passed to option closures; options are now applied in a separate
  function only when some are given.

RealWorldParseFormat: 76.7k -> 72.9k allocs/op, 6.02 -> 5.98 MB/op.

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

* fix(parser,ast,formatter): close the real-world corpus failures (join groups, JSON_TABLE, query tail, ...)

The known-failure list is now empty (16 entries closed, plus 6 that the Oracle
splitter fix below uncovered).

Parser and AST
- A parenthesized joined table as a JOIN operand is kept whole as
  TableModifiers.Group (a JOIN (b JOIN c ON ...) ON ..., t1 LEFT JOIN
  (t2, t3) ON ...): flattening it would change which tables an ON condition
  sees. Rendered by SQL() and the formatter, walked by Walk, renamed by
  transform.ReplaceTable and listed by ExtractTables. An alias after a group is
  refused.
- SetOperation.Tail: an ORDER BY / LIMIT / OFFSET / FETCH after a set operation
  whose last operand is parenthesized and has clauses of its own (and after a
  parenthesized set operation). A clause that already applies to the whole
  operation still returns E4001.
- JSON_TABLE (MySQL, MariaDB, Oracle) and XMLTABLE (Oracle, PostgreSQL) keep
  their COLUMNS / PASSING clause as text (ast.ClauseArgument); JSON_OBJECT
  KEY / VALUE / name: value entries; XMLATTRIBUTES / XMLFOREST AS names;
  CURSOR(SELECT ...) and MULTISET(SELECT ...); SELECT UNIQUE; PARTITION FOR;
  INSERT ... LOG ERRORS.
- MariaDB: WITH / WITHOUT SYSTEM VERSIONING columns, DELETE HISTORY ... BEFORE
  SYSTEM_TIME, LIMIT ... ROWS EXAMINED.
- ClickHouse COLUMNS('re') / * APPLY (fn); Snowflake {'k': v} object constants;
  SQL Server OPENROWSET(BULK 'file', ...) and (expr).method(args) calls.
- The Oracle statement splitter understands q'[...]' / nq'[...]' literals, so a
  quote or ';' inside one no longer swallows the following statements.

Tests that pinned the old refusals were updated; every new form has a
behaviour test, and the CLI corpus gains one known gap (mysql/05 contains
DESCRIBE, which has no renderer).

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

* perf(parser): keep the new syntax checks off the common parse path

Guard the BINARY and DISTINCTROW lookups by length, test the token type
before the dialect for the PostgreSQL prefix operators, check the
special-argument functions (CURSOR, JSON_OBJECT, XMLTABLE, ...) once per call,
pass the first table reference to parseTableListAndJoins by pointer and keep
the ^ operator fast path inline. Simple SELECT parses within about 2% of the
base commit (min of 16 alternating runs); allocations are unchanged apart from
InsertStatement growing by one pointer for LOG ERRORS.

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

* fix(dialect): preserve GO batches and finish audit validation

Retain SQL Server GO boundaries and repeat counts through parsing,
recovery, library rendering, transpilation and CLI formatting. Reject
unsupported target batch scopes and invalid GO repeat counts.
Preserve raw-clause
literal quo…

This branch was successfully deployed

1 active deployment
Preview — e6be50fe 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 tokenizer Tokenizer / lexer changes

Projects

None yet

1 participant