Repository navigation
fix(dialect): complete real-world SQL audit and preserve round trips - #858
ajitpratap0 wants to merge 103 commits into
Conversation
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
…ith 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
…IME 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
… 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
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
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
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
…pping 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
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
… 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
…ble 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
…k 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
- 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
… 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
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
# Conflicts: # testdata/realworld/known_failures.txt
# Conflicts: # cmd/gosqlx/cmd/format_corpus_test.go # testdata/realworld/known_failures.txt
# Conflicts: # testdata/realworld/known_failures.txt
# Conflicts: # testdata/realworld/known_failures.txt
# Conflicts: # cmd/gosqlx/cmd/format_corpus_test.go # pkg/sql/ast/ast.go # pkg/sql/ast/sql.go # pkg/sql/parser/dml_update.go # pkg/sql/parser/expressions_literal.go # testdata/realworld/known_failures.txt
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
…tatements 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
…eject 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
…LACE 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
…ATE 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
…ormat 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
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
…atements 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
…difiers 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
|
Claude finished @ajitpratap0's task in 29s —— View job Review of PR #858Scope: The merge base now resolves ( Resolved from earlier bot reviews
|
The PostgreSQL absolute-value operator was written directly before its operand, so "@ a - b", "@ t.a", "@ abs(a)" and "@ a::int" rendered as "@A ...", which re-parses as a placeholder or variable and changes the tree. Always write "@ " for the operator. The parser now also refuses "@" directly followed by operator characters ("@-@", "@-5", "@~a"): PostgreSQL lexes those as one longer operator, so reading them as abs of a signed operand produced output that no longer re-parsed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
For "WITH c ... ((A UNION B) INTERSECT C) EXCEPT D" the parser marks the outermost level whose left operand was parenthesized after the WITH clause, but HoistLeadingWith walked the left spine from the innermost level and gave up at the first parenthesized level without the mark, so SQL(), FormatAST and Transpile wrote the WITH clause inside the parentheses, hiding its CTEs from the other operands. Walk from the outermost level and hoist at the marked level; levels below it are inside the clause's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
A hint after a nested SELECT was put back after the SELECT with the same ordinal in the rendered statement. When the renderer writes the clauses holding the subqueries in another order (Oracle START WITH before CONNECT BY), the hints swapped subqueries. Record a context signature (surrounding words) for every occurrence of the hinted keyword in the source and in the rendered statement. Place the hint by ordinal when the signatures line up; otherwise place it at the only rendered occurrence with the hinted signature, and refuse with ErrUnplaceableComment (input left unchanged) when that occurrence cannot be identified. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
sqlcmd splits a script at every line holding only GO [n], but the parser
recognized GO only at a statement start, so "SELECT a,\ngo\nFROM t"
parsed (and formatted) as one statement with a column named go, while
the comment layer split the source at the GO line.
In SQL Server, a statement that runs across a GO line is now a parse
error ("GO batch separator inside a statement"); the recovering parser
reports it and resumes at the GO line. GO with an invalid repeat count
alone on its line (GO 0, GO 1.5) gets a dedicated "invalid GO repeat
count" error instead of an unrelated FROM / end-of-statement error.
The comment layer now takes the parser's separator positions
(CommentLayout.Batches, from formatter.BatchPositions) instead of
re-detecting GO lines, and falls back to hoisting comments when the GO
items of the source and the rendered statements do not line up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
handleDocumentSymbol listed every BatchSeparator as "Statement #n" with a range starting at the top of the file, and statementStartLocation had no position for it, so the statement before a GO line ran to the end of the file. Skip separators in the outline and use their position to bound the preceding statement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
The CLI analyzer, the MCP parse_sql tool and the C binding counted (and typed) every retained BatchSeparator as a statement, so a SQL Server script with GO lines reported more statements than it has. Skip separators in statement_count and statement types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
checkFormattable skipped its "parser split a statement" guard for SQL Server because T-SQL statements need no ';', leaving the withBatches parameter of statementSegments dead. Bound SQL Server parses by the semicolon/GO runs plus GO lines plus the words a T-SQL statement can start with, and drop the dead parameter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
releaseStatement had no case for *ast.Values or *ast.SetOperation, so ReleaseAST never returned the pooled nodes under a standalone VALUES statement or a top-level (or CTE-body) UNION/INTERSECT/EXCEPT to their pools. Release set-operation operands (operand links are cleared, so a node cannot be put twice) and VALUES rows through a shared releaseValues also used for VALUES derived tables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
The AST recorded only "^", so Transpile emitted it unchanged although it is exponentiation in PostgreSQL and bitwise XOR (at two different precedence levels) in MySQL/MariaDB and SQL Server. PostgreSQL prefix operators, SQL Server money literals and Snowflake object constants were also emitted unchanged for dialects that lack them. Record XOR semantics on BinaryExpression.BitwiseXor (fits in existing padding; the node size is unchanged) and rewrite for the target: exponentiation becomes POWER(a, b) outside PostgreSQL; XOR keeps ^ with explicit grouping between the MySQL family and SQL Server, becomes # in PostgreSQL, BITXOR() in Snowflake, bitXor() in ClickHouse, and is an error elsewhere. @, |/ and ||/ become ABS(), SQRT() and CBRT() (an error without CBRT); !!, money literals and object constants are errors in dialects without them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
scripts/dialect-audit and the real-world corpus test carried identical copies of the dropped-word detector. Move it, with both test suites, to internal/dropcheck and use it from both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
The dialect audit added precedence levels (XOR, bitwise/other operators,
^, postfix COLLATE / AT TIME ZONE) and many per-clause dialect probes, so
every operand, table reference and SELECT tail ran through several
non-inlined checks that cannot match. ParserComplexSelect was 27% and
ParserSimpleSelect 24% slower than v1.15.0.
- parseBitwiseExpression returns an operand directly when the next token
is one no operator of any level or dialect consumes (",", ")", FROM,
WHERE, JOIN, ON, AS, ...); the ^ * / + - || levels gain tail functions
so the shortcut composes them exactly as before. The comparison loop
and canBeAlias use the same screen.
- parseComparisonSuffix jumps straight to the comparison operators for
a comparison token, past the predicate (LIKE, IN, IS, ...) probes.
- parsePrimaryExpression screens the PostgreSQL prefix operator,
prefixed string, typed literal, MySQL BINARY and hierarchical
operator probes with inlinable token checks.
- FROM / JOIN table references stop at a table-reference end, and
parseSelectStatement stops at the end of the statement, instead of
probing every modifier and trailing clause.
- parseJoinStrictness and isTSQLStatementStart test the dialect inline.
- TableReference.Only and JoinClause.Comma (both new since v1.15.0)
move into existing padding: TableReference 160 -> 152 bytes,
JoinClause 456 -> 432.
Parse output is unchanged: FormatAST, SQL() and error text for every
testdata statement (per dialect) and a full reflective AST dump are
byte-identical to b14f14c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
…lace Further cuts to the per-operand, per-table and per-statement probes that the dialect audit added: - Table-reference and alias probes (OPENJSON WITH, FOR SYSTEM_TIME, time travel, SAMPLE, flashback, CONNECT BY / START WITH, PIVOT, QUALIFY, MATCH_RECOGNIZE, WINDOW, RETURNING, select extensions, index hints, INDEXED BY) get an inlinable head: a necessary condition (the dialect, the token type, or len(word) <= len(value) for a word match) ANDed with the unchanged original. - equalsUpper screens on length inline; parseTopClause, isSomeQuantifier and the MySQL BINARY probe test the cheap condition first. - parseBitwiseExpression also returns at once before =, <>, >=, ==, the regex operators, NOT, BETWEEN, LIKE, IN, IS, AND and OR, which no tighter level consumes. - UPDATE / DELETE parse a plain target name straight into the statement instead of building, returning and unpacking a TableReference. Parse output is unchanged: FormatAST, SQL() and error text for every testdata statement (per dialect) and a full reflective AST dump are byte-identical to b14f14c. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
The dialect audit (#858) made BenchmarkPerformanceBaseline ~22% slower than main (geomean; SimpleSelect +25%, ComplexQuery +28%) and +19% B/op. GOGC=off showed the same gap, so it was parse-path CPU, accumulated across the pkg-d1, pkg-c1, pkg-b, d2, fu-quoting and w3-boundaries merges. - Move branch-new cold AST fields behind lazily allocated side structs (SelectStatement.Extras, LiteralValue extras, InsertStatement.Extras, TableReference.Modifiers, BinaryExpression/FunctionCall.Extras) with read-only DialectExtras/DialectModifiers accessors and EnsureExtras writers; pool release resets them. No field that exists on main moved. - Remove per-token/per-expression overhead ordinary SQL paid for the new dialect features: comparison-suffix dispatch on clause-ending tokens, cold SELECT/table-modifier probes, prefix/quote metadata checks, CTE quote metadata built eagerly, string escape mode recomputed per literal (now a per-parser flag refreshed in WithDialect/resetDialectState), and helper calls for table references at EOF or ')'. - TestRealWorldCorpus now asserts the shared read-only stand-ins are still empty after the whole corpus round-trips. Interleaved benchstat vs origin/main (n=10, Apple M4): sec/op +2.2% geomean, no sub-benchmark significant (SimpleSelect p=0.36, ComplexQuery p=0.09, CTE p=0.14); allocs/op identical; B/op +7.3% (was +18.9%). Behaviour: corpus 2,788/2,788 with known_failures.txt unchanged; an independent A/B harness found byte-identical parse/SQL()/format/transpile output across 9 dialects (64k + 26k + 721k cases). task check and task test:cbinding pass. Implemented with Codex (gpt-6.1-sol), two rounds; reviewed and verified here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9
… paths - parsePrimaryExpression handles string, number, boolean, placeholder and NULL tokens before the identifier case, whose condition never holds for them, so literals no longer run the keyword-name probes. - The bitwise level also returns at once at <, > and <= when the next token cannot make them <<, >>, <-> or <=>. - parseQualifiedNameQuoted returns a one-part name without the parts array; TRY_CAST / EXTRACT detection screens on length first. - UPDATE / DELETE: the assignment column records name quotes only when they can be non-zero, the parenthesized-target path and the multi-table UPDATE target list get their own functions so their TableReference temporaries leave the common frames, and the SQL Server GO-batch checks run only in that dialect. Parse output is unchanged: FormatAST, SQL() and error text for every testdata statement (per dialect) and a full reflective AST dump are byte-identical to b14f14c. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
- isWord and isKeepClause screen on the word length inline, so a function call no longer makes two calls to look for SEPARATOR and KEEP. - UPDATE / DELETE: the RETURNING, target-alias, OUTPUT and ORDER BY / LIMIT probes screen on the token type or dialect inline, and ONLY is looked for only when a table name follows. Parse output is unchanged: FormatAST, SQL() and error text for every testdata statement (per dialect) and a full reflective AST dump are byte-identical to b14f14c. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
Resolve extract.go: keep the branch's join-group and multi-table DML table collection alongside main's SELECT-alias scoping (#849). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
UpdateStatement and DeleteStatement embedded DMLTargetFields by value, which made every plain UPDATE/DELETE 352 B. The fields now live under an optional Extras pointer (like SelectStatement.Extras) that the parser sets only when one of them is used; TableNameQuote and AliasQuote stay on the statement. Update is 160 B and Delete 176 B. Rendered SQL/FormatAST output is byte-identical over all testdata statements; FullPipeline/update and /delete gain about 1 point vs main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
Reconcile the two perf lines that both started at 0db001a. Keep this branch's parser hot-path work (review fixes, operand/clause-end screens, DML target parsing) where both sides changed the same code, and take the other line's memory moves: LiteralValue, FunctionCall, BinaryExpression, InsertStatement and SelectStatement side structs, TableReference.Values under Modifiers, the rawStringLiterals flag and the dialect stand-in guard in TestRealWorldCorpus. UPDATE/DELETE gain the matching DialectExtras() accessor and are covered by the guard. FormatAST/SQL() output is byte-identical to b14f14c over all 4,597 testdata statements; the AST dump matches apart from moved field paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
|
Claude finished @ajitpratap0's task in 30s —— View job Review of PR #858 (spot-check, not a sign-off)The merge base resolves this time (
|
|
This PR is split into a stack of three for review:
With all three merged, the code is identical to this PR's head 🤖 Generated with Claude Code |
Summary
Audit 2,788 production-style SQL statements across eight dialects and preserve their parse/format round trips with zero listed corpus failures. Retain SQL Server GO batch boundaries and repeat counts, so formatting a procedure, view or trigger followed by another batch does not absorb that batch into the object definition.
Complete the render and performance reviews: fix raw JSON_TABLE/XMLTABLE literal quoting and dialect-sensitive nested hint placement; verify scratch-buffer ownership with concurrent retained ASTs; align the audit harness with the corpus detector. Keep SQL Server transpilation batch-aware, reject GO scripts targeting other dialects, and prevent CLI validation from accepting invalid GO counts through its splitter fallback. Update the unreleased changelog and dialect compatibility results.
Test Plan
Breaking Changes
Known low-priority follow-ups remain: deep (>100-branch) T-SQL IF/ELSE-IF chains, malformed-block parser/splitter fallback differences, and possible further AST layout optimization. No database execution-equivalence claim is made from the syntax/round-trip corpus.