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>
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