Skip to content

fix(dialect): parse, format and transpile the real-world corpus in all 8 dialects - #867

Merged
ajitpratap0 merged 120 commits into
mainfrom
fix/dialect-audit-fixes
Oct 26, 2026
Merged

ajitpratap0 merged 120 commits into
mainfrom
fix/dialect-audit-fixes

Conversation

@ajitpratap0

Copy link
Copy Markdown
Owner

Summary

Part 2 of 3 splitting #858. It stacks on #866 (corpus and ratchet) and is followed by the small breaking-change PR. This PR holds all the dialect, render and quoting fixes plus the perf work. Render/quoting and parser fixes share about 40 parser files and 47 mixed files, so they ship together; the commits are kept so they can be reviewed one by one.

Result: all 2,788 corpus statements parse, format, re-parse to the same output, and keep every word. known_failures.txt goes from 1,327 entries to 0.

  • Parser fixes per dialect:
    • EXPLAIN is now ExplainStatement.
    • DML: multi-table UPDATE/DELETE, OUTPUT, RETURNING … INTO, MERGE.
    • DDL: CREATE/ALTER/DROP objects and CTAS.
    • SELECT clauses, operators and table factors.
    • SQL Server scripts without semicolons, GO batches (ast.BatchSeparator), BEGIN…END bodies, PL/SQL blocks.
    • SplitStatements agrees with the parser.
  • Render and quoting fixes:
    • The formatter no longer drops clauses.
    • WITH clauses before set operations stay in front.
    • Optimizer hints stay with their SELECT.
    • PostgreSQL @ stays separate from its operand.
    • Identifier quotes are preserved, and string prefixes and escapes are rendered correctly.
  • Transpiler:
    • Strings and identifiers are retargeted to the target dialect.
    • Dialect-specific operators are rewritten, or refused where the target has no equivalent: ^ as power or XOR, PostgreSQL @ |/ ||/, money literals, Snowflake {…}.
    • A GO script sent to another dialect returns an error.
  • AST layout: new dialect fields sit behind optional pointers, so plain statements don't carry them. Read them with DialectExtras() / DialectModifiers() and write with EnsureExtras() / EnsureModifiers().
  • Performance vs main (6 interleaved rounds, M4, 22 benchmarks):
    • time −6.1% (geomean)
    • memory −34.7% B/op (geomean)
    • allocs equal or fewer
    • high-level gosqlx.Parse* 15–19% faster
    • core parser within about 5% (FullPipeline/delete +5.2%, update +3.3%, the rest ≤ +3%)
  • Not in this PR: the last commit takes out the rule that rejects a GO line inside a SQL Server statement. The next PR puts it back as a separate breaking change.

Test Plan

  • task check (fmt, vet, golangci-lint, race tests): exit 0
  • task test:cbinding: exit 0
  • TestRealWorldCorpus: 0 known failures
  • Rendered SQL()/FormatAST output is byte-identical to the pre-perf baseline over all 4,597 testdata statements. The AST dump is identical apart from moved field paths.
  • Independent reviews of the render, boundaries, Codex and perf work; all review findings fixed or listed below

Breaking Changes

These change behaviour for input that main accepts:

  • EXPLAIN <stmt> returns *ast.ExplainStatement. Main returned a *ast.DescribeStatement that lost the statement: EXPLAIN SELECT 1 formatted as DESCRIBE "SELECT".
  • gosqlx.Lint / LintWithDialect return an error wrapping ErrTokenize/ErrSyntax for invalid SQL. Main returned no error. linter.FileResult gains TokenizeError, ParseError and SyntaxError().
  • Raw NUL / Ctrl-Z inside a string literal is an error where the dialect can't write it back. Main dropped or rewrote the byte when formatting.
  • PIVOT/UNPIVOT IN lists require commas between entries and an alias after AS.
  • CLI: format --no-uppercase now lower-cases keywords; main upper-cased them.

Not breaking for any input that works today, because main couldn't parse these inputs at all:

  • BatchSeparator nodes in AST.Statements
  • statement counts that exclude GO
  • transpile errors for GO scripts and for operators with no equivalent in the target

The new AST fields are additions.

Known LOW items, left as is:

  • Comment placement around GO without ;.
  • GO 2 /* c */ is handled differently by the splitter and the parser.
  • MariaDB DELETE history FROM history.
  • JSON operators vs ^ precedence.
  • Quoted type names are written unquoted.
  • The dropped-word check compares word sets, not counts.
  • FormatAST with a different target dialect doesn't apply the ^ precedence logic.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2

Ajit Pratap Singh and others added 30 commits October 22, 2026 17:46
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
@github-actions github-actions Bot added tokenizer Tokenizer / lexer changes ast AST node changes wasm WebAssembly playground labels Oct 26, 2026
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

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


Review of PR #867: dialect fixes, render/quoting, transpile

Scope of this review: I read internal/batch/batch.go and internal/tokscratch/tokscratch.go in full. I did not read the rest of the diff and I did not run any tests or linters. The diff against test/realworld-corpus is 324 files and about +48.9k/−6k lines, though the PR header lists 100 changed files. The PR description claims task check, task test:cbinding and the corpus test pass. I did not verify any of that.

Findings

1. tokscratch will nil-panic if the tokenizer isn't linked in (low).

  • impl is a package-level Func that is nil until pkg/sql/tokenizer's init registers it, and Bytes/String/StringContext call it without a check.
  • Any package that imports tokscratch without transitively importing tokenizer will hit a nil-func panic. A defensive if impl == nil { panic("tokscratch: tokenizer not linked") } would give a clearer message.
  • Likewise, t any is asserted to *tokenizer.Tokenizer in the implementation. A wrong type there should fail loudly and not silently.

2. Borrowed-slice lifetime is the main risk of the perf work (medium, design).

  • The tokscratch contract is "valid until the next tokenize call, Reset, or PutTokenizer", and callers must copy what they keep.
  • This is easy to break later, for example by retaining []TokenWithSpan from a gosqlx.Parse* path or an LSP/cache path. The new concurrent_retained_ast_test.go and scratch_tokens_test.go are the right safety net.
  • Please make sure at least one test retains an AST or tokens across a second parse on the same pooled tokenizer, then runs under -race. Also confirm that no AST node holds a Word value that points into the scratch buffer. The doc says token strings are input substrings, so this looks safe.

3. batch.Line GO detection (looks correct, a few edge cases to confirm).

  • Position checks (End.Column-Start.Column != 2, single line, previous token on an earlier line) are sound and match sqlcmd's "GO alone on its line" rule.
  • Count guards strconv.Atoi overflow through the error return, so GO 99999999999999999999 is treated as an invalid count. Good.
  • Edge cases worth a test, if not already covered:
    • GO after a /* ... */ comment on the same line. Comments are probably not tokens, so tokens[i-1].End.Line may mistake GO for being alone on its line.
    • GO -- comment on the same line.
    • CRLF line endings.
    • Tokens that span multiple lines, such as a multi-line string ending on the line before GO. That should work, since End.Line is used.
    • go in lowercase. EqualFold handles it.
  • The PR notes that GO 2 /* c */ is handled differently by the splitter and the parser. SplitStatements and the parser disagree on that input, which defeats the "SplitStatements agrees with the parser" goal for it. It is listed as a known LOW item, so just make sure it is tracked in an issue.

4. Breaking changes are documented, but they matter for the version bump.

  • gosqlx.Lint/LintWithDialect now return an error for invalid SQL, and format --no-uppercase now lower-cases keywords. Callers that relied on the old behaviour will break silently or change output.
  • EXPLAIN now returns *ast.ExplainStatement, and the PIVOT/UNPIVOT IN syntax is stricter. Type switches over DescribeStatement will break.
  • The description says a follow-up PR carries the GO-inside-a-statement breaking change. Since this PR already has user-visible breaks, the CHANGELOG should say clearly which release they ship in. Merging them in a minor release goes against semver expectations.

5. AST layout change (DialectExtras()/EnsureExtras()).

  • Moving fields behind optional pointers saves memory, which matches the claimed −34.7% B/op. It is an API-breaking move for anyone reading the old fields directly. The PR says "the new AST fields are additions", so check that no existing exported field moved. The perf section says "AST dump identical apart from moved field paths", which suggests some did.
  • Pooled nodes must clear and nil these pointers on release. Otherwise stale Extras leak between reuses. Please confirm the Release* paths reset them and that a pool-reuse test covers it.

6. Transpiler refusal behaviour is good.

  • Returning errors for GO scripts and for operators with no target equivalent (^, @, |/, money literals) is the safe choice over silently producing wrong SQL.
  • The known gap is that FormatAST with a different target dialect doesn't apply the ^ precedence logic. That can produce semantically different SQL (power vs XOR precedence), so consider returning an error there instead of leaving it as a LOW item.

7. Dropped-word check compares word sets, not counts (known LOW). A formatter that drops one of several repeated words (for example one AND among several) would pass. Switching to a multiset comparison is cheap and would make the "keeps every word" guarantee real.

8. Process.

  • 106 commits and about 49k lines in one PR is hard to review. The description's plan to keep commits reviewable is sensible, but a large share of the diff is generated or testdata (known_failures.txt alone is about −1,300 lines).
  • The performance claims (time −6.1%, memory −34.7%) come from the author's M4 laptop. performance_baselines.json is the CI gate, so check CI for regressions in FullPipeline/delete (+5.2%) and update (+3.3%) under the repo's tolerance.

Summary

The code I read (batch, tokscratch) is clean, well documented and defensively written. The main risks are the borrowed-token-buffer lifetime contract, pointer-reset on pooled nodes, and the breaking-change release strategy. I can't vouch for the parser, formatter and transpiler changes, which are the bulk of the PR. A reviewer who knows those areas should go through them, with the corpus ratchet and task test:race as the backstop.

Ajit Pratap Singh and others added 11 commits October 27, 2026 03:03
… rune

Remove a comment that described a check this branch does not contain,
report malformed and duplicate known_failures.txt entries instead of
skipping them, note that statement indexes shift when a corpus file
changes, and truncate report text on rune boundaries.

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

Memoise failed Oracle q-quote searches per closing byte so unterminated
literals cost one scan in total, and cap the MySQL DELIMITER token at 32
bytes (longer lines are ordinary text).

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

- Optimizer hints: occurrence signatures are computed once per statement and
  keyword and shared by every hint; source/rendered signatures are compared
  once per keyword (occMatcher); the statement is written with one
  strings.Builder. Placement semantics and ErrUnplaceableComment refusals are
  unchanged.
- locateComment finds the item by binary search instead of a linear scan.
- The parser records the source start of every top-level statement in
  ast.AST.StatementStarts (reset on release, preallocated with the pooled
  AST). CommentLayout.Starts (filled via formatter.StatementPositions at both
  callers) cuts the source at those starts, so procedure/trigger bodies with
  ';' and T-SQL statements without ';' keep their comments. Disagreement falls
  back to the documented hoist; nil Starts keeps the ';' split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
… subtrees, honour release mode

- MERGE: write the derived-table column alias list after SourceAlias in
  ast and formatter renderers.
- Children: CreateTableStatement (ENGINE clauses and args, CLONE point,
  partition bound, options, PERIOD identifiers), CreateMaterializedView
  (engine clauses/options/column defs), SelectStatement (TOP, ARRAY JOIN).
  TableOption gains ValueArgs so parenthesized ENGINE arguments are
  walkable while Value keeps the rendered text.
- AST.SQL()/stmtSQL fall back to ToSQL() then TokenLiteral() instead of
  skipping ToSQL-only statements (sequences, unsupported).
- CREATE TABLE release helpers honour releaseDrawn (#556).

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

- Outside SQL Server, SET/USE/CALL/COPY/VACUUM/ANALYZE/LOCK/DO/GRANT/
  REVOKE and closed CREATE/ALTER/DROP kinds end at a statement-starting
  word on a new line (SET/USE also mid-line); the missing ';' is then
  an error, as after "SELECT 1\nSELECT 2", unless a transaction
  statement follows on its own line.
- Transaction statements are checked against a per-dialect grammar
  (PostgreSQL, MySQL/MariaDB, SQL Server, Oracle, SQLite, Snowflake,
  ClickHouse, BigQuery; generic = union) and end where it ends, so
  trailing words error and the next line's statement is parsed on its
  own. END/ABORT treat "generic" like no dialect.
- <=> as a comparison only in MySQL/MariaDB (PostgreSQL keeps pgvector's
  operator); == only in SQLite and ClickHouse.
- T-SQL: unclosed BEGIN blocks, an END that closes nothing, and IF/WHILE
  without a body or ELSE without a statement are errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
# Conflicts:
#	pkg/gosqlx/realworld_corpus_test.go
…t review fixes

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

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

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


Review of PR #867

Scope: I read internal/batch/batch.go and internal/tokscratch/tokscratch.go in full. I did not read the other ~98 changed files (parser, formatter, transpiler, AST). I did not run tests, linters or benchmarks. The pass/fail claims in the PR description are unverified.

Findings

1. tokscratch calls a nil impl with no guard (low). tokscratch.go:43-65

  • impl stays nil until pkg/sql/tokenizer's init registers it. Bytes, String and StringContext call it directly.
  • A package that imports tokscratch without linking tokenizer gets a bare nil-func panic. A check like if impl == nil { panic("tokscratch: tokenizer not linked") } would give a clearer message.
  • Inside the registered func, the t any → *tokenizer.Tokenizer assertion should use the two-value form and fail with a clear message. I did not read that code, so check it.

2. Borrowed token buffer lifetime (medium, design).

  • The contract says the slice is valid only until the next tokenize call, Reset or PutTokenizer. A retained slice would be silently corrupted, with no panic, and only -race or a targeted test would catch it.
  • The doc says token strings are input substrings, so retained ASTs should be safe.
  • Please make sure a test parses twice on the same pooled tokenizer, keeps the first AST, and compares it after the second parse, under -race. concurrent_retained_ast_test.go looks aimed at this; I did not read it.
  • Check that every tokscratch caller copies the tokens before they escape. That includes the LSP and any caching path.

3. batch.Line GO detection (batch.go:58-79).

  • The shape checks look sound: an unquoted identifier with EqualFold "GO", a single line, width 2, and the previous token ending on an earlier line.
  • Count rejects non-digits and an Atoi overflow, so GO 99999999999999999999 is invalid.
  • The "alone on its line" rule depends on comments not being tokens. Please confirm tests exist for these inputs:
    • /* c */ GO, where the previous token may be on an earlier line and the comment is invisible.
    • GO -- c.
    • CRLF line endings.
    • A multi-line string or comment ending just before GO.
    • GO as the first token (i == 0).
  • Line does not require Start.Column == 1, so GO indented, or GO after a leading block comment on its own line, is accepted. That is the same as sqlcmd, but it is worth a test.
  • The PR already lists GO 2 /* c */ as splitter and parser disagreeing. That cuts against the "SplitStatements agrees with the parser" claim. Please track it in an issue.

4. Breaking changes and versioning.

  • Breaking changes in this PR:
    • EXPLAIN now returns *ast.ExplainStatement.
    • gosqlx.Lint / LintWithDialect now return an error for invalid SQL.
    • Raw NUL / Ctrl-Z in a string literal is now an error.
    • PIVOT/UNPIVOT IN syntax is stricter.
    • format --no-uppercase now lower-cases keywords.
  • The CHANGELOG should say which release carries these. Shipping them in a minor release goes against semver expectations. At the least, call them out under a "Breaking" heading.

5. AST layout (DialectExtras() / EnsureExtras()).

  • The description says the new fields are additions, and the perf section says the AST dump is identical "apart from moved field paths". Those two statements disagree on whether existing exported fields moved. If any did, that is an API break for direct field readers and needs a CHANGELOG entry.
  • Pooled nodes must nil the extras/modifiers pointers in their Release* paths. Otherwise stale dialect data leaks between reuses. Please confirm a pool-reuse test covers this: parse a dialect-specific statement, release it, then parse a plain one and assert the extras are nil.

6. Transpiler.

  • Refusing to translate GO scripts and operators with no target equivalent is the right call.
  • FormatAST to a different target dialect not applying the ^ precedence logic can silently change semantics. Power and XOR bind differently, so I'd return an error there rather than leave it as a LOW item.

7. Dropped-word check compares word sets, not counts. A formatter that drops one of several repeated tokens, such as one AND, would pass. A multiset comparison is cheap and would make the "keeps every word" guarantee real.

8. Process and performance.

  • The diff is very large (the header says 100 files, but the diff against the base is ~324 files). The commit-by-commit review approach is reasonable.
  • The benchmarks come from one M4 machine. Check CI against performance_baselines.json for FullPipeline/delete (+5.2%) and update (+3.3%).

Summary

The two small files I read are clean, documented and defensive. The main risks are the borrowed-buffer lifetime, pointer reset on pooled nodes, the "moved field paths" ambiguity, and how the breaking changes ship. The parser, formatter and transpiler changes are the bulk of the PR and still need a reviewer who knows those areas, with the corpus ratchet and task test:race as the backstop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
@ajitpratap0
ajitpratap0 changed the base branch from test/realworld-corpus to main October 26, 2026 22:08
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

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


Review of PR #867

Scope: I read internal/batch/batch.go and internal/tokscratch/tokscratch.go in full. I did not read the rest of the diff (parser, formatter, transpiler, AST, CLI). I ran no tests, linters or benchmarks, so the pass/fail claims in the description are unverified. The earlier automated reviews covered the same two files, so most points below repeat theirs, with a few corrections.

Findings

1. tokscratch nil impl (low). internal/tokscratch/tokscratch.go:43-65

  • impl is nil until pkg/sql/tokenizer's init calls Register. Bytes, String and StringContext call it with no check.
  • A package that imports tokscratch without linking tokenizer gets a bare nil-func panic. A one-line guard with a clear message would help.
  • I did not read the registered func, so I can't say whether its t any assertion uses the two-value form. Check that it does.

2. Borrowed token buffer lifetime (medium).

  • The slice and its Word values are valid only until the next tokenize call, Reset or PutTokenizer. A retained slice would be corrupted silently.
  • The package doc says callers must copy and never return the slice to a user. Please confirm that every caller outside the parser's preprocess step honours this, especially LSP and any caching path.
  • pkg/gosqlx/concurrent_retained_ast_test.go looks like the right test. I did not read it. It should parse twice on the same pooled tokenizer, keep the first AST, compare it after the second parse, and run under -race.

3. batch.Line (batch.go:58-79).

  • The shape checks are sound: unquoted identifier equal to "GO" (case-insensitive), single line, width 2, previous token ends on an earlier line.
  • Count rejects non-digits and overflow, so GO 99999999999999999999 is invalid.
  • The "alone on its line" rule relies on comments not being tokens. If they aren't, /* c */ GO is accepted. The same goes for an indented GO. That matches sqlcmd, but it should have tests. internal/batch/batch_test.go is only 48 lines, so check that it covers:
    • GO after a comment on the same line
    • GO -- c
    • CRLF line endings
    • GO as the first token
    • GO 0 and GO 1.5
  • The line-75 check rejects GO followed by a token on the same line. GO 2 /* c */ is already listed as a known gap, and I'd track it in an issue. It cuts against the claim that SplitStatements agrees with the parser.

4. Breaking changes and versioning.

  • Breaking changes in this PR:
    • EXPLAIN now returns *ast.ExplainStatement.
    • Lint and LintWithDialect now return an error for invalid SQL.
    • Raw NUL and Ctrl-Z in string literals are now errors.
    • PIVOT/UNPIVOT IN syntax is stricter.
    • format --no-uppercase now lower-cases keywords.
  • Make sure the CHANGELOG has a clear "Breaking" section naming the target release. Shipping these in a minor release goes against semver expectations.

5. AST layout.

  • The description says the new fields are additions. The perf section says the AST dump is identical "apart from moved field paths". If any existing exported field moved behind DialectExtras() or DialectModifiers(), that is an API break and needs a CHANGELOG entry.
  • Every Release* path for pooled nodes must nil the extras and modifiers pointers. I'd want a pool-reuse test: parse a dialect-specific statement, release it, parse a plain one, and assert the extras are nil.

6. Transpiler. Refusing GO scripts and operators with no target equivalent is the safe choice. FormatAST to a different dialect not applying the ^ precedence logic can change semantics silently, because power and XOR bind differently. I'd return an error there and not leave it as a LOW item.

7. Dropped-word check. It compares word sets, not counts. A formatter that drops one of several repeated words, such as one AND, would pass. A multiset comparison is cheap and would make the "keeps every word" guarantee real.

8. Process and performance.

  • The PR header says 100 files, but the diff against the base is much larger. The commit-by-commit review plan is reasonable.
  • The benchmarks come from one M4 laptop. Check CI against performance_baselines.json for FullPipeline/delete (+5.2%) and update (+3.3%).

Summary

The two small files I read are clean and documented. The main risks are the borrowed-buffer lifetime contract, pointer reset on pooled nodes, the "moved field paths" ambiguity, and how the breaking changes ship. The parser, formatter and transpiler changes are the bulk of the PR and still need a reviewer who knows those areas. The corpus ratchet and task test:race are the backstop.

@ajitpratap0

Copy link
Copy Markdown
Owner Author

Pre-merge review and fixes

Four independent reviews ran over the whole stack before merging: parser/tokenizer, AST/rendering, formatter/transform/APIs, and security/DoS. Every HIGH finding, and every MEDIUM that main doesn't have, is fixed on this branch:

  • Formatter comment layer (c6834dac)
    • Optimizer-hint placement was quadratic: 140 KB of hints took 4.3 s and 2.6 GB. It now takes 13 ms.
    • Comment lookup was O(comments × statements). It is now a binary search.
    • Comments are now matched to the parser's statement start positions (new AST.StatementStarts). A procedure or trigger body, or a T-SQL statement without ;, can no longer attach a comment to the wrong statement. If the positions disagree, the comments go to the top as before, never next to the wrong statement.
  • Splitter (81eee98c)
    • An unterminated Oracle q'… made splitting quadratic: 240 KB took 1.7 s, now 10 ms.
    • MySQL DELIMITER tokens are capped at 32 bytes.
  • Parser (19b36aad)
    • Outside SQL Server, a utility statement (SET, USE, GRANT, COPY, …) without ; swallowed the next line's statement, hiding it from walkers. That is now an error.
    • Transaction statements are checked against a per-dialect grammar.
    • <=> is accepted only in MySQL, MariaDB and PostgreSQL. == is accepted only in SQLite and ClickHouse.
    • An unbalanced T-SQL BEGIN/END, and a bare IF or WHILE, are errors.
  • AST (32d76349)
    • MERGE keeps the source's column alias list.
    • Children() now reaches CREATE TABLE and materialized-view ENGINE clauses, CLONE … AT, partition bounds, SELECT TOP and ARRAY JOIN. A reflection test over the corpus enforces this.
    • AST.SQL() no longer drops sequence statements.
    • DDL release helpers honour the release mode.
  • Small items: parse excludes GO lines from its statement count. CHANGELOG notes cover the fingerprint change for quoted identifiers, --no-uppercase being partial, and the stricter utility-statement and transaction rules.

Verification: the security review fuzzed about 813k mutated inputs and found no panics. Every scanner payload that main caught (93 payloads × 9 dialects) is still caught. Parser benchmarks are flat. task check and task test:cbinding pass.

Known limitations, documented in the CHANGELOG:

  • A missing ; after a statement whose body can hold statements (CREATE TRIGGER/FUNCTION, PREPARE … AS) still folds the next statement in.
  • A second statement on the same line as GRANT or COPY is not split off.
  • Pre-existing and unchanged: the transform LIMIT rules ignore TOP/FETCH; splitter gaps with MySQL backslash escapes and parenthesized CREATE RULE bodies.

@ajitpratap0 ajitpratap0 reopened this Oct 26, 2026
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

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


Review of PR #867

Scope: This is a targeted review, not a full one. I read internal/tokscratch/tokscratch.go and checked how widely the dialect extras are used in pkg/sql/ast. I did not read the parser, formatter or transpiler diffs, and I ran no tests or benchmarks. The PR's pass/fail claims are unverified. Earlier automated reviews covered the same files, so I only repeat what still matters.

Findings

1. tokscratch calls impl without a nil check (low). internal/tokscratch/tokscratch.go:43-65

  • impl stays nil until pkg/sql/tokenizer's init registers it. Bytes, String and StringContext call it directly.
  • Any package that imports tokscratch without linking tokenizer gets a bare nil-func panic. A guard like if impl == nil { panic("tokscratch: tokenizer not linked") } gives a clearer message.
  • Check that the registered func asserts t.(*tokenizer.Tokenizer) with the two-value form. CLAUDE.md requires that style, and I did not read that code.

2. Borrowed token buffer (medium, design).

  • The returned slice is valid only until the next tokenize call, Reset or PutTokenizer. A caller that retains it gets silent corruption.
  • Confirm that no caller outside the parser's preprocess step retains it, especially LSP and any cache. pkg/gosqlx/concurrent_retained_ast_test.go looks like the right safety net. It should parse twice on the same pooled tokenizer, keep the first AST, compare it after the second parse, and run under -race.

3. Pooled nodes and the new optional pointers.

  • There are about 150 EnsureExtras / DialectExtras uses in pkg/sql/ast. Every Release* path must nil the extras and modifiers pointers.
  • Please add or confirm a pool-reuse test. It should parse a dialect-specific statement, release it, parse a plain statement from the same pool, and assert the extras are nil.
  • The PR says the new fields are additions, but the perf section mentions "moved field paths". If any existing exported field moved, that is an API break and needs a CHANGELOG entry.

4. batch.Line edge cases (internal/batch/batch.go).

  • internal/batch/batch_test.go is only 48 lines. Check it covers:
    • /* c */ GO
    • GO -- c
    • CRLF line endings
    • GO as the first token
    • GO 0 and GO 1.5
    • GO after a multi-line string
  • GO 2 /* c */ is handled differently by the splitter and the parser, which undercuts "SplitStatements agrees with the parser". Track it in an issue.

5. Transpiler FormatAST with ^ precedence. FormatAST with a different target dialect does not apply the ^ precedence logic. Power and XOR bind differently, so the output can silently change meaning. Returning an error there would be safer than leaving it as a LOW item.

6. Dropped-word check. It compares word sets, not counts. A formatter that drops one of several repeated words, such as one AND, would pass. A multiset comparison is cheap and makes the "keeps every word" guarantee real.

7. Breaking changes and versioning.

  • Breaking changes in this PR:
    • EXPLAIN now returns *ast.ExplainStatement.
    • Lint and LintWithDialect now return an error for invalid SQL.
    • Raw NUL and Ctrl-Z in string literals are now errors.
    • PIVOT/UNPIVOT IN syntax is stricter.
    • format --no-uppercase now lower-cases keywords.
    • Utility statements without ; are now errors outside SQL Server.
  • The CHANGELOG should give these a "Breaking" heading and name the target release. Shipping them in a minor release goes against semver expectations.

8. Performance. The numbers come from one M4 machine. Check CI against performance_baselines.json for FullPipeline/delete (+5.2%) and update (+3.3%).

Summary

The code I read is clean and well documented. The pre-merge fuzzing, the security review and the corpus ratchet reduce risk. Before merge, confirm the pointer-reset test (3), the buffer-lifetime test (2) and the CHANGELOG release target (7). The parser, formatter and transpiler changes still need a reviewer who knows those areas.

…ound

The 1 s bounds failed on race-instrumented macOS and Windows runners
although the splitter is linear. Time n and 4n and fail above 10x:
linear work grows ~4x, the old quadratic code measured 16-24x.

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

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

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


Review of PR #867

Scope: This is a targeted review. I read internal/tokscratch/tokscratch.go, internal/batch/batch_test.go and the registration site in pkg/sql/tokenizer/scratch.go. I did not read the parser, formatter, transpiler or AST diffs. I ran no tests, linters or benchmarks, so the pass/fail claims in the description are unverified.

Corrections to earlier automated reviews

  • The t any assertion is already safe. pkg/sql/tokenizer/scratch.go:30 uses t, ok := tv.(*Tokenizer), which is the two-value form CLAUDE.md requires.
  • batch_test.go is more complete than earlier reviews implied. It covers GO, go 3 -- repeat, [GO], "GO", 'GO', GO 1.5, GO 0, GO SELECT 1, and tokens without spans.

Findings

1. tokscratch has no nil guard on impl (low). internal/tokscratch/tokscratch.go:50-65

  • impl is nil until pkg/sql/tokenizer's init registers it. A package that imports tokscratch without linking tokenizer gets a bare nil-func panic.
  • A one-line if impl == nil { panic("tokscratch: tokenizer not linked") } would give a clearer message. This is optional, since only internal packages can import it.

2. Borrowed token buffer lifetime (medium, design).

  • The returned slice is valid only until the next tokenize call, Reset or PutTokenizer. A retained slice is silently corrupted. The doc comment states the contract well.
  • pkg/gosqlx/concurrent_retained_ast_test.go looks like the right safety net. Please confirm it keeps the first AST, parses again on the same pooled tokenizer, compares the first AST afterwards, and runs under -race.
  • Please confirm no caller outside the parser's preprocess step retains the slice, especially the LSP and any caching path.

3. batch.Line edge-case tests (low).

  • The existing tests cover the main shapes. They do not cover these cases:
    • /* c */ GO and an indented GO. These are accepted if comments are not tokens. That matches sqlcmd, but it should be pinned by a test.
    • CRLF line endings.
    • GO after a multi-line string or comment.
  • GO 2 /* c */ is handled differently by the splitter and the parser. That conflicts with the "SplitStatements agrees with the parser" goal for this input, so please track it in an issue.

4. Pooled nodes and the optional pointers (medium).

  • There are about 150 EnsureExtras / DialectExtras uses in pkg/sql/ast. Every Release* path must nil the extras and modifiers pointers, or stale dialect data leaks between pooled reuses.
  • Please add or confirm a pool-reuse test. It should parse a dialect-specific statement, release it, parse a plain one, and assert the extras are nil.
  • The description says "new fields are additions", but the perf section says "moved field paths". If any existing exported field moved, that is an API break and needs a CHANGELOG entry.

5. Transpiler FormatAST with ^ precedence (low-medium). FormatAST with a different target dialect skips the ^ precedence logic. Power and XOR bind differently, so this can silently change meaning. Returning an error would be safer than leaving it as a known LOW item.

6. Dropped-word check (low). It compares word sets, not counts. A formatter that drops one of several repeated words, such as one AND, would pass. A multiset comparison is cheap and makes the "keeps every word" guarantee real.

7. Breaking changes and release target.

  • Breaking changes in this PR:
    • EXPLAIN now returns *ast.ExplainStatement.
    • Lint and LintWithDialect now return errors for invalid SQL.
    • Raw NUL and Ctrl-Z in string literals are now errors.
    • PIVOT/UNPIVOT IN syntax is stricter.
    • format --no-uppercase now lower-cases keywords.
    • Utility statements without ; are now errors outside SQL Server.
    • <=> and == are now restricted to specific dialects.
  • Please make sure the CHANGELOG has an explicit "Breaking" heading that names the target release. Shipping these in a minor release goes against semver expectations.

8. Performance. The numbers come from one M4 machine. Check CI against performance_baselines.json for FullPipeline/delete (+5.2%) and update (+3.3%).

Summary

The code I read is clean and well documented. The pre-merge fuzzing, the security review and the corpus ratchet reduce risk. Before merging, confirm the pooled-node reset test (4), the buffer-lifetime test (2) and the CHANGELOG release target (7). The parser, formatter and transpiler changes still need a reviewer who knows those areas.

@ajitpratap0
ajitpratap0 merged commit e3e01c5 into main Oct 26, 2026
43 checks passed
ajitpratap0 pushed a commit that referenced this pull request Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ast AST node changes cli documentation Improvements or additions to documentation linter lsp parser python security tokenizer Tokenizer / lexer changes transform wasm WebAssembly playground

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant