Repository navigation
fix(ast,formatter): NOT EXISTS, quoted identifiers, table hints and PIVOT render correctly (#601, #611, #604) - #632
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
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 What I checked
Suggestions (non-blocking)
Test coverageCoverage looks strong:
SecurityNo new injection surface. Quoting only adds escaping, and The PR is fine to merge from my side once you've considered point 1. --- · Branch: |
9b56c7d to
9038ed0
Compare
|
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 Note for whoever owns |
|
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
SecurityNo new injection surface. Quoting only adds escaping. |
…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
9038ed0 to
e6be50f
Compare
|
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 What I did not cover
Suggestions
Before merge
|
|
Merge train (batch 3): #632
|
…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
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
…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>
…e guards Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
…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
…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>
…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…
…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…
Summary
#601 ast/formatter: NOT EXISTS (subquery) renders as
NOT (EXISTS (...) NOT ), which does not re-parseThis 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 invalidNOT (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"andidproduce different fingerprints. Deferred follow-ups, each with a sound scope reason:ON cols#603 chains)gosqlx formatsilently corrupts many expressions; switch the CLI to pkg/formatter #599)#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 asAS alias, in parser order: alias, FINAL, FOR SYSTEM_TIME, hints, PIVOT/UNPIVOT, AS alias. Before this,SELECT * FROM s PIVOT (...) AS prendered asSELECT * FROM s pandWITH (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 gainingWITH (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:
NOT (EXISTS (...) NOT ), which does not re-parse #601)19 files changed, 1585 insertions(+), 42 deletions(-)Test Plan
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.benchstat -count=6before/after on the same machine.#601 evidence (validator)
#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)
#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)
#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.txtBreaking Changes
NOT (EXISTS (...) NOT ), which does not re-parse #601: none. No exported signatures or AST fields change. The only observable change: BinaryExpression.SQL() (and so ast.AST.SQL(), formatter.FormatAST/FormatStatement/FormatExpression and FormatString) now renders the parser's NOT EXISTS shape (Operator "NOT", Right nil) asNOT EXISTS (...)instead of the invalidNOT (EXISTS (...) NOT ). This is a bug fix; the previous output could not be re-parsed.ast.Identifier.Quoted bool, set by the parser for ANSI double-quoted identifiers and cleared on pool release.ast.QuoteName(string) stringandast.QuoteQualifiedName(string) string, needed because pkg/formatter cannot call unexported ast helpers.Observable behaviour changes in SQL(), FormatAST, FormatStatement, Normalize, Fingerprint and Transpile output:
WITH (hints)and the PIVOT/UNPIVOT clauses (with the pivot alias asAS alias), and the formatter now emitsWITH (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 tcame back fromgosqlx.Format/SQL()as two statements. Main has the same exposure. This PR now closes the whole class:'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 throughSELECT 1 ASx”; DROP…“``.-- 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):
Additional behaviour changes:
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.'/".lıkeis stored asLIKE. Window rendering no longer repeats the window name or wrapsOVER win parentheses. The formatter keepsWITHinWITH CHECK OPTION.Follow-ups found during review, already on main and not changed here:
ALTER TABLE ... ADD CONSTRAINTrenders empty. SQL ServerCROSS APPLYrenders asCROSS APPLY JOIN. MySQLMATCH ... AGAINSTduplicatesAGAINST. In the default dialect,"#x"renders bare and fails to re-parse.Notes
gosqlx formatsilently corrupts many expressions; switch the CLI to pkg/formatter #599-schema/postgres: loader misreports index columns and key flags (column order, INCLUDE, expression and partitioned indexes, read-only roles) #620); later PRs in the train are rebased onto this one after it merges.main.Fixes #601
Fixes #611
Fixes #604
Follow-up to #566
🤖 Generated with Claude Code
https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve