Skip to content

statement/parser: minimal-paren canonicalization and binlog statement-class gaps - #1134

Merged
morgo merged 32 commits into
block:mainfrom
morgo:parser-restore-skip-redundant-parens
Aug 17, 2026
Merged

statement/parser: minimal-paren canonicalization and binlog statement-class gaps#1134
morgo merged 32 commits into
block:mainfrom
morgo:parser-restore-skip-redundant-parens

Conversation

@morgo

@morgo morgo commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Minimal-paren canonicalization

Closes #1128

Ports pingcap/tidb@52b9a887b3b0: the opt-in format.RestoreSkipRedundantParentheses flag and the MySQL precedence table that decides whether a ParenthesesExpr may be dropped in its position. With the flag set, a + (b * c) restores as a + b * c, while (a + b) * c and a - (b - c) keep their parentheses — a same-precedence right child only drops them for operators that regroup safely. Default restore behaviour is unchanged, since the flag is opt-in.

Five operators need a rule of their own. BETWEEN, IN, LIKE, REGEXP and MEMBER OF look infix, but MySQL gives their operands fixed productions rather than expressions at their own precedence level, so they do not nest the way the table implies: dropping the parentheses either rebinds the expression, since the server reads a = b BETWEEN 1 AND 10 as a = (b BETWEEN 1 AND 10), or emits text it rejects outright, as with a IN (1,2) IN (3,4). Each operand position is held to its own production instead of the operator's precedence: subjects, BETWEEN's bounds and REGEXP's pattern are bit_exprs, so (a + b) IN (1,2) and a BETWEEN (b + 1) AND (c * 2) still minimize, while LIKE's pattern and MEMBER OF's document are simple_exprs, a level only COLLATE reaches. 26 shapes are pinned across the restore and canonicalizer tests, plus 3 pairs that must stay textually distinct.

pkg/statement's expression normalizer uses it, so a CHECK constraint now canonicalizes as CHECK (`a`=1 OR `b`=2 AND `c`=3) rather than the fully-parenthesized CHECK ((`a`=1) OR ((`b`=2) AND (`c`=3))). The existing strip-and-wrap pass stays: it erases the input's parenthesization so the text is a function of the parse tree alone, which is what makes the form canonical. Two shapes still can't converge textually, because MySQL rewrites the expression when storing it — NOT (a > 0 AND b > 0) comes back De Morgan'd, and s REGEXP '^y' as regexp_like(...).

Fix found along the way: the strip-and-wrap pass didn't re-wrap MEMBER OF, quantified comparisons, or COLLATE, so CHECK (a = (1 MEMBER OF (j))) was rendered as a = 1 MEMBER OF (j) — which reads left to right as the different (a = 1) MEMBER OF (j). Pre-existing, but it would have become a collision between the two forms under minimal parens.

Parser MySQL compatibility

pkg/change currently ignores binlog Query events it can't parse. Making that strict needs a parser that recognizes everything MySQL writes to the binary log, so the rest of the commits close the gap against the MySQL 9.6 grammar, measured on the mysql-test corpus (196,996 statements):

Pass rate 90.55% → 99.74%, panics 16 → 0. A 457-probe set of binlog-relevant clause forms went from 119 failing to 0.

Areas closed, roughly in the order the corpus surfaced them:

  • A P0 panic: decimal literals wider than 81 digits crashed MyDecimal.FromString. A strict pkg/change must never panic on input the server accepted.
  • DDL and the type system: INVISIBLE columns and ENGINE_ATTRIBUTE, VECTOR, DEFAULT (expr) as a full expression, all 41 charsets and their collations, tablespaces, logfile groups, spatial reference systems, ALTER VIEW, ALTER INSTANCE, and the remaining partition and LOAD DATA clauses.
  • Access control: the multi-factor, random-password and RETAIN CURRENT PASSWORD surface of CREATE/ALTER USER, plus GRANT ... AS and REVOKE IF EXISTS.
  • Administration: XA, HANDLER, CHECK/CHECKSUM/REPAIR TABLE, plugins and components, resource groups, CLONE, replication administration (CHANGE REPLICATION SOURCE, START/STOP REPLICA, SET PERSIST), and the EXPLAIN/SHOW variants.
  • Expressions: JSON_TABLE and JSON_VALUE, WEIGHT_STRING, ODBC escapes, MATCH ... AGAINST, SELECT ... INTO, INTERVAL() arity, MEMBER with the OF omitted, CAST(x AS DOUBLE PRECISION), and REGEXP's pattern as a full bit_expr rather than a simple_expr'a' REGEXP 'a' + 'b' parses, 'a' LIKE 'a' + 'b' is a syntax error, on the fork and on the server alike.
  • Stored programs, the largest single piece: procedures, functions, triggers and events with their full compound-statement bodies — DECLARE, handlers, cursors, IF/CASE/loops, SIGNAL, labels. pkg/change needs this because a routine body arrives in the binary log as one Query event. Worth 95.62% → 99.34% on its own.
  • 9.x features: LIBRARY DDL, dollar-quoted strings, FLUSH option lists.
  • Keywords follow live MySQL: OPTIMIZER_COSTS, SYSTEM, UNDO and JSON_TABLE become reserved, ~35 unreserved keywords are added, and unreserved keywords are split into the label/non-label sets MySQL uses.

The remaining 506

Not a backlog. Each one was replayed byte-exact against a live MySQL 9.7 through COM_STMT_PREPARE, which parses a statement without executing it: 504 come back ERROR 1064, so MySQL's own grammar rejects them too. The two that returned 1193 short-circuit on an unknown system variable before the server reaches the rest of the text — substituting a variable it does know gives 1064 as well.

They are mysqltest directives (246), literals made of raw sjis/big5/ucs2 test bytes (164), extraction artifacts such as multi-statement records and statements cut mid-literal (59), ANSI_QUOTES DDL (25), and SQL the test files run deliberately to check an error path (12).

So on the criterion that matters for a strict pkg/changedoes the fork parse everything the server parses — this is 100%.

Validation

  • Grammar shapes were validated against live MySQL 8.0.46 and 9.7.0 — clause order, rejection cases, and SHOW CREATE output shapes — rather than inferred from sql_yacc.yy alone. Where the two disagreed I followed the server: JSON_TABLE really is reserved, empty WHILE/REPEAT/LOOP bodies really are rejected, and x MEMBER (doc) really does read as MEMBER OF. Each class has round-trip restore tests in binlog_stmt_gaps_test.go.
  • TestRoundTrip_ExpressionParenShapes runs 28 expression shapes both directions against MySQL 8.0.45: the shape as a person writes it must converge with MySQL's stored form, and the canonical text must apply as DDL and still converge after MySQL stores it. That second direction is what checks the ported precedence table against MySQL's own parser. TestCanonicalExprParensIsFixedPoint and TestCanonicalExprParensKeepsDistinctExpressionsDistinct cover it offline.
  • The grammar generates with zero reported conflicts and the parser-regen CI gate (byte-identical regeneration from parser.y) passes; golangci-lint run reports 0 issues.

🤖 Generated with Claude Code

morgo and others added 6 commits August 15, 2026 23:05
Ports pingcap/tidb@52b9a887b3b0 ("parser, bindinfo: normalize binding
parentheses with AST restore"): the opt-in RestoreSkipRedundantParentheses
restore flag, the RestoreCtx plumbing it needs (ParentBinaryOp,
ParentBinarySide, InUnaryOperation), and the MySQL precedence table that
decides whether a ParenthesesExpr can be dropped in its position.

With the flag set, `a + (b * c)` restores as `a + b * c` while
`(a + b) * c` and `a - (b - c)` keep their parentheses; same-precedence
right children only drop them for operators that regroup safely
(AND/OR/XOR, bitwise and logical), never for arithmetic. Unary operands,
unknown operators, and anything below a kept pair of parentheses are
treated conservatively. Default restore behaviour is unchanged — the flag
is opt-in, so `DEFAULT ('{}')` keeps its parentheses as before.

Adapted for the fork: stdlib wrapped errors, PatternLikeExpr in place of
upstream's PatternLikeOrIlikeExpr, and //nolint:exhaustive on the two
opcode switches that deliberately fall through to a default.

The tests are ours; upstream's cover the flag through binding
normalization, which this fork does not have. Each case asserts the
canonical text, that re-restoring it is a fixed point, and — outside the
deliberate reassociation cases — that the text parses back to the same
expression structure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The expression normalizer stripped every parenthesis and re-wrapped every
operator node, so a CHECK constraint converged with MySQL's stored form as
`CHECK ((`a`=1) OR ((`b`=2) AND (`c`=3)))` — stable, but not DDL anyone
would write. It now renders the same tree with
RestoreSkipRedundantParentheses, giving `CHECK (`a`=1 OR `b`=2 AND `c`=3)`.

The strip-and-wrap pass stays: it is what makes the form canonical, by
erasing the input's parenthesization before anything is rendered. Restore
never invents parentheses, so without it `-(a)` and `-a`, or
`f((a + b))` and `f(a + b)`, would each canonicalize two ways — and MySQL
emits the first of each pair. What the new flag adds is only how much of
the (already canonical) structure has to be spelled out.

Also fixes a pre-existing gap in that pass: MEMBER OF, quantified
comparisons (`= ANY (...)`), and COLLATE were not re-wrapped, so
`a = (1 MEMBER OF (j))` rendered as `a = 1 MEMBER OF (j)`, which reads
left to right as the different `(a = 1) MEMBER OF (j)`. Every node whose
parentheses the renderer reasons about is now wrapped.

Verified against MySQL 8.0.45: TestRoundTrip_ExpressionParenShapes runs 28
expression shapes through both directions of the round trip — the shape as
written must converge with MySQL's stored form, and the canonical text must
apply as DDL and converge after MySQL stores it — which is what checks the
ported precedence table against MySQL's own. Generated-column expressions
get the same treatment. Two shapes are documented as out of reach because
MySQL rewrites the expression itself when it stores it (De Morgan on NOT,
REGEXP into REGEXP_LIKE).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MyDecimal.FromString panicked on any literal whose digits exceeded the
9-word buffer, e.g. SELECT with a 90-digit number — valid MySQL that can
appear in binlogged statements. pkg/change parses every binlog Query
event with no recover, so one such statement killed the process.

Port upstream TiDB's clamping instead: an oversized integer part
returns ErrDataOutOfRange (toDecimal already clamps those to the max
decimal value with a warning, like MySQL), and an oversized fraction is
truncated to the words that remain, now surfaced as a warning too. The
remaining panic branches (empty input, no digits, scientific notation)
are unreachable from the lexer and now return an error instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All of these are valid MySQL that is written to the binary log as Query
events, so pkg/change must at least parse them:

- REPAIR [NO_WRITE_TO_BINLOG|LOCAL] TABLE(S) ... [QUICK][EXTENDED][USE_FRM]
- RENAME TABLES and ANALYZE TABLES plural spellings
- ANALYZE ... UPDATE HISTOGRAM: MANUAL/AUTO UPDATE (8.4+) and USING DATA
- BEGIN/COMMIT/ROLLBACK WORK spellings; START TRANSACTION with
  comma-separated characteristics
- FLUSH USER_RESOURCES / OPTIMIZER_COSTS / RELAY LOGS [FOR CHANNEL] /
  TABLES ... FOR EXPORT
- ALTER DATABASE ... READ ONLY = {0|1|DEFAULT}
- ALTER INSTANCE: ROTATE {INNODB|BINLOG} MASTER KEY, RELOAD TLS FOR
  CHANNEL, {ENABLE|DISABLE} INNODB REDO_LOG, RELOAD KEYRING

New keyword tokens follow live-MySQL reservation status (OPTIMIZER_COSTS,
SYSTEM and UNDO are reserved there; the rest are unreserved), keeping
reserved_words_test green. mysql-test corpus pass rate: 90.55% -> 90.72%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
These statement classes appear in the binary log (or, for XA, in
workloads spirit must detect and refuse) but previously failed to
parse, which blocks making pkg/change strict about unparseable
statements:

- ALTER VIEW (with algorithm/definer/security/check option)
- XA {START|BEGIN|END|PREPARE|COMMIT|ROLLBACK|RECOVER}, including
  JOIN/RESUME/SUSPEND [FOR MIGRATE]/ONE PHASE/CONVERT XID and
  string/hex xids with an optional formatID
- CREATE [OR REPLACE] / DROP SPATIAL REFERENCE SYSTEM
- CREATE/ALTER/DROP [UNDO] TABLESPACE and CREATE/ALTER/DROP
  LOGFILE GROUP with the size/engine/wait option set

mysql-test corpus pass rate: 90.72% -> 91.39% (18274 -> 16953
failures out of 196996 statements).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both switches intentionally rely on default: the first errors on the
invalid zero value, the second prints all size options one way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@morgo
morgo force-pushed the parser-restore-skip-redundant-parens branch from 9f705bf to f376d06 Compare August 16, 2026 05:07
@morgo morgo changed the title statement/parser: canonicalize CHECK and generated-column expressions with minimal parentheses statement/parser: minimal-paren canonicalization and binlog statement-class gaps Aug 16, 2026
@morgo
morgo marked this pull request as ready for review August 16, 2026 05:16
morgo and others added 19 commits August 16, 2026 06:59
Adds the MySQL 8.0.23+ invisible column DDL surface:

- VISIBLE/INVISIBLE as column options (CREATE TABLE, ADD/MODIFY/CHANGE
  COLUMN), stored as ColumnOptionVisibility. SHOW CREATE TABLE emits the
  attribute as /*!80023 INVISIBLE */, which the lexer already unwraps.
- ALTER TABLE ... ALTER COLUMN c SET {VISIBLE | INVISIBLE} as a new
  AlterTableAlterColumnVisibility spec, sharing ast.IndexVisibility with
  the existing ALTER INDEX form.

Also adds ENGINE_ATTRIBUTE (8.0.21+) as a column option and an index
option, alongside the SECONDARY_ENGINE_ATTRIBUTE support that already
existed; the table-level option was already supported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Parses VECTOR and VECTOR(N) as a column type and as a CAST target,
using the server's type code (MYSQL_TYPE_VECTOR, 0xf2). The dimension
restores only when it was written; MySQL applies the 2048 default
server side. Parse-level support only: spirit does not (yet) migrate
tables with VECTOR columns, but pkg/change must recognize the DDL when
it appears in the binlog stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add grammar and AST support for the ACL clauses MySQL 8.0 can write to
the binary log that the fork could not parse:

- IDENTIFIED BY RANDOM PASSWORD (8.0.18+), REPLACE 'current password'
  (8.0.14+), and multi-factor IDENTIFIED ... AND ... chains with
  INITIAL AUTHENTICATION (8.0.27+) on CREATE/ALTER USER
- CREATE USER ... DEFAULT ROLE and ALTER USER ... DEFAULT ROLE
- ALTER USER ADD/MODIFY/DROP n FACTOR and WebAuthn
  INITIATE/FINISH REGISTRATION/UNREGISTER (8.0.27+)
- PASSWORD REQUIRE CURRENT [OPTIONAL] account policy (8.0.13+), plus a
  missing restore case for PASSWORD REQUIRE CURRENT DEFAULT
- SET PASSWORD TO RANDOM / REPLACE / RETAIN CURRENT PASSWORD forms
- GRANT ... AS user [WITH ROLE ...] (8.0.16+) and GRANT role WITH ADMIN
  OPTION
- REVOKE [IF EXISTS] ... [IGNORE UNKNOWN USER] (8.0.30+) and
  REVOKE PROXY

The new GrantAsOpt clause puts AS into the follow set of GRANT user
specs, which LALR-merges into the CREATE USER states and silently
precedence-resolved IDENTIFIED WITH plugin AS 'hash' toward the bare
plugin form (rule precedence from WITH beats the low AS token
precedence). The bare-plugin rule now carries %prec empty so the shift
wins and the hash form parses in every position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MySQL 8.0.13+ expression defaults take any expression inside the
parentheses (sql_yacc.yy uses expr; invalid constructs like subqueries
are rejected semantically). The fork only accepted a whitelist —
parenthesized literals, identifiers, and builtin function calls — so
defaults such as (a + 1), (CAST('[]' AS JSON)),
(CURRENT_DATE + INTERVAL 1 YEAR), (DATE '2020-01-01'), and ((1+2))
failed to parse.

DefaultValueExpr now takes '(' Expression ')' wrapped in
ParenthesesExpr, replacing the parenthesized whitelist alternatives.
Two deliberate restore-shape changes follow, both matching what MySQL
itself prints for expression defaults:

- DEFAULT (NOW()) keeps its shape instead of folding to
  CURRENT_TIMESTAMP; only the bare timestamp-default form folds.
- String literals inside expression defaults restore with their
  charset introducer, e.g. DEFAULT (_UTF8MB4'{}').

BuiltinFunction splits into a bare variant for DefaultValueExpr while
KILL keeps accepting the parenthesized form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fork inherited TiDB's split between "supported" charsets (the 7 TiDB
could store) and "known" charsets (the full MySQL registry), and several
grammar paths errored on the known-but-unsupported set. A parser that
only parses and restores DDL has no reason to reject a charset it will
never encode, so:

- GetCharsetInfo now resolves any charset in the registry without error,
  which unblocks CHARACTER SET clauses (columns, tables, databases,
  CONVERT ... USING, ALTER ... CONVERT TO) and literal introducers such
  as _ucs2 X'0078' for all 41 charsets.
- GetDefaultCollationLegacy delegates to GetDefaultCollation instead of
  a hardcoded six-charset switch (the introducer gate).
- utf8mb3_* collation spellings alias to the registry's utf8_* names by
  prefix instead of a 3-name switch; all 28 spellings MySQL 8.0 prints
  now resolve, consistent with the utf8mb3 -> utf8 charset mapping.
- Add the 14 utf8mb4 locale collations missing from the registry
  (utf8mb4_{nb,nn,sr_latn,bs,bg,gl,mn_cyrl}_0900_{ai_ci,as_cs}, IDs
  310-323, verified against MySQL 8.0.46 information_schema).
- CHAR/VARCHAR/NCHAR/NVARCHAR take opt_charset_with_opt_binary like
  MySQL's grammar, so the BYTE/ASCII/UNICODE column-attribute shorthands
  parse (CHAR(10) BYTE == BINARY(10), ASCII == latin1, UNICODE == ucs2;
  restore shapes verified against SHOW CREATE TABLE), in column types
  and in CAST targets.

Unknown names are still rejected with the same errors as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MySQL 9.x additions relevant to binlogged statements, validated against
a live 9.7.0 server:

- CREATE LIBRARY [IF NOT EXISTS] [schema.]name [COMMENT 'x']
  LANGUAGE JAVASCRIPT AS <body>, ALTER LIBRARY name COMMENT 'x', and
  DROP LIBRARY [IF EXISTS] name, with the same clause order and
  rejections as the server (no OR REPLACE, no bare ALTER LIBRARY).
- Dollar-quoted string literals ($tag$ ... $tag$), which library and
  routine bodies use. The lexer treats a $tag$ opener as a string that
  keeps quotes and backslashes verbatim and anything else starting
  with $ as an ordinary identifier, so $-identifiers still work.
  Restore normalizes the body to a standard quoted string.
- LIBRARY as a GRANT/REVOKE object type (GRANT EXECUTE ON LIBRARY
  db.lib TO u). A database literally named library still parses: the
  token after it picks the interpretation, as in MySQL.
- FLUSH with a comma-separated option list (FLUSH STATUS,
  USER_RESOURCES). The table form stays exclusive, matching MySQL's
  grammar; extra targets land in FlushStmt.ExtraTargets.

JavaScript stored procedures and functions remain out of scope with
the rest of the stored-program grammar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four small grammar gaps that appear in binlog-relevant DDL, found by
the probe corpus:

- NOT SECONDARY column option (HeatWave column exclusion), with a new
  SECONDARY unreserved keyword.
- CREATE TABLE ... START TRANSACTION, the form MySQL 8.0.21+ writes to
  the binary log in place of CREATE TABLE ... SELECT under row-based
  replication.
- Plain WITH CHECK OPTION on CREATE/ALTER VIEW (equivalent to WITH
  CASCADED CHECK OPTION, which SHOW CREATE VIEW also normalizes to).
- Derived table column alias lists: FROM (SELECT ...) dt (c1, c2).
  The AST support already existed for LATERAL; this wires up the
  non-LATERAL form.

Probe failures drop 59 -> 54; everything left is stored programs or
deliberately-excluded admin statements. Corpus pass rate 93.10% ->
93.14% with zero regressions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Appeases gocritic ifElseChain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The parser now understands CREATE TABLE ... START TRANSACTION (the
binlog form of CTAS under row-based replication), so the GTID client's
parsed-statement path started promoting the pending GTID at that
QueryEvent — mid-group, before the CTAS row events are buffered. The
deferral previously happened by accident, because the statement failed
to parse.

Make it deliberate: extractTablesFromDDLStmts now reports whether a
statement opens a transaction group (BEGIN / START TRANSACTION, or a
CreateTableStmt with the StartTransaction form), and processQueryEvent
leaves the pending GTID pending for those. The group's own XIDEvent
promotes it, exactly as before the parser learned the syntax.

The position-based client is unaffected: a position is a byte offset
("everything before this is buffered"), so advancing it at a mid-group
QueryEvent was never a hazard there.

TestGTIDClientUnparseableQueryPromotionOrdering used the CTAS form as
its canonical unparseable statement; group 1 now pins the parsed path
and the test is renamed TestGTIDClientQueryPromotionOrdering. Group 2
(CREATE TRIGGER) still pins the unparseable path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the admin statement classes MySQL supports but TiDB never
implemented, all validated shape-by-shape against a live 8.0.46:

- CHECK TABLE (FOR UPGRADE/QUICK/FAST/MEDIUM/EXTENDED/CHANGED) and
  CHECKSUM TABLE (QUICK/EXTENDED)
- The HANDLER interface: OPEN [AS], CLOSE, and all three READ shapes
  (natural scan, index scan, index value lookup) with WHERE/LIMIT
- PURGE BINARY|MASTER LOGS TO/BEFORE (MASTER restores as BINARY)
- IMPORT TABLE FROM (SDI import)
- CACHE INDEX ... IN and LOAD INDEX INTO CACHE ... [IGNORE LEAVES],
  including PARTITION (ALL | list) and INDEX|KEY (list) entries
- INSTALL/UNINSTALL PLUGIN and COMPONENT (with the 8.0.33+ SET clause)
- CREATE/ALTER/DROP SERVER with generic OPTIONS name/value lists
- CREATE/ALTER/DROP/SET RESOURCE GROUP (VCPU ranges, THREAD_PRIORITY,
  ENABLE/DISABLE, FORCE)
- CLONE LOCAL / CLONE INSTANCE FROM user@host:port
- LOCK INSTANCE FOR BACKUP and UNLOCK INSTANCE
- LOCK TABLES gains the [AS] alias and LOW_PRIORITY WRITE lock type

New tokens: BEFORE (reserved, matching MySQL) and CLOSE plus 28
unreserved keywords; goyacc still reports zero conflicts.

Corpus: failures 13512 -> 12697 (93.55% pass), 0 panics, and a
set-diff against the pre-series baseline shows zero regressions.
All 28 admin-statement probes now pass; the 26 remaining probe
failures are all stored programs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the replication admin statement classes that binlog streams and
mysql-test corpora regularly contain:

- CHANGE REPLICATION SOURCE TO / CHANGE MASTER TO (deprecated spelling
  parses to the same node and restores modern), with FOR CHANNEL
- CHANGE REPLICATION FILTER with table/db/wildcard/rewrite-pair lists
- START/STOP REPLICA (and SLAVE) with IO_THREAD/SQL_THREAD lists, UNTIL
  clauses, USER/PASSWORD/DEFAULT_AUTH/PLUGIN_DIR connection options
- START/STOP GROUP_REPLICATION
- RESET REPLICA [ALL], RESET MASTER [TO n], RESET BINARY LOGS AND GTIDS
  [TO n] (RESET MASTER restores as the modern spelling)
- RESET PERSIST [[IF EXISTS] var]
- SET PERSIST / SET PERSIST_ONLY as VariableAssignment scopes, restored
  as @@persist. / @@PERSIST_ONLY.; the lexer scope-prefix list now also
  accepts those scopes with quoted variable names

The space-separated connection option names are restricted to the set
MySQL itself allows (USER/PASSWORD/DEFAULT_AUTH/PLUGIN_DIR) because a
generic identifier there is ambiguous with the thread-type list.

RESET and UNTIL are added as unreserved keywords, matching MySQL (both
verified usable as identifiers on 8.0.46). All statements live-validated
against MySQL 8.0.46 and 9.7.0. Corpus failures drop 12,697 -> 11,862
with zero regressions; grammar remains conflict-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EXPLAIN:
- FORMAT=TREE (new TREE keyword) alongside TRADITIONAL/JSON
- EXPLAIN [ANALYZE] FORMAT=... INTO @var (MySQL 8.3+), stored on
  ExplainStmt.IntoVar and restored in place
- EXPLAIN ANALYZE FORMAT='<string>' for restore round-trips

SHOW:
- SHOW CREATE PROCEDURE/FUNCTION/TRIGGER/EVENT/LIBRARY
- SHOW PROCEDURE/FUNCTION/LIBRARY STATUS with LIKE/WHERE
- SHOW PROCEDURE/FUNCTION CODE
- SHOW BINARY LOGS (and deprecated SHOW MASTER LOGS)
- SHOW BINLOG EVENTS [IN 'log'] [FROM pos] [LIMIT ...]
- SHOW REPLICAS (and deprecated SHOW SLAVE HOSTS)
- SHOW REPLICA STATUS FOR CHANNEL 'name'
- SHOW WARNINGS/ERRORS LIMIT (moved out of the LIKE/WHERE-filterable
  set: MySQL rejects LIKE there, and COUNT(*) WARNINGS/ERRORS now
  restores its COUNT(*) prefix)
- SHOW LOCAL VARIABLES (LOCAL as SESSION scope synonym)
- SHOW STORAGE ENGINES
- SHOW PARSE_TREE <stmt> (debug-build MySQL statement, appears
  throughout mysql-test)

New unreserved keywords TREE, CODE, REPLICAS, PARSE_TREE (all verified
usable as identifiers on MySQL 8.0.46). All statements live-validated
against MySQL 8.0.46/9.7.0. Corpus failures drop 11,862 -> 10,505 with
zero regressions; grammar remains conflict-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lauses

Rework the SELECT statement tail so INTO and locking clauses match MySQL:

- INTO @var [, @var]... and INTO DUMPFILE 'file' join the existing INTO
  OUTFILE support; SelectIntoOption carries the variable list and
  restores all three forms.
- The INTO clause may appear directly after the select item list
  (SELECT ... INTO @v FROM t), before locking clauses, or after them.
  MySQL's own grammar resolves the pre-FROM/trailing ambiguity with a
  bison shift preference; goyacc has a zero-conflict policy, so the
  no-FROM SELECT tail is split into presence-marked alternatives (bare,
  lock-first, INTO-first, WHERE.../GROUP.../ORDER.../LIMIT...) instead.
- Locking clauses may repeat (FOR UPDATE OF t1 FOR SHARE OF t2 ...);
  SelectStmt.LockInfo becomes LockInfos []*SelectLockInfo.
- Two INTO clauses in one query block are rejected at parse time
  (MySQL rejects them post-parse with ER_MULTIPLE_INTO_CLAUSES).
- SelectStmt.Accept now visits SelectIntoOpt.

Corpus: failures 10505 -> 9907 (94.97% pass), zero regressions
(statement-keyed diff), zero grammar conflicts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- JSON_VALUE: RETURNING <cast type> plus {NULL|ERROR|DEFAULT literal}
  ON EMPTY / ON ERROR clauses, as a dedicated JSONValueExpr AST node
  (the bare two-argument call previously parsed as a generic function
  call). ON EMPTY must precede ON ERROR, matching MySQL. RETURNING
  reuses CastType, so all cast targets work.
- WEIGHT_STRING(str, retlen, maxlen, flags) debug form, restored as a
  plain argument list.
- MATCH ... AGAINST moves from Expression to SimpleExpr so it can be a
  comparison operand (MATCH(a) AGAINST('q') > 0.5), and the column list
  may omit parentheses (MATCH a, b AGAINST(...)), both as in MySQL.
- CAST/CONVERT accept spatial types (POINT, GEOMETRY, ...); the
  GEOMCOLLECTION synonym works as column type, cast target, and spatial
  constructor function, and RestoreAsCastType learns TypeGeometry.
- New keywords: RETURNING/GEOMCOLLECTION/JSON_VALUE stay unreserved;
  EMPTY is reserved, matching MySQL 8.0.4+, which intentionally rejects
  identifiers named "empty" that only parsed on pre-8.0 servers.

Corpus: failures 9907 -> 9234 (95.31% pass); the only two statement
regressions are CREATE/DROP TABLE `empty` from pre-8.0 NDB tests, which
stock MySQL 8.0.46 also rejects (verified live, ERROR 1064).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes ten scattered statement-level grammar gaps (123 corpus
statements, zero regressions):

- KILL [QUERY|CONNECTION] @uservar, and KillStmt.Accept now visits
  Expr so text cleaning reaches the operand.
- HELP 'topic' (new HelpStmt node).
- GET [CURRENT|STACKED] DIAGNOSTICS with statement items and
  CONDITION <n> items (new GetDiagnosticsStmt node). Statement and
  condition information item sets are validated disjointly, matching
  MySQL, which rejects mixing them with ER_PARSE_ERROR.
- SET <var> = {ALL|ROW|SYSTEM} value keywords (mirrors ON/BINARY).
- Keyword-named roles: RoleNameString accepts SKIP/LOCKED/NOWAIT/
  BINLOG/ROLE (all nonreserved in MySQL, verified live).
- EXPLAIN [FORMAT=...] [INTO @var] FOR {SCHEMA|DATABASE} name stmt
  (MySQL 8.3+; the lexer already folds SCHEMA onto the DATABASE
  token, so ExplainForDB needs only "DATABASE").
- SHOW ENGINE <name> {STATUS|LOGS|MUTEX} (three new ShowStmt types).
- SHOW EXTENDED [FULL] TABLES / SHOW EXTENDED INDEX, with EXTENDED
  now restored for the tables/index forms.
- CREATE VIEW IF NOT EXISTS (MySQL 9.x, verified on 9.7.0).
- LOAD XML INFILE with ROWS IDENTIFIED BY '<tag>', plus
  IGNORE n ROWS as a LINES synonym for both LOAD DATA and LOAD XML.

All forms live-verified against MySQL 8.0.46/9.7.0 sandboxes.
Corpus: 9,234 -> 9,111 failing (95.38% pass), probes 26 (all stored
programs), panics 0. goyacc reports zero conflicts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Close the expression/clause-level statement classes still failing in the
mysql-test corpus:

- LIKE ... ESCAPE now accepts any simple expression (hex/multibyte
  literals, function calls, subqueries, user variables), matching MySQL,
  which validates the one-character requirement at execution time
  (ER_WRONG_ARGUMENTS, not a syntax error). Single-character string
  literals keep the legacy byte representation; everything else is kept
  as PatternLikeExpr.EscapeExpr.
- DO <expr> [[AS] alias] accepts (and discards) column aliases.
- HAVING without GROUP BY in no-FROM and FROM DUAL selects
  (SELECT 1 WHERE 1 HAVING 1, SELECT 1 FROM DUAL ... HAVING ...).
- QUALIFY clause (MySQL 9.7). QUALIFY is reserved to match MySQL; the
  corpus has zero identifier uses of it.
- GROUP BY GROUPING SETS ((a), (b), ()). GROUPING stays callable as a
  function via FunctionNameConflictNonNow.
- ST_COLLECT aggregate incl. DISTINCT and windowed forms.
- CAST(expr AT TIME ZONE 'tz' AS DATETIME) (MySQL trunk).
- JSON_VALUE ... DEFAULT DATE/TIME/TIMESTAMP'...' temporal literals.
- a SOUNDS LIKE b, desugared to SOUNDEX(a) = SOUNDEX(b) exactly as
  MySQL defines it. SOUNDS is reserved in the fork (MySQL keeps it
  nonreserved and resolves the alias ambiguity via bison shift
  preference); the corpus has no identifier uses.

All forms verified against live MySQL 8.0.46/9.7.0. Corpus pass rate
95.38% -> 95.45% (145 statements fixed, zero regressions by
statement-keyed set-diff); goyacc still reports zero conflicts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Support JSON_TABLE(doc, path COLUMNS (...)) [AS] alias as a table
factor: FOR ORDINALITY counters, typed PATH and EXISTS PATH value
columns (with charset clauses), NESTED [PATH] column groups, and
NULL/ERROR/DEFAULT <literal> ON EMPTY/ON ERROR behaviors, reusing the
JSON_VALUE behavior grammar and AST.

JSON_TABLE lexes as a keyword only when followed by '(' (like other
builtin function tokens), and PATH/NESTED/ORDINALITY join the
unreserved keyword list, so none of them conflict with identifier uses.

Corpus pass rate 95.45% -> 95.52% (131 statements fixed, zero
regressions); the remaining JSON_TABLE corpus failures sit inside
stored-program bodies, which are a separate gap. goyacc still reports
zero conflicts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ANALYZE PARTITION becomes a regular alter spec (NO_WRITE_TO_BINLOG/ALL,
  comma-combinable) instead of a standalone statement, and REORGANIZE
  PARTITION moves out of the trailing-only position so it can follow
  modifier specs like ALGORITHM=INPLACE after a comma.
- SECONDARY_LOAD/SECONDARY_UNLOAD accept a PARTITION (...) list
  (MySQL 9.x); a small precedence pair makes the bare forms yield to the
  partition list without disturbing trailing PARTITION BY clauses.
- CREATE TABLE t (SELECT ...) parses without AS, including trailing
  ORDER BY/LIMIT and set operations with a parenthesized first operand.
  TABLE/VALUES query operands get the same ladder precedence as SELECT
  so the existing unparenthesized forms keep their parse.
- LOAD DATA gains CONCURRENT, the optional FROM keyword, NDB's IN
  PRIMARY KEY ORDER hint, a target PARTITION list, and := in SET.
- AUTOEXTEND_SIZE accepts plain byte counts, ENCRYPTION accepts any
  string (validated at execution, not parse: ER 1525/3184 not 1064),
  FLOAT/DOUBLE accept a decimal precision like FLOAT(10.3), and
  CURRENT_ROLE works as a select-field alias.

Corpus: 196,996 records, failures 8,835 -> 8,770 (95.55%), zero
regressions, zero panics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Grammar:
- GROUP BY ROLLUP(...), the function-style spelling of WITH ROLLUP
- QUALIFY without a FROM clause (SELECT 1 QUALIFY ROW_NUMBER() OVER () > 1)
- @var := val as a simple expression (MySQL variable_aux), usable as any
  operand; replaces the Expression-level and BoolPri-special productions.
  The value is a BitExpr so expression chain reductions stay conflict-free
- user variables as LAG/LEAD offsets
- CAST(... AS NCHAR/NATIONAL CHAR) with the utf8mb3 national charset
- structured system variables with quoted components
  (@@global.`default`.`key_buffer_size`)
- JSON_ARRAYAGG(expr NULL ON NULL)
- INTO OUTFILE ... CHARACTER SET, and COLLATE on JSON_TABLE column types
- unknown charset/collation names now parse: MySQL reports 1115/1273 at
  execution time (e.g. user-defined LDML collations), not 1064

Lexer:
- /*!NNNNN ... */ comments are now version-gated against the mimicked
  version (90700): higher-version content is discarded like MySQL 9.7's
  consume_comment, honoring one nested comment level per level, instead
  of always being parsed (fixes mysqldump 9.x /*!999999 sandbox headers)
- /*+ ... */ outside MySQL's hint slots (after CREATE etc.) warns and
  skips instead of failing

Corpus: 196,996 records, unique failing statements 8,770 -> 8,677
(93 fixed, 0 regressions), pass rate 95.60%, 0 panics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
morgo and others added 5 commits August 16, 2026 14:38
Wave 2f of the corpus gap closure (8,677 -> 8,637 unique failing
statements, 95.62% pass, zero regressions):

- SET LOCAL TRANSACTION as a synonym for SET SESSION TRANSACTION
- SHOW RELAYLOG EVENTS (new RELAYLOG unreserved keyword)
- HELP with an unquoted identifier topic
- RESET BINARY LOGS AND GTIDS TO 0xF (hex log index)
- PRIMARY as an index name in CACHE INDEX / LOAD INDEX key lists
- REPLACE 'current' on the ALTER USER USER() form
- quoted dynamic privileges: GRANT 'SYSTEM_VARIABLES_ADMIN' ON *.*
- CREATE LIBRARY: COMMENT after LANGUAGE and hex/bit literal bodies
- CREATE [OR REPLACE] JSON [RELATIONAL] DUALITY VIEW with the view
  attribute prefix and IF NOT EXISTS, plus the JSON_DUALITY_OBJECT
  constructor ('key' : value members, WITH (INSERT, UPDATE, DELETE)
  annotations) as new AST nodes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds CREATE/ALTER/DROP PROCEDURE, FUNCTION (stored, loadable/UDF, and
external-language AS-body forms), TRIGGER and EVENT, plus the compound
statement language of stored program bodies: BEGIN...END, DECLARE
(variables, cursors, conditions, handlers), IF/CASE/WHILE/REPEAT/LOOP
with labels, RETURN/LEAVE/ITERATE, OPEN/FETCH/CLOSE, and top-level
SIGNAL/RESIGNAL. CALL now accepts unreserved keywords as procedure
names.

The routine CREATE rules reuse the CREATE VIEW prefix shape
(OrReplace/ViewAlgorithm/ViewDefiner/ViewSQLSecurity) so the grammar
stays LALR(1); ALTER EVENT folds ON SCHEDULE / ON COMPLETION into one
clause for the same reason. The empty OptFieldLen/FloatOpt/OptBinary
productions take %prec lowerThanParenthese so a '(' after a bare
RETURNS type is greedily a field length, matching MySQL. New reserved
tokens mirror MySQL's keyword table exactly (probed on 9.7.0);
FOUND/RETURNS/CONTAINS/SCHEDULE/etc. stay unreserved.

Statement splits into SimpleStatement | BeginTransactionStmt so BEGIN
inside a body is always a compound block, like MySQL's
simple_statement_or_begin.

Corpus: 196,996 records, 8,637 -> 1,301 failing (95.62% -> 99.34%),
7,336 statements fixed, zero regressions, zero grammar conflicts.

pkg/change fallout: stored program DDL now parses, so a standalone
CREATE TRIGGER/PROCEDURE group promotes its GTID at its own QueryEvent
(prompt path) instead of deferring to the next GTIDEvent. The
unparseable-DDL tests now use ANSI_QUOTES DDL - the durably
unparseable class - as their fixture, and the ordering test gained a
group asserting the new prompt promotion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wave 2h finishes the mysql-test corpus sweep: 196,996 records now parse
except for 506, and every one of those 506 is ERROR 1064 on MySQL 9.7
too (verified by replaying each record through COM_STMT_PREPARE). They
are mysqltest directives, multi-statement or truncated extractions,
charset-dependent literals, and ANSI_QUOTES DDL.

Grammar added or corrected, each checked against a live server:

  * SELECT ... INTO naming routine variables, and the trailing INTO of a
    parenthesised query expression.
  * A trailing COLLATE on routine parameters, RETURNS and DECLARE types.
  * JSON_TABLE is a reserved word, so it may be followed by whitespace
    before its open paren and may not name a column.
  * Library aliases without AS, GET DIAGNOSTICS naming routine variables,
    and function-argument attribute aliases.
  * NATURAL INNER JOIN, CAST(x AS DOUBLE PRECISION), and MEMBER with the
    OF omitted.
  * JSON_TABLE accepts ON EMPTY and ON ERROR in either order (JSON_VALUE
    keeps the stricter ordering) and a charset introducer on path
    literals.
  * ASCII and UNICODE combined with BINARY in either order.
  * A routine parameter or local as a LEAD/LAG offset, a LIMIT bound, or
    a KILL connection id.
  * START TRANSACTION as a routine body statement, split out of
    BeginTransactionStmt so a bare BEGIN there still opens a compound
    statement.
  * Labels named after unreserved keywords. UnReservedKeyword is now
    LabelKeyword plus NonLabelKeyword, matching the set MySQL excludes,
    and WHILE/REPEAT/LOOP bodies must be non-empty as they are in MySQL.
  * STRAIGHT_JOIN takes the same low priority as a cross join, so a
    following join keeps its own ON clause.
  * INTERVAL() takes two or more arguments, which is what makes
    `d - INTERVAL (n) DAY_MICROSECOND` a temporal interval.

The grammar still generates with zero reported conflicts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five restore switches let the enum's zero value fall through to the
default branch, which the exhaustive linter reads as a missing case.
Annotated the way the rest of the repo does, with the reason.

TestUnparsableStatements asserted that CREATE TRIGGER fails with a
syntax error. The fork parses it now, so the migration is refused by
statement type instead; the statement is still rejected, which is what
the test is there to check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Kiran01bm

Copy link
Copy Markdown
Collaborator

🤖 Review findings - created by Kiran's code review agent - for pull/1134, 7ccffd3.

Verdict: 3 findings — 1 blocking (paren canonicalization unsound for BETWEEN/IN/LIKE/MEMBER OF), 2 non-blocking (decimal-warning digit corruption, stale README table). CI: 13/13 checks pass at head 7ccffd3 (DCO Check, build x7 matrix jobs, build(gtid), build(nogtid), generated-parser-matches-grammar, govulncheck, lint); re-ran gh -R block/spirit pr checks 1134 directly and all are green.

Blocking

  1. canRestoreBinaryChildWithoutParentheses drops parens for BETWEEN/IN/LIKE/MEMBER OF as if they were ordinary left-associative infix operators, but their right-hand sides are fixed multi-token productions, not simple operands. At expressions.go:1081, the side == binaryOpLeftSide branch fires for these four operators the same way it does for +/-/=, since restoreBinaryPrecedence gives them no operator-specific carve-out. A CHECK constraint shaped like CHECK ((a BETWEEN 1 AND 2) BETWEEN 3 AND 4) gets canonicalized to a BETWEEN 1 AND 2 BETWEEN 3 AND 4, which re-parses to a different tree (right-associated instead of the original nesting); the IN/LIKE/MEMBER OF variants restore to text that fails to re-parse at all, so applying that DDL to MySQL errors. No existing test nests one of these four constructs as another instance's subject operand.

Non-blocking

  1. FromString's integer-digit clamp on decimal overflow doesn't adjust strIdx, so the digit-extraction loop reads trailing digits into the diagnostic value instead of leading ones. At mydecimal.go:241, digitsInt is clamped but the pre-clamp cursor captured at line 247 is left unchanged, so a 130-digit literal 9×129 + 1 produces a warning ending ...991 (the literal's tail) rather than MySQL's actual 65-nines value. The stored decimal itself is correct (overwritten by mysql.DefaultDecimal right after), and no caller currently reads the warnings slice, so this is a diagnostic-text-only bug.
  2. pkg/statement/README.md's normalizer table still documents the pre-PR fully-parenthesized canonical form. README.md:346 says CHECK/generated-column expressions canonicalize to a fully-parenthesized form, but this PR's normalize_expression_parens.go change now produces minimal-paren output — the doc is stale as a direct side effect of this PR and should be updated alongside it.

The one thing that could have broken, verified

The riskiest mechanism is the new precedence-based paren-dropping rule for BETWEEN/IN/LIKE/MEMBER OF at expressions.go:1069-1082, since it's exercised on every CHECK-constraint/generated-column diff via pkg/statement's canonicalizer. I built a standalone probe against the checked-out worktree and ran restore-then-reparse round trips: ordinary chained operators and COLLATE ((a-b)-c, (a=b)=c, (a COLLATE x) COLLATE y) reparse to identical trees, confirming the rule is sound there, but (a BETWEEN 1 AND 2) BETWEEN 3 AND 4 reparses to a different tree, and the IN/LIKE/MEMBER OF analogues fail to reparse at all — confirming the rule is unsound for these four constructs (Finding 1).

Verified correct

  • opensTransaction is correctly propagated from extractTablesFromDDLStmts to all three call sites, with binlog.go's two sites intentionally discarding it via _ (utils.go).
  • gtid.go's pending-GTID-promotion gate correctly defers promotion for BEGIN/CTAS-with-implicit-commit while leaving standalone DDL and existing COMMIT/ROLLBACK/XA paths untouched (gtid.go:790).
  • fixWordCntError's clamping keeps wordsInt+wordsFrac<=wordBufLen, so the >81-digit fix never writes out of bounds despite the corrupted warning text (Finding 2) (mydecimal.go).
  • Top-level paren stripping (ctx.ParentBinaryOp==0) faithfully reproduces the prior manual unwrap it replaced (expressions.go:1028-1029).
  • Unary-operand conservatism keeps parentheses wherever dropping them would be unsound (expressions.go:1014, :1020).
  • Ordinary infix/COLLATE chains round-trip to identical trees (verified with a standalone probe, not just code reading).
  • go build ./..., go test ./pkg/parser/..., and non-integration pkg/change/pkg/statement unit tests all pass locally.
  • CI: 13/13 checks pass at head 7ccffd3 (DCO Check, build x7 matrix jobs, build(gtid), build(nogtid), generated-parser-matches-grammar, govulncheck, lint); re-ran gh -R block/spirit pr checks 1134 directly, all green.

This review was generated by Claude Code (claude-fable-5).

BETWEEN, IN, LIKE, REGEXP and MEMBER OF read like ordinary infix
operators but do not nest like them: MySQL's grammar fixes each of their
operands to a specific production rather than an expression at their own
precedence level. The canonicalizer treated them as ordinary
left-associative operators and dropped parentheses that carry meaning, so
`CHECK ((a BETWEEN 1 AND 2) BETWEEN 3 AND 4)` was rewritten to text that
re-parses as `a BETWEEN 1 AND (2 BETWEEN 3 AND 4)`, and the IN, LIKE,
REGEXP and MEMBER OF equivalents produced DDL MySQL rejects outright.

The subject of all five is a bit_expr, so it may still drop its
parentheses when it binds at least as tightly as one -- `(a + b) IN
(1,2)` still canonicalizes to `a + b IN (1,2)`. The other operands vary
per operator, so they keep theirs.

Twelve shapes are pinned as fixed points, including the cross-precedence
ones a same-precedence rule would miss: MySQL reads
`a = b BETWEEN 1 AND 10` as `a = (b BETWEEN 1 AND 10)`, so
`(a = b) BETWEEN 1 AND 10` cannot drop its parentheses either.

Found while checking the above: REGEXP takes a bit_expr pattern, not a
simple_expr, so `'a' REGEXP 'a' + 'b'` is valid where the same shape
after LIKE is not. Both verified against 8.0.46 and 9.7.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@morgo

morgo commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Thanks — findings 1 and 3 are fixed in 06c7597. Finding 1 was real and somewhat larger than reported. Finding 2 I believe is incorrect; evidence below.

1. Predicate-operator parens (blocking) — fixed, and broader than reported

Reproduced and then confirmed against live MySQL 8.0.46 and 9.7.0 in both directions: SELECT 0 BETWEEN 1 AND 10 BETWEEN 0 AND 0 returns 0, so the server really does regroup it right-associatively rather than reparse the original nesting, and the IN/LIKE/MEMBER OF analogues are ERROR 1064 outright. The fork's parser was faithful to MySQL here — the bug was entirely in the paren-dropping rule.

Probing the shape space found 12 broken forms, not 4, in two ways the report didn't cover:

  • REGEXP is affected too(s REGEXP 'x') REGEXP 'y' has the same failure.
  • It isn't limited to same-precedence subjects. MySQL reads a = b BETWEEN 1 AND 10 as a = (b BETWEEN 1 AND 10), so a lower-precedence subject can't drop its parens either: (a = b) BETWEEN 1 AND 10, (a IS NULL) IN (1,2). The right-hand operands break the same way — a BETWEEN 1 AND (b = c), s LIKE (a = b).

Root cause is that these five aren't ordinary infix operators: MySQL fixes each operand to a specific grammar production rather than an expression at the operator's own level. The fix models that. The subject of all five is a bit_expr, so it may drop parentheses only when it binds at least as tightly as bitwise |(a + b) IN (1,2) still minimizes to a + b IN (1,2), which is the point of the flag — and the other operands keep theirs, since they vary per operator (LIKE's pattern is a simple_expr, BETWEEN's bounds are a bit_expr and a predicate). Modelling each operand position individually would need new RestoreCtx plumbing for 3-operand BETWEEN; not worth it for text that is already correct, just not minimal.

Tests pin 19 fixed points (the 16 predicate shapes plus 3 that must still minimize) and 3 pairs that must stay textually distinct.

One extra gap found while checking this: REGEXP's pattern was a SimpleExpr in the fork's grammar, but MySQL's is a bit_expr — so 'a' REGEXP 'a' + 'b' was wrongly rejected. Fixed and pinned, along with the fact that the same shape after LIKE is an error, on the fork and on the server alike.

2. Decimal warning digits — not a bug; MySQL prints exactly this

The behaviour is real but it matches the server, so changing it would introduce divergence rather than remove it. Your own example, on both servers:

mysql> SELECT <129 nines then a 1>;
99999999999999999999999999999999999999999999999999999999999999999
Warning 1292 Truncated incorrect DECIMAL value: '999999999999999999999999999999999999999999999999999999999999999999999999999999991'

That warning ends ...991 — the literal's tail, 81 digits counted from the right. A less symmetric case makes it unambiguous: for 65 1s followed by 65 2s, MySQL warns with 16 ones + 65 twos, i.e. the last 81 digits, not the first. Identical on 8.0.46 and 9.7.0.

So the warning quotes a truncation of the literal (keeping trailing digits), not a rendering of the clamped value — the "MySQL's actual 65-nines value" in the finding is the query result, which the fork already returns correctly. I left the code alone, added a comment explaining why strIdx deliberately stays put, and added a subtest that pins the MySQL-matching output so a future reader doesn't "fix" it in the direction this finding suggests.

3. Stale README table — fixed

pkg/statement/README.md:346 now describes the minimal-paren canonical form, with both the before and after shapes.

@morgo
morgo enabled auto-merge (squash) August 17, 2026 00:33
The previous commit was too blunt: it kept the parentheses around every
operand of BETWEEN, IN, LIKE, REGEXP and MEMBER OF except the subject,
which regressed `a BETWEEN (b + 1) AND (c * 2)` -- the bounds are
bit_exprs, so they minimize like any other. Each position now uses its
own production. Subjects, BETWEEN's bounds and REGEXP's pattern are
bit_exprs; LIKE's pattern and MEMBER OF's document are simple_exprs, a
level only COLLATE reaches here. BETWEEN's upper bound is really a
predicate, looser again, but it shares a side with the lower bound and so
is held to the stricter of the two.

Checked against 9.7.0: `1 BETWEEN 0=0 AND 2` is a syntax error, so the
lower bound is no looser than a bit_expr; `'A' LIKE 'a' COLLATE
latin1_bin` returns 0, so COLLATE binds to the pattern rather than to the
LIKE; and `1 MEMBER OF (JSON_ARRAY(1)|0)` is a syntax error, so the
document really is a simple_expr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@morgo

morgo commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Correction to my reply above: the first cut of the fix kept parentheses around every non-subject operand, which was too blunt — it regressed the ported a BETWEEN (b + 1) AND (c * 2) case, since BETWEEN's bounds are bit_exprs and minimize like any other. CI caught it; b72f6ac holds each operand position to its own production instead.

Subjects, BETWEEN's bounds and REGEXP's pattern are bit_exprs; LIKE's pattern and MEMBER OF's document are simple_exprs, which only COLLATE reaches. Each half checked on 9.7.0: SELECT 1 BETWEEN 0=0 AND 2 is a syntax error, SELECT 'A' LIKE 'a' COLLATE latin1_bin returns 0 (so COLLATE binds to the pattern, not to the LIKE), and SELECT 1 MEMBER OF (JSON_ARRAY(1)|0) is a syntax error. Six restore-level shapes added alongside the canonicalizer ones.

@morgo
morgo merged commit 4ebe30a into block:main Aug 17, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

parser: adopt upstream's precedence-aware parentheses canonicalizer (RestoreSkipRedundantParentheses)

2 participants