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>

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