statement/parser: minimal-paren canonicalization and binlog statement-class gaps - #1134
Conversation
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>
9f705bf to
f376d06
Compare
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>
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>
|
🤖 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 Blocking
Non-blocking
The one thing that could have broken, verifiedThe 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 Verified correct
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>
|
🤖 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 reportedReproduced and then confirmed against live MySQL 8.0.46 and 9.7.0 in both directions: Probing the shape space found 12 broken forms, not 4, in two ways the report didn't cover:
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 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: 2. Decimal warning digits — not a bug; MySQL prints exactly thisThe behaviour is real but it matches the server, so changing it would introduce divergence rather than remove it. Your own example, on both servers: That warning ends 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 3. Stale README table — fixed
|
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>
|
🤖 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 Subjects, |
Minimal-paren canonicalization
Closes #1128
Ports pingcap/tidb@52b9a887b3b0: the opt-in
format.RestoreSkipRedundantParenthesesflag and the MySQL precedence table that decides whether aParenthesesExprmay be dropped in its position. With the flag set,a + (b * c)restores asa + b * c, while(a + b) * canda - (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,REGEXPandMEMBER OFlook 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 readsa = b BETWEEN 1 AND 10asa = (b BETWEEN 1 AND 10), or emits text it rejects outright, as witha 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 andREGEXP's pattern arebit_exprs, so(a + b) IN (1,2)anda BETWEEN (b + 1) AND (c * 2)still minimize, whileLIKE's pattern andMEMBER OF's document aresimple_exprs, a level onlyCOLLATEreaches. 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 asCHECK (`a`=1 OR `b`=2 AND `c`=3)rather than the fully-parenthesizedCHECK ((`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, ands REGEXP '^y'asregexp_like(...).Fix found along the way: the strip-and-wrap pass didn't re-wrap
MEMBER OF, quantified comparisons, orCOLLATE, soCHECK (a = (1 MEMBER OF (j)))was rendered asa = 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/changecurrently 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:
MyDecimal.FromString. A strictpkg/changemust never panic on input the server accepted.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 andLOAD DATAclauses.RETAIN CURRENT PASSWORDsurface ofCREATE/ALTER USER, plusGRANT ... ASandREVOKE IF EXISTS.HANDLER,CHECK/CHECKSUM/REPAIR TABLE, plugins and components, resource groups,CLONE, replication administration (CHANGE REPLICATION SOURCE,START/STOP REPLICA,SET PERSIST), and theEXPLAIN/SHOWvariants.JSON_TABLEandJSON_VALUE,WEIGHT_STRING, ODBC escapes,MATCH ... AGAINST,SELECT ... INTO,INTERVAL()arity,MEMBERwith theOFomitted,CAST(x AS DOUBLE PRECISION), andREGEXP's pattern as a fullbit_exprrather than asimple_expr—'a' REGEXP 'a' + 'b'parses,'a' LIKE 'a' + 'b'is a syntax error, on the fork and on the server alike.DECLARE, handlers, cursors,IF/CASE/loops,SIGNAL, labels.pkg/changeneeds this because a routine body arrives in the binary log as one Query event. Worth 95.62% → 99.34% on its own.LIBRARYDDL, dollar-quoted strings,FLUSHoption lists.OPTIMIZER_COSTS,SYSTEM,UNDOandJSON_TABLEbecome 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 backERROR 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_QUOTESDDL (25), and SQL the test files run deliberately to check an error path (12).So on the criterion that matters for a strict
pkg/change— does the fork parse everything the server parses — this is 100%.Validation
sql_yacc.yyalone. Where the two disagreed I followed the server:JSON_TABLEreally is reserved, emptyWHILE/REPEAT/LOOPbodies really are rejected, andx MEMBER (doc)really does read asMEMBER OF. Each class has round-trip restore tests inbinlog_stmt_gaps_test.go.TestRoundTrip_ExpressionParenShapesruns 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.TestCanonicalExprParensIsFixedPointandTestCanonicalExprParensKeepsDistinctExpressionsDistinctcover it offline.parser-regenCI gate (byte-identical regeneration fromparser.y) passes;golangci-lint runreports 0 issues.🤖 Generated with Claude Code