Skip to main content

squonk_ast/dialect/
mysql.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Moderately AI Inc.
3
4//! The MySQL dialect preset and its reserved-keyword sets.
5//!
6//! The module is self-contained for feature gating: a build without the `mysql`
7//! cargo feature compiles none of this preset's data and never depends on a gated
8//! sibling preset.
9
10use super::keyword::{
11    MYSQL_FUNCTION_ONLY_KEYWORDS, MYSQL_RESERVED_KEYWORDS, MYSQL_TYPE_FUNC_NAME_KEYWORDS,
12};
13use super::{
14    AccessControlSyntax, AggregateCallSyntax, CallSyntax, CaretOperator, Casing,
15    ColumnDefinitionSyntax, CommentSyntax, ConstraintSyntax, CreateTableClauseSyntax,
16    DoubleAmpersand, ExistenceGuards, ExpressionSyntax, FeatureSet, GroupingSyntax,
17    IdentifierQuote, IdentifierSyntax, IndexAlterSyntax, JoinSyntax, Keyword, KeywordOperators,
18    KeywordSet, MYSQL_BYTE_CLASSES, MaintenanceSyntax, MutationSyntax, NullOrdering,
19    NumericLiteralSyntax, OperatorSyntax, ParameterSyntax, PipeOperator, PredicateSyntax,
20    QueryTailSyntax, SelectSyntax, SessionVariableSyntax, ShowSyntax, StatementDdlGates,
21    StringFuncForms, StringLiteralSyntax, TableExpressionSyntax, TableFactorSyntax, TargetSpelling,
22    TransactionSyntax, TypeNameSyntax, UtilitySyntax, ViewSequenceClauseSyntax,
23};
24use crate::precedence::{
25    Assoc, BindingPower, BindingPowerTable, STANDARD_SET_OPERATION_BINDING_POWERS,
26};
27
28/// MySQL backtick-only identifier quoting. MySQL spells a quoted identifier
29/// `` `a` ``; `"a"` is a string under its default (`ANSI_QUOTES`-off) mode, so `"`
30/// is deliberately absent here (see [`StringLiteralSyntax::MYSQL`]).
31pub const MYSQL_IDENTIFIER_QUOTES: &[IdentifierQuote] = &[IdentifierQuote::Symmetric('`')];
32
33// --- MySQL per-position reject sets (mysql-reserved-word-set) -----------------
34//
35// MySQL's reserved-word set (MySQL 8.0 manual, transcribed into
36// `mysql_keywords.csv`) differs from the shared ANSI/PostgreSQL one in both
37// directions: it reserves words PostgreSQL leaves free (`RLIKE`, `DIV`, `XOR`,
38// `STRAIGHT_JOIN`, `ZEROFILL`, …) and leaves free words PostgreSQL reserves
39// (`OFFSET`, `SYMMETRIC`, …). The shared inventory now carries every MySQL reserved
40// word, and these sets reserve them *only* for the MySQL dialect; under
41// ANSI/PostgreSQL the same words stay non-reserved (the `token_admissible` gate
42// reads the active dialect's set, so a word is an identifier wherever its set
43// omits it). MySQL has no PostgreSQL-style four-way class table — it has one
44// reserved set plus a grammar carve-out admitting built-in functions as call names
45// — so these compose from two generated bitsets the way the PostgreSQL gates
46// compose from four: every reserved word is also a `type_func_name` member for the
47// non-function positions, while the function position rejects only the fully
48// reserved set.
49
50/// MySQL `ColId` reject set (column/table name, correlation alias, qualifier):
51/// `type_func_name ∪ reserved`, so `LEFT`/`RLIKE` cannot be a bare column name.
52pub const MYSQL_RESERVED_COLUMN_NAME: KeywordSet =
53    MYSQL_TYPE_FUNC_NAME_KEYWORDS.union(MYSQL_RESERVED_KEYWORDS);
54
55/// MySQL's 11 dedicated *window* function names as a keyword bitset: `ROW_NUMBER`,
56/// `RANK`, `DENSE_RANK`, `PERCENT_RANK`, `CUME_DIST`, `NTILE`, `LEAD`, `LAG`,
57/// `FIRST_VALUE`, `LAST_VALUE`, `NTH_VALUE`. These are fully reserved words in MySQL 8.0,
58/// yet MySQL admits each as a *call head* through its dedicated window-function grammar
59/// (`ROW_NUMBER() OVER (…)` is valid, engine-verified on mysql:8), so they must be carved
60/// out of [`MYSQL_RESERVED_FUNCTION_NAME`] below — the one function-call position where a
61/// reserved window name is admissible. Every other position (column, type, bare/`AS`
62/// alias) keeps rejecting them via [`MYSQL_RESERVED_KEYWORDS`], matching MySQL
63/// (`SELECT ROW_NUMBER` bare / `AS row_number` are `ER_PARSE_ERROR`). The string-keyed
64/// twin `MYSQL_WINDOW_FUNCTIONS` in the parser crate carries the same 11 names and drives
65/// the OVER-required, fixed-arity window-function grammar once the head is admitted.
66pub const MYSQL_WINDOW_FUNCTION_KEYWORDS: KeywordSet = KeywordSet::from_keywords(&[
67    Keyword::RowNumber,
68    Keyword::Rank,
69    Keyword::DenseRank,
70    Keyword::PercentRank,
71    Keyword::CumeDist,
72    Keyword::Ntile,
73    Keyword::Lead,
74    Keyword::Lag,
75    Keyword::FirstValue,
76    Keyword::LastValue,
77    Keyword::NthValue,
78]);
79
80/// MySQL function-name reject set: the fully-reserved words *minus* the dedicated
81/// window-function names ([`MYSQL_WINDOW_FUNCTION_KEYWORDS`]), *plus* the `function_only`
82/// class ([`MYSQL_FUNCTION_ONLY_KEYWORDS`]). The `type_func_name` built-ins (`LEFT`, `IF`,
83/// `MOD`, …) are admitted here because MySQL parses `kw(...)` as a call, matching how
84/// PostgreSQL admits its `type_func_name` class as function names; the window-function
85/// names are admitted for the same reason — MySQL's dedicated window grammar accepts
86/// `ROW_NUMBER(…) OVER (…)` — even though they are otherwise fully reserved. The parser
87/// then enforces the window grammar's own restrictions (mandatory `OVER`, fixed argument
88/// arity) on the admitted head. The `function_only` class (only `array`) is the inverse:
89/// a plain identifier in every non-function position, so it is added *only* here — MySQL
90/// admits `SELECT 1 AS array` / `SELECT 1 array` but syntax-rejects `array(...)`
91/// (engine-verified 1064 on 8.4.10, mysql-reserved-word-set-8-4-over-rejections).
92pub const MYSQL_RESERVED_FUNCTION_NAME: KeywordSet = MYSQL_RESERVED_KEYWORDS
93    .difference(MYSQL_WINDOW_FUNCTION_KEYWORDS)
94    .union(MYSQL_FUNCTION_ONLY_KEYWORDS);
95
96/// MySQL (user-defined) type-name reject set: `type_func_name ∪ reserved`. Built-in
97/// type spellings (`INT`, `VARCHAR`, the MySQL `TINYINT`/`UNSIGNED`/… surface) are
98/// matched contextually before this gate, so it only governs user-named types,
99/// none of which may be a reserved word.
100pub const MYSQL_RESERVED_TYPE_NAME: KeywordSet =
101    MYSQL_TYPE_FUNC_NAME_KEYWORDS.union(MYSQL_RESERVED_KEYWORDS);
102
103/// MySQL bare-alias reject set: `type_func_name ∪ reserved`. Unlike PostgreSQL —
104/// whose `BARE_LABEL`/`AS_LABEL` split lets a reserved word like `SELECT` be a bare
105/// alias — MySQL rejects every reserved word as a bare (`AS`-less) alias.
106pub const MYSQL_RESERVED_BARE_ALIAS: KeywordSet =
107    MYSQL_TYPE_FUNC_NAME_KEYWORDS.union(MYSQL_RESERVED_KEYWORDS);
108
109impl CommentSyntax {
110    /// The `MYSQL_VERSION_ID` the fitted preset models for versioned-comment
111    /// gating: the ceiling of the MySQL 8.4 LTS series the `mysql:8` oracle image
112    /// tracks. Real-world `/*!NNNNN … */` markers name the *released* version a
113    /// feature appeared in, so every id the 8.4 line can reach is included
114    /// regardless of which 8.4.x patch the oracle runs, while 8.5+/9.x ids are
115    /// skipped exactly as the live server skips them (engine-verified:
116    /// `/*!80500 … */` and `/*!90000 … */` are discarded on 8.4.10). Pinning the
117    /// oracle's exact patch id instead would rot on every image bump; ids in the
118    /// unreleased tail of the window (above the running patch, at most `..=80499`)
119    /// are the accepted approximation.
120    pub const MYSQL_8_VERSION_BOUND: u32 = 80499;
121
122    /// The `MYSQL` preset for comment syntax.
123    pub const MYSQL: Self = Self {
124        line_comment_hash: true,
125        // MySQL ends a `--`/`#` line comment at `\n` only — a `\r` is ordinary comment
126        // content (engine-verified against mysql:8: `SELECT 1 -- c\rFROM` is one comment
127        // to end-of-line and prepares as `SELECT 1`).
128        line_comment_ends_at_carriage_return: false,
129        nested_block_comments: false,
130        versioned_comments: Some(Self::MYSQL_8_VERSION_BOUND),
131        // MySQL rejects an unterminated `/* …` at EOF (engine-verified), unlike SQLite.
132        unterminated_block_comment_at_eof: false,
133    };
134}
135
136impl StringLiteralSyntax {
137    /// The `MYSQL` preset for string literal syntax.
138    pub const MYSQL: Self = Self {
139        escape_strings: false,
140        dollar_quoted_strings: false,
141        national_strings: true,
142        double_quoted_strings: true,
143        backslash_escapes: true,
144        unicode_strings: false,
145        bit_string_literals: true,
146        // MySQL's `x'…'`/`X'…'` hexadecimal literal requires an even count of hex digits
147        // and syntax-rejects an odd/non-hex body (probed on the live m3 oracle:
148        // `ER_PARSE_ERROR` 1064 for `x'ABC'`/`x'XY'`/`x'0'`; `x''` accepts), unlike the
149        // deferred bit-string above. With both flags on, the eager blob arm owns the
150        // `x`/`X` marker by scan precedence while `B'…'`/`b'…'` stays the deferred binary
151        // bit-string — exactly MySQL's split (a `b'…'` body takes any digit count).
152        blob_literals: true,
153        charset_introducers: true,
154        // MySQL concatenates adjacent string literals with any whitespace separator,
155        // newline or not (`'a' 'b'` → `'ab'`).
156        same_line_adjacent_concat: true,
157    };
158}
159
160impl NumericLiteralSyntax {
161    /// The `MYSQL` preset for numeric literal syntax.
162    pub const MYSQL: Self = Self {
163        hex_integers: true,
164        octal_integers: false,
165        binary_integers: true,
166        underscore_separators: false,
167        radix_leading_underscore: false,
168        money_literals: false,
169        // MySQL's trailing-junk rule is mixed: it rejects an integer glued to an
170        // identifier (`123abc`, `1x`) but accepts a dot-float glued to one (`0.a`,
171        // `0.0e1a`) by re-reading the suffix as an alias. A single boolean cannot model
172        // that split, so this stays loose until the lexer can express it.
173        reject_trailing_junk: false,
174    };
175}
176
177impl ParameterSyntax {
178    /// The `MYSQL` preset for parameter syntax.
179    pub const MYSQL: Self = Self {
180        positional_dollar: false,
181        positional_dollar_large: false,
182        anonymous_question: true,
183        named_colon: false,
184        named_at: false,
185        // SQLite's `$name`; MySQL has no dollar-named parameter.
186        named_dollar: false,
187        numbered_question: false,
188    };
189}
190
191impl SessionVariableSyntax {
192    /// The `MYSQL` preset for session variable syntax.
193    pub const MYSQL: Self = Self {
194        user_variables: true,
195        system_variables: true,
196        variable_assignment: true,
197    };
198}
199
200impl IdentifierSyntax {
201    /// The `MYSQL` preset for identifier syntax.
202    pub const MYSQL: Self = Self {
203        non_ascii: super::NonAsciiIdentifierSyntax::Any,
204        dollar_in_identifiers: true,
205        // MySQL syntax-rejects a string literal in a name position, so the SQLite
206        // string-identifier misfeature stays off.
207        string_literal_identifiers: false,
208        string_literal_table_names: false,
209        empty_quoted_identifiers: false,
210    };
211}
212
213impl TableExpressionSyntax {
214    /// The `MYSQL` preset for table expression syntax.
215    pub const MYSQL: Self = Self {
216        only: false,
217        table_sample: false,
218        parenthesized_joins: true,
219        // MySQL admits a FROM table-alias column list on a *derived* table / subquery
220        // (`FROM (SELECT …) AS c(x)` parses on mysql:8, only bind-failing) but rejects one
221        // on a *base* table (`FROM t AS y(a, b)` is a syntax error). This single knob is not
222        // position-aware, so leaving it on keeps the valid derived-table form; the
223        // base-table over-acceptance needs a base-vs-derived split and stays pinned for a
224        // follow-up.
225        table_alias_column_lists: true,
226        join_using_alias: false,
227        // MySQL index hints and explicit partition selection on a table factor.
228        index_hints: true,
229        // MSSQL-only `WITH (...)` table hints — off; MySQL has no such tail.
230        table_hints: false,
231        partition_selection: true,
232        // A column-list alias is admitted on a *derived* table / subquery
233        // (`table_alias_column_lists` above, on) but NOT on a *base* table: `FROM t AS
234        // y(a, b)` is `ER_PARSE_ERROR` on mysql:8 (while `FROM (SELECT …) AS c(x)` parses),
235        // so the base-table position is off — the base-vs-derived split.
236        base_table_alias_column_lists: false,
237        // DuckDB-only string-literal table alias. MySQL accepts a string *column*
238        // alias but rejects a string *table* alias, so this stays off here.
239        string_literal_aliases: false,
240        // MySQL admits a parenthesized join but rejects an alias on it — `(a CROSS JOIN b)
241        // AS x` is `ER_PARSE_ERROR` on mysql:8, while the bare group and a derived-table
242        // `(SELECT …) AS x` both parse.
243        aliased_parenthesized_join: false,
244        // MySQL's bare table alias is a `ColId`, not the SQLite `ids` class; its JOIN
245        // keywords are reserved as a `ColId` regardless (via the MySQL reserved sets).
246        bare_table_alias_is_bare_label: false,
247        // MySQL has no table version / time-travel modifier.
248        table_version: false,
249        // MySQL has no PartiQL / SUPER table-position JSON path.
250        table_json_path: false,
251        // MySQL has no SQLite `INDEXED BY` / `NOT INDEXED` index directive (it has its own
252        // `index_hints`).
253        indexed_by: false,
254        prefix_colon_alias: false,
255    };
256}
257
258impl JoinSyntax {
259    /// The `MYSQL` preset for join syntax.
260    pub const MYSQL: Self = Self {
261        stacked_join_qualifiers: true,
262        // MySQL has no `FULL [OUTER] JOIN` — only `LEFT`/`RIGHT` outer joins — so an
263        // already-aliased factor followed by `FULL [OUTER] JOIN` is a syntax error
264        // (engine-measured-rejected on mysql:8). `FULL` is non-reserved, so a bare
265        // `a full JOIN b` still reads `full` as the alias, matching the engine.
266        full_outer_join: false,
267        // MySQL's `NATURAL` join grammar admits only `LEFT`/`RIGHT`, never `CROSS`.
268        natural_cross_join: false,
269        straight_join: true,
270        // DuckDB-only nonstandard joins.
271        asof_join: false,
272        positional_join: false,
273        semi_anti_join: false,
274        sided_semi_anti_join: false,
275        apply_join: false,
276        // MySQL's recursive CTEs have no SEARCH/CYCLE clauses (a SQL:2023 PostgreSQL form).
277        recursive_search_cycle: false,
278        // MySQL parse-accepts the modifier; its recursive-part restriction is a resolver check.
279        recursive_union_rejects_order_limit: false,
280        // `USING KEY` is DuckDB's keyed-recursion clause; MySQL has no such spelling.
281        recursive_using_key: false,
282    };
283}
284
285impl TableFactorSyntax {
286    /// The `MYSQL` preset for table factor syntax.
287    pub const MYSQL: Self = Self {
288        lateral: false,
289        table_functions: false,
290        rows_from: false,
291        // MySQL has no `FROM UNNEST(…)` (it uses `JSON_TABLE`), so the keyword falls
292        // through to the named-table path and rejects.
293        unnest: false,
294        unnest_with_offset: false,
295        table_function_ordinality: false,
296        // MySQL has no PostgreSQL `func_table` promotion: a bare `current_date`/
297        // `current_timestamp` special value function in table position is `ER_PARSE_ERROR`
298        // on mysql:8 (those words are reserved), so it falls through to the named-table path
299        // where the reserved-word gate rejects it.
300        special_function_table_source: false,
301        // PIVOT/UNPIVOT are DuckDB-only operators.
302        pivot: false,
303        unpivot: false,
304        // DuckDB-only DESCRIBE/SHOW/SUMMARIZE table source.
305        show_ref: false,
306        // DuckDB-only bare `FROM VALUES (…) AS t` row-list table factor.
307        from_values: false,
308        // MySQL has its own `JSON_TABLE` with a different grammar, and no `XMLTABLE`; this
309        // PG-shaped surface stays off so it never fires. `JSON_TABLE(` falls to the ordinary
310        // function/name path (a MySQL-parity follow-up owns the MySQL grammar).
311        json_table: false,
312        xml_table: false,
313        // `TABLE(<expr>)` is a Snowflake/Oracle form; MySQL has no such factor.
314        table_expr_factor: false,
315        // The standard PIVOT is a Snowflake/BigQuery/Oracle form; MySQL has no PIVOT.
316        pivot_value_sources: false,
317        // MATCH_RECOGNIZE is a Snowflake/Oracle form; MySQL has no such factor.
318        match_recognize: false,
319        // OPENJSON is a SQL Server form; MySQL has no such factor.
320        open_json: false,
321    };
322}
323
324impl MutationSyntax {
325    /// The `MYSQL` preset for mutation syntax.
326    pub const MYSQL: Self = Self {
327        insert_ignore: true,
328        insert_overwrite: false,
329        returning: false,
330        on_conflict: false,
331        on_duplicate_key_update: true,
332        multi_column_assignment: false,
333        update_tuple_value_row_arity: false,
334        where_current_of: false,
335        merge: false,
336        replace_into: true,
337        insert_set: true,
338        // MySQL admits the single-table `UPDATE`/`DELETE ... ORDER BY ... LIMIT` tails.
339        update_delete_tails: true,
340        joined_update_delete: true,
341        // The SQLite `INSERT OR <action>` prefix is not MySQL: MySQL's own conflict
342        // shorthand is a bare post-verb `INSERT IGNORE` (no `OR`), a different surface
343        // not modelled here, so the `OR`-prefixed form stays off.
344        or_conflict_action: false,
345        insert_column_matching: false,
346        delete_using: true,
347        // MySQL has no `UPDATE … FROM`: it lists the extra tables in the target
348        // (`UPDATE t1, t2 SET …`), so `UPDATE t SET … FROM u` is `ER_PARSE_ERROR` on mysql:8.
349        update_from: false,
350        // MySQL's `DELETE FROM tbl … USING …` names bare delete targets (no alias); an
351        // alias on the target is `ER_PARSE_ERROR` on mysql:8 (`DELETE FROM t AS e USING …`),
352        // while a plain single-table `DELETE FROM t AS e WHERE …` is fine.
353        delete_using_target_alias: false,
354        // MySQL admits a leading `WITH` before SELECT/UPDATE/DELETE but not before INSERT
355        // (`WITH … INSERT …` is `ER_PARSE_ERROR` on mysql:8; the CTE rides the
356        // `INSERT … SELECT` source instead).
357        cte_before_insert: false,
358        // MySQL has no `MERGE` at all, so the leading-`WITH` gate is moot; off.
359        cte_before_merge: false,
360        // MySQL CTE bodies are subqueries only — a DML body is `ER_PARSE_ERROR` 1064
361        // on mysql:8 (probed).
362        data_modifying_ctes: false,
363        // MySQL has no `MERGE` at all, so its residual-grammar gates are all moot; off.
364        merge_when_not_matched_by: false,
365        merge_insert_default_values: false,
366        merge_insert_overriding: false,
367        merge_insert_multirow: false,
368        merge_update_set_star: false,
369        merge_insert_star_by_name: false,
370        merge_error_action: false,
371        update_set_qualified_column: true,
372    };
373}
374
375impl StatementDdlGates {
376    /// The `MYSQL` preset for statement ddl gates.
377    pub const MYSQL: Self = Self {
378        colocation_groups: false,
379        // MySQL's `CREATE TRIGGER` body is not the modelled SQLite `BEGIN … END` form.
380        create_trigger: false,
381        // The macro DDL is DuckDB-specific; MySQL has no `CREATE MACRO`.
382        create_macro: false,
383        create_secret: false,
384        create_type: false,
385        // Virtual tables are SQLite-only; MySQL rejects `CREATE VIRTUAL TABLE`.
386        create_virtual_table: false,
387        // MySQL has no sequence generators (it uses AUTO_INCREMENT); `CREATE SEQUENCE` rejects.
388        create_sequence: false,
389        extension_ddl: false,
390        transform_ddl: false,
391        alter_system: false,
392        // MySQL's InnoDB/NDB tablespace and NDB logfile-group storage DDL. Live mysql:8.4.10:
393        // every grammar-valid form is grammar-positive (ER_UNSUPPORTED_PS 1295 over the PREPARE
394        // oracle — recognized but not preparable).
395        tablespace_ddl: true,
396        logfile_group_ddl: true,
397        schemas: true,
398        // MySQL's `CREATE SCHEMA` is a `CREATE DATABASE` synonym with no embedded
399        // schema-element grammar (engine-rejected), so the embedding stays off.
400        schema_elements: false,
401        databases: true,
402        // MySQL's `DROP {DATABASE | SCHEMA} [IF EXISTS] <name>` single-database drop —
403        // DATABASE and SCHEMA are synonyms, exactly one unqualified name, no CASCADE.
404        drop_database: true,
405        // MySQL has no materialized views (`CREATE`/`DROP MATERIALIZED VIEW` are
406        // engine-measured-rejected on mysql:8), so the keyword pair is left undispatched.
407        materialized_views: false,
408        // MySQL has temporary *tables* but no temporary *views* — `CREATE TEMPORARY VIEW`
409        // is engine-measured-rejected on mysql:8.
410        routines: true,
411        or_replace: true,
412        create_or_replace_table: false,
413        // `CREATE RECURSIVE VIEW` is a DuckDB form; MySQL leaves `RECURSIVE`
414        // unconsumed before the expected `VIEW`.
415        // MySQL routine/trigger/event bodies are SQL/PSM compound statements
416        // (`BEGIN … END` with a `DECLARE` prefix and flow control), parsed by the
417        // separate body dispatcher.
418        compound_statements: true,
419        // MySQL's `ALTER DATABASE` (charset/collation) is a distinct behaviour not yet
420        // modelled; DuckDB's alias/sequence/relocation forms stay off here.
421        alter_database: false,
422        alter_database_options: true,
423        server_definition: true,
424        alter_instance: true,
425        spatial_reference_system: true,
426        resource_group: true,
427        alter_sequence: false,
428        alter_object_set_schema: false,
429        // MySQL owns the view definition-option surface: the `ALGORITHM`/`DEFINER`/`SQL
430        // SECURITY` prefix on `CREATE VIEW` and the whole `ALTER VIEW` redefinition.
431    };
432}
433impl ViewSequenceClauseSyntax {
434    /// View/sequence clause surface for the `MYSQL` preset.
435    pub const MYSQL: Self = Self {
436        materialized_view_to: false,
437        create_sequence_cache: false,
438        temporary_views: false,
439        recursive_views: false,
440        view_definition_options: true,
441    };
442}
443
444impl CreateTableClauseSyntax {
445    /// The `MYSQL` preset for create table clause syntax.
446    pub const MYSQL: Self = Self {
447        table_options: true,
448        // MySQL has no SQLite trailing `WITHOUT ROWID` table option (rowid storage is an
449        // InnoDB internal, not surface syntax).
450        without_rowid_table_option: false,
451        // MySQL has no SQLite trailing `STRICT` table option (its column-type enforcement is
452        // the `STRICT_*` SQL modes, not table surface syntax).
453        strict_table_option: false,
454        // MySQL has no PostgreSQL-style `WITH (<param> = <value>)` storage-parameter
455        // clause on `CREATE TABLE` (its table options are bare `<KEY> = <value>` pairs,
456        // gated by `table_options`), so the parenthesized form is off
457        // (engine-measured-rejected on mysql:8).
458        storage_parameters: false,
459        // MySQL has no `ON COMMIT {PRESERVE | DELETE} ROWS` temporary-table clause
460        // (engine-measured-rejected on mysql:8).
461        on_commit: false,
462        create_table_as_with_data: false,
463        create_table_as_execute: false,
464        // MySQL's `PARTITION BY HASH(c) PARTITIONS n` is an unrelated surface; the PostgreSQL
465        // declarative form is not accepted.
466        declarative_partitioning: false,
467        // MySQL has no table inheritance; its `CREATE TABLE t LIKE src` is the distinct
468        // statement-level production gated by `statement_level_table_like`, not the PostgreSQL
469        // `(LIKE …)` element gated by `like_source_table`.
470        table_inheritance: false,
471        like_source_table: false,
472        statement_level_table_like: true,
473        unlogged_tables: false,
474        table_access_method: false,
475        without_oids: false,
476        typed_tables: false,
477    };
478}
479
480impl ColumnDefinitionSyntax {
481    /// The `MYSQL` preset for column definition syntax.
482    pub const MYSQL: Self = Self {
483        // MySQL spells the keywordless generated-column `AS (…)` shorthand, but has none
484        // of the SQLite `CREATE TABLE` decorations (its own `AUTO_INCREMENT` rides
485        // `table_options`, not the SQLite flag), so that stays off.
486        generated_column_shorthand: true,
487        // MySQL has no SQLite column-level `ON CONFLICT <resolution>` clause (its upsert
488        // conflict handling is `INSERT … ON DUPLICATE KEY`, a separate surface).
489        column_conflict_resolution_clause: false,
490        // MySQL requires a data type on every column; the SQLite typeless column is not
491        // part of its grammar.
492        typeless_column_definitions: false,
493        // MySQL requires a type on a generated column too (`x INT AS (…)`); DuckDB's
494        // type-optional generated column is not part of its grammar.
495        typeless_generated_columns: false,
496        // MySQL spells auto-increment as the underscored `AUTO_INCREMENT` attribute (gated by
497        // `table_options`), never SQLite's joined `AUTOINCREMENT`, so the joined spelling is off.
498        joined_autoincrement_attribute: false,
499        // MySQL's inline `PRIMARY KEY` takes no `ASC`/`DESC` order qualifier; the trailing
500        // keyword is left unconsumed and rejected.
501        inline_primary_key_ordering: false,
502        // MySQL has no column `COLLATE` clause (its collation is a distinct attribute grammar), so
503        // it never admits the SQLite `CONSTRAINT <name>` prefix on one.
504        named_column_collate_constraint: false,
505        // MySQL has no SQL-standard `GENERATED … AS IDENTITY` column — it spells
506        // auto-numbering with the `AUTO_INCREMENT` attribute (which rides `table_options`),
507        // so the `IDENTITY` clause is off (engine-measured-rejected on mysql:8).
508        identity_columns: false,
509        compact_identity_columns: false,
510        // MySQL requires a functional column default to be parenthesized: `DEFAULT UUID()` /
511        // `DEFAULT 1 + 2` are `ER_PARSE_ERROR` on mysql:8, while `DEFAULT (UUID())` and the
512        // literal / `CURRENT_TIMESTAMP`/`NOW()` forms parse.
513        default_expression_requires_parens: true,
514        // MySQL admits a `CONSTRAINT <symbol>` name only on an inline `CHECK`; a named inline
515        // `REFERENCES`/`UNIQUE`/`PRIMARY KEY`/`NOT NULL` is `ER_PARSE_ERROR` on mysql:8.
516        column_default_requires_b_expr: false,
517        // Column COLLATE (MySQL spells it via its own `CHARACTER SET … COLLATE …` attribute
518        // grammar — a separate surface), UNLOGGED, column STORAGE/COMPRESSION, the table USING
519        // access method, WITHOUT OIDS, and typed `OF <type>` tables are all PostgreSQL surfaces
520        // MySQL does not spell here.
521        column_collation: false,
522        column_storage: false,
523    };
524}
525
526impl ConstraintSyntax {
527    /// The `MYSQL` preset for constraint syntax.
528    pub const MYSQL: Self = Self {
529        deferrable_constraints: false,
530        named_inline_non_check_constraints: false,
531        // MySQL is not measured to accept a bodyless `CONSTRAINT <name>`; unmodelled, off.
532        bare_constraint_name: false,
533        exclusion_constraints: false,
534        constraint_no_inherit_not_valid: false,
535        index_constraint_parameters: false,
536        // MySQL's key_part admits ASC/DESC and length prefixes / functional (expr) parts but not
537        // COLLATE — a differently-shaped surface with no corpus demand, scoped out rather than
538        // modelled as this SQLite-shaped gate.
539        constraint_column_collate_order: false,
540        referential_action_cascade_set: true,
541        check_constraint_subqueries: true,
542    };
543}
544
545impl IndexAlterSyntax {
546    /// The `MYSQL` preset for index alter syntax.
547    pub const MYSQL: Self = Self {
548        rename_constraint: false,
549        alter_table_set_options: false,
550        drop_primary_key: true,
551        alter_column_add_identity: false,
552        index_storage_parameters: false,
553        drop_behavior: true,
554        // MySQL's `DROP INDEX <name> ON <table> [ALGORITHM …] [LOCK …]` — mandatory ON,
555        // online-DDL execution hints.
556        index_drop_on_table: true,
557        index_concurrently: false,
558        index_using_method: false,
559        partial_index: false,
560        // MySQL rejects `CREATE INDEX IF NOT EXISTS`, index-key `NULLS FIRST`/`LAST`, and a
561        // routine argument-type list (`DROP FUNCTION f(INT)`) — each engine-measured
562        // `ER_PARSE_ERROR` on mysql:8.
563        index_if_not_exists: false,
564        index_nulls_order: false,
565        alter_table_extended: true,
566        // MySQL's extended `ALTER TABLE` (multi-action lists, `ADD`/`DROP CONSTRAINT`,
567        // `ALTER COLUMN`) is on via `alter_table_extended`, but it has none of these:
568        // `ALTER TABLE IF EXISTS`, `ADD COLUMN IF NOT EXISTS`, `DROP [COLUMN|CONSTRAINT] IF
569        // EXISTS` — each `ER_PARSE_ERROR` on mysql:8; and its `ALTER COLUMN` admits only
570        // `SET`/`DROP DEFAULT` (type changes go through `MODIFY`/`CHANGE`), so `SET DATA
571        // TYPE`/`TYPE`/`SET NOT NULL`/`DROP NOT NULL` are `ER_PARSE_ERROR` too. MySQL also
572        // has no deferrable constraints (`… DEFERRABLE`/`INITIALLY DEFERRED`) and no
573        // `CREATE TABLE … AS SELECT … WITH [NO] DATA` populate clause — all `ER_PARSE_ERROR`
574        // on mysql:8.
575        alter_nested_column_paths: false,
576        alter_existence_guards: false,
577        alter_column_set_data_type: false,
578        routine_arg_types: false,
579        routine_arg_defaults: false,
580        routine_arg_modes: false,
581        // MySQL's routine `LANGUAGE` admits only the bare word `SQL`; the string spelling
582        // `LANGUAGE 'SQL'` is engine-measured `ER_PARSE_ERROR` (1064) on mysql:8 for both
583        // `CREATE FUNCTION` and `CREATE PROCEDURE`.
584        routine_language_string: false,
585        alter_table_multiple_actions: true,
586    };
587}
588
589impl ExistenceGuards {
590    /// The `MYSQL` preset for existence guards.
591    pub const MYSQL: Self = Self {
592        if_exists: true,
593        view_if_not_exists: false,
594        create_database_if_not_exists: true,
595    };
596}
597
598impl ExpressionSyntax {
599    /// The `MYSQL` preset for expression syntax.
600    pub const MYSQL: Self = Self {
601        typecast_operator: false,
602        subscript: false,
603        // DuckDB's three-bound `[lower:upper:step]` slice is a dialect extension.
604        slice_step: false,
605        collate: false,
606        at_time_zone: false,
607        semi_structured_access: false,
608        array_constructor: false,
609        multidim_array_literals: false,
610        collection_literals: false,
611        row_constructor: false,
612        struct_constructor: false,
613        field_selection: false,
614        field_wildcard: false,
615        typed_string_literals: true,
616        // MySQL has the `DATE`/`TIME`/`TIMESTAMP` typed literals but no first-class
617        // interval literal: every prefix-typed `INTERVAL '…'` form — standalone or in a
618        // `+`/`-` operand, including the unit-less `INTERVAL '1'` and the ANSI
619        // `HOUR TO SECOND`/`SECOND(p)` spellings — is `ER_PARSE_ERROR` on mysql:8.4.10
620        // (engine-measured). The only valid MySQL interval is the operator-position
621        // `INTERVAL <expr> <unit>` (`mysql_interval_operator` below); the literal path
622        // stays off so its declined forms reject.
623        typed_interval_literal: false,
624        // DuckDB's relaxed interval spellings are a dialect extension.
625        relaxed_interval_syntax: false,
626        mysql_interval_operator: true,
627        // DuckDB's `#n` positional column reference is a dialect extension; MySQL spells
628        // `#` a line comment.
629        positional_column: false,
630        lambda_keyword: false,
631    };
632}
633
634impl OperatorSyntax {
635    /// The `MYSQL` preset for operator syntax.
636    pub const MYSQL: Self = Self {
637        operator_construct: false,
638        containment_operators: false,
639        json_arrow_operators: false,
640        // MySQL has neither the PostgreSQL `jsonb` operators nor a `#`/`@`-operator surface,
641        // and it spells `@@name`/`?` as the system-variable sigil / placeholder, so this
642        // stays off (enabling it would contend for the `@@` and `?` triggers).
643        jsonb_operators: false,
644        double_equals: false,
645        // MySQL spells integer division with the `DIV` keyword (via `keyword_operators`),
646        // not DuckDB's `//` symbol.
647        integer_divide_slash: false,
648        starts_with_operator: false,
649        is_general_equality: false,
650        // Truth-value tests are standard SQL (F571); MySQL 8 accepts all six forms
651        // (measured over the wire).
652        truth_value_tests: true,
653        // MySQL null-safe equality `<=>`.
654        null_safe_equals: true,
655        // The single-arrow lambda is DuckDB-only. (MySQL's own JSON `->` accessor
656        // stays off too — `json_arrow_operators` above — pending its dialect child.)
657        lambda_expressions: false,
658        // MySQL accepts the bitwise `| & ~ << >>` operators (its own distinct precedence
659        // ranks live in `MYSQL_BINDING_POWERS`). Bitwise XOR is its `^` spelling, carried
660        // by `caret_operator` on the preset below.
661        bitwise_operators: true,
662        quantified_comparisons: true,
663        quantified_comparison_lists: false,
664        // MySQL admits only the comparison operators in the quantifier — no any-operator
665        // extension.
666        quantified_arbitrary_operator: false,
667        // MySQL has no general `Op`-class operator surface (its `^` being bitwise XOR, not
668        // exponentiation, is carried by `caret_operator` on the preset below).
669        custom_operators: false,
670        null_test_postfix: false,
671        // MySQL has no postfix operator surface — a trailing symbolic operator rejects.
672        postfix_operators: false,
673    };
674}
675
676impl CallSyntax {
677    /// The `MYSQL` preset for call syntax.
678    pub const MYSQL: Self = Self {
679        named_argument: false,
680        utc_special_functions: true,
681        columns_expression: false,
682        extract_from_syntax: true,
683        try_cast: false,
684        // MySQL's `CAST`/`CONVERT` target is the narrow `cast_type` set (SIGNED/UNSIGNED,
685        // CHAR/BINARY, DATE/DATETIME/TIME, DECIMAL/DOUBLE/FLOAT/REAL, JSON) — not the full
686        // column-type vocabulary — so `CAST(x AS INT)`/`AS VARCHAR`/`AS TIMESTAMP`/… are
687        // engine-measured parse errors on mysql:8.
688        restricted_cast_targets: true,
689        // DuckDB-specific call tails; off for MySQL.
690        extract_string_field: false,
691        method_chaining: false,
692        // MySQL has no SQL/JSON `JSON()`/`JSON_SCALAR()`/`JSON_SERIALIZE()` constructor
693        // keywords; those names take the ordinary function path.
694        sqljson_constructors_require_argument: false,
695        // MySQL's JSON functions (`JSON_VALUE`/`JSON_OBJECT`/…) have their OWN grammar,
696        // distinct from the SQL:2016 special forms modelled here; keep them ordinary calls.
697        sqljson_expression_functions: false,
698        // MySQL has no SQL/XML expression functions (`ExtractValue`/`UpdateXML` are ordinary
699        // functions); keep the `xml*` names ordinary calls.
700        xml_expression_functions: false,
701        variadic_argument: false,
702        // `merge_action()` is a PostgreSQL-only support function.
703        merge_action_function: false,
704        convert_function: true,
705    };
706}
707
708impl StringFuncForms {
709    /// The `MYSQL` preset for string func forms.
710    pub const MYSQL: Self = Self {
711        // The standard string special forms, engine-measured on mysql:8.4: SUBSTRING
712        // takes the FROM-first keyword form only (the FOR-leading orders and the
713        // SIMILAR regex form are 1064) with a 2-3 plain-call arity floor
714        // (`SUBSTRING('a')` is 1064 while a spaced `SUBSTRING ('a')` demotes to the
715        // any-arity stored-function path); SUBSTR is a full keyword synonym;
716        // POSITION takes MySQL's asymmetric `bit_expr IN expr` operands; OVERLAY
717        // does not exist (`overlay(…)` stays an ordinary stored-function-shaped
718        // call); TRIM is the restricted single-source form (the PostgreSQL
719        // trim_list tails and `trim('a', 'b')` comma form are all 1064). The
720        // keyword forms compose with `aggregate_args_require_adjacent_paren` above:
721        // a spaced `TRIM (LEADING …)` / `SUBSTRING ('a' FROM 2)` demotes to the
722        // generic path exactly as the engine does (both probed 1064 via that path).
723        substring_from_for: true,
724        substring_leading_for: false,
725        substring_similar: false,
726        substring_plain_call_requires_2_or_3_args: true,
727        substr_from_for: true,
728        position_in: true,
729        position_asymmetric_operands: true,
730        overlay_placing: false,
731        overlay_requires_placing: false,
732        trim_from: true,
733        trim_list_syntax: false,
734        // `COLLATION FOR (<expr>)` is a PostgreSQL-only common-subexpr.
735        collation_for_expression: false,
736        // The `CEIL TO <field>` keyword form is sqlparser-rs-parity surface only —
737        // no probed oracle engine's grammar admits it.
738        ceil_to_field: false,
739        // The `FLOOR TO <field>` keyword form is sqlparser-rs-parity surface only —
740        // no probed oracle engine's grammar admits it.
741        floor_to_field: false,
742        // MySQL's full-text `MATCH (…) AGAINST (…)` special form (this preset's oracle
743        // ran the full grammar on mysql:8.4.10). SQLite's infix `MATCH` operator is a
744        // separate binding-power entry, unaffected by this prefix-position gate.
745        match_against: true,
746    };
747}
748
749impl AggregateCallSyntax {
750    /// The `MYSQL` preset for aggregate call syntax.
751    pub const MYSQL: Self = Self {
752        group_concat_separator: true,
753        within_group: false,
754        aggregate_filter: false,
755        // MySQL has no aggregate `FILTER` clause at all, so the body-widening is inert.
756        filter_optional_where: false,
757        // MySQL's default `IGNORE_SPACE`-off tokenizer rejects the aggregate-only argument
758        // forms behind a spaced paren (`COUNT ( * )`, `MAX ( ALL 1 )` — engine-measured 1064),
759        // while a spaced normal call `count (1)` still parses (binding, not syntax).
760        aggregate_args_require_adjacent_paren: true,
761        null_treatment: false,
762        // MySQL's dedicated aggregate grammar requires an argument (or `COUNT(*)`), so
763        // `COUNT()`/`SUM()`/… are `ER_PARSE_ERROR` on mysql:8, while `NOW()`/`UUID()` and
764        // empty user-function calls are accepted.
765        aggregate_calls_reject_empty_arguments: true,
766        // MySQL admits `OVER` only on the aggregate ∪ window functions; `OVER` on a scalar
767        // built-in or user function (`PERCENTILE_CONT(x, 0.5) OVER ()`) is `ER_PARSE_ERROR`
768        // on mysql:8.
769        over_requires_windowable_function: true,
770        window_function_tail: true,
771        standalone_argument_order_by: false,
772    };
773}
774
775impl SelectSyntax {
776    /// The `MYSQL` preset for select syntax.
777    pub const MYSQL: Self = Self {
778        distinct_on: false,
779        // MySQL has no `SELECT … INTO <table>` create-table form.
780        select_into: false,
781        // MySQL requires at least one select item, so a bare `SELECT` is rejected.
782        empty_target_list: false,
783        // MySQL has no `QUALIFY` clause (a DuckDB extension).
784        qualify: false,
785        // MySQL accepts a string literal as a column alias (`SELECT 1 AS 'x'`).
786        alias_string_literals: true,
787        // MySQL also reads a bare (`AS`-less) string in projection-alias position as the
788        // column name (`SELECT 1 'x'`; engine-measured on mysql:8.4.10). The overlap with
789        // same-line adjacent-string concatenation (`SELECT 'a' 'b'` is the single value
790        // `'ab'`, not `'a' AS 'b'`) is resolved by parse ordering, not a carve-out flag: a
791        // string primary greedily folds every following unprefixed string continuation
792        // (`same_line_adjacent_concat`) before the alias parser runs, so a trailing
793        // string only reaches the bare-alias branch when the preceding expression was NOT a
794        // string (`SELECT 1 'x'` → alias; `SELECT 'a' 'b'` → concat). Engine-measured.
795        bare_alias_string_literals: true,
796        // `UNION [ALL] BY NAME` is a DuckDB extension; MySQL has no name-matched set
797        // operation, so `BY` after a set operator is a syntax error there.
798        union_by_name: false,
799        wildcard_modifiers: false,
800        wildcard_replace: false,
801        intersect_all: true,
802        except_all: true,
803        // MySQL's `table_wild` (`t.*`) is a non-aliasable select-item production; a trailing
804        // alias rejects (measured Reject on mysql:8 with the table provisioned).
805        qualified_wildcard_alias: false,
806        // FROM-first SELECT is a DuckDB extension; MySQL rejects a statement-position
807        // `FROM`.
808        from_first: false,
809        explicit_table: true,
810        parenthesized_query_operands: true,
811        // MySQL accepts a ragged VALUES constructor at parse and rejects it later; the
812        // parse-time equal-arity check is a DuckDB-only tightening, so it is off here.
813        values_rows_require_equal_arity: false,
814        // MySQL's query-position VALUES constructor is `VALUES ROW(1), ROW(2)`; a bare
815        // `(…)` row (`VALUES (1)`, `FROM (VALUES (1))`, `VALUES (1) UNION …`) is
816        // engine-measured `ER_PARSE_ERROR` on mysql:8, so bare rows are rejected in query
817        // position. The `INSERT … VALUES (…)` source list is a separate path, unaffected.
818        values_row_constructor: false,
819        // MySQL has no PostgreSQL `ColLabel` relaxation: a reserved word (`type_func_name ∪
820        // reserved`, the `reserved_bare_alias` set) is rejected as an `AS` projection alias
821        // exactly as it is rejected as a bare alias — `SELECT 1 AS range`/`AS left`/`AS
822        // delete` are `ER_PARSE_ERROR` on mysql:8, while the non-reserved `SELECT 1 AS any`
823        // parses. The dotted-name continuation (`t.range`) stays permissive via the empty
824        // `reserved_as_label`; this gate scopes the reservation to the projection `AS` alias.
825        as_alias_rejects_reserved: true,
826        // A trailing comma in a list is a DuckDB tolerance; MySQL rejects it.
827        trailing_comma: false,
828        // The prefix colon alias is a DuckDB extension; a `:` at a select-item /
829        // table-factor head is a parse error in MySQL.
830        prefix_colon_alias: false,
831        // Hive/Spark `LATERAL VIEW` is not MySQL; a post-FROM `LATERAL` is a parse
832        // error there.
833        lateral_view_clause: false,
834        // The Oracle-style `START WITH`/`CONNECT BY` hierarchical query clause is not
835        // MySQL; a post-WHERE `CONNECT BY`/`START WITH` is a parse error there.
836        connect_by_clause: false,
837    };
838}
839
840impl QueryTailSyntax {
841    /// The `MYSQL` preset for query tail syntax.
842    pub const MYSQL: Self = Self {
843        // MySQL row-limits with `LIMIT`; it has no SQL:2008 `FETCH FIRST … ROWS`
844        // spelling (engine-measured-rejected on mysql:8), so the clause is gated off and
845        // a leading `FETCH` surfaces as a clean parse error.
846        fetch_first: false,
847        limit_offset_comma: true,
848        // MySQL's `FOR UPDATE`/`FOR SHARE [OF …] [NOWAIT|SKIP LOCKED]` and legacy
849        // `LOCK IN SHARE MODE` row-locking tails.
850        locking_clauses: true,
851        // MySQL's grammar has only `UPDATE`/`SHARE` and exactly one locking clause
852        // (engine-verified, mysql-select-tails-locking-hints-partition), so the
853        // PostgreSQL-only `NO KEY UPDATE`/`KEY SHARE` strengths and stacked clauses stay
854        // off — `FOR NO KEY UPDATE` and a trailing second `FOR …` are parse errors here.
855        key_lock_strengths: false,
856        stacked_locking_clauses: false,
857        using_sample: false,
858        // MySQL row-limits with `LIMIT` only: a bare leading `OFFSET` with no preceding
859        // `LIMIT` (`SELECT 1 OFFSET 1`, and every `OFFSET … [LIMIT …]`/`OFFSET … ROWS`
860        // spelling) is `ER_PARSE_ERROR` on mysql:8, so leading offset is off (like SQLite)
861        // and the `OFFSET` keyword surfaces as a clean parse error. The `OFFSET` that
862        // *trails* a `LIMIT` (`LIMIT 10 OFFSET 5`) is unaffected — parsed by the `LIMIT`
863        // branch, not this gate.
864        leading_offset: false,
865        // MySQL restricts a `LIMIT`/`OFFSET` count to an unsigned integer literal or a `?`
866        // placeholder — `LIMIT 1 + 1` / `LIMIT (SELECT 1)` are engine-measured-rejected on
867        // mysql:8 — so arbitrary limit expressions are off.
868        limit_expressions: false,
869        limit_percent: false,
870        with_ties_requires_order_by: false,
871        // BigQuery/ZetaSQL `|>` pipe syntax is not MySQL; off here. A `|>` after a query is
872        // a parse error, and the token never lexes with the gate off.
873        pipe_syntax: false,
874        // ClickHouse `LIMIT n BY …` is not MySQL; a `BY` after `LIMIT` is a parse error.
875        limit_by_clause: false,
876        // ClickHouse `SETTINGS …` is not MySQL; a trailing `SETTINGS` is a parse error.
877        settings_clause: false,
878        // ClickHouse `FORMAT …` is not MySQL; a trailing `FORMAT` is a parse error.
879        format_clause: false,
880        // MSSQL `FOR XML`/`FOR JSON` is not MySQL; a trailing `FOR XML`/`FOR JSON` is a
881        // parse error (a bare `FOR UPDATE`/`FOR SHARE` locking clause is unaffected).
882        for_xml_json_clause: false,
883    };
884}
885
886impl GroupingSyntax {
887    /// The `MYSQL` preset for grouping syntax.
888    pub const MYSQL: Self = Self {
889        // MySQL has no standard grouping sets; its only grouping surface is the
890        // distinct trailing `WITH ROLLUP`. With this off, `ROLLUP (a, b)` in GROUP BY
891        // falls through to the expression grammar as an ordinary function call, which
892        // is how MySQL resolves it (a stored-function reference).
893        grouping_sets: false,
894        // MySQL's `GROUP BY <keys> WITH ROLLUP` is its sole grouping-set surface.
895        with_rollup: true,
896        // MySQL sorts only by `ASC`/`DESC`; `USING <operator>` is PostgreSQL-only.
897        order_by_using: false,
898        // `GROUP BY ALL` / `ORDER BY ALL` are DuckDB clause modes; MySQL reserves
899        // `ALL`, so either spelling is a syntax error there.
900        group_by_all: false,
901        group_by_set_quantifier: false,
902        order_by_all: false,
903    };
904}
905
906impl UtilitySyntax {
907    /// The `MYSQL` preset for utility syntax.
908    pub const MYSQL: Self = Self {
909        kill: true,
910        // MySQL's `HANDLER <t> {OPEN | READ … | CLOSE}` low-level cursor family. Live
911        // mysql:8.4.10: all forms grammar-accept (ER_UNSUPPORTED_PS 1295, not preparable over
912        // the wire; a bare-connection unqualified `OPEN` is ER_NO_DB_ERROR 1046).
913        handler_statements: true,
914        // MySQL's `INSTALL`/`UNINSTALL` `PLUGIN`/`COMPONENT` family. Live mysql:8.4.10: `INSTALL
915        // PLUGIN … SONAME …` and `UNINSTALL PLUGIN …` prepare; the `COMPONENT` forms grammar-
916        // accept as ER_UNSUPPORTED_PS 1295 (not preparable over the wire).
917        plugin_component_statements: true,
918        // MySQL's server-administration leading-keyword families. Live mysql:8.4.10 (PREPARE
919        // oracle): `SHUTDOWN`/`RESTART`/`CLONE`/`IMPORT TABLE`/`HELP` grammar-accept as
920        // ER_UNSUPPORTED_PS 1295 (not preparable over the wire); `BINLOG` is preparable, so it
921        // PREPAREs a grammar-valid payload (decode/apply happen only at execution).
922        shutdown: true,
923        restart: true,
924        clone: true,
925        import_table: true,
926        help_statement: true,
927        binlog: true,
928        // MySQL's `CACHE INDEX` / `LOAD INDEX INTO CACHE` MyISAM key-cache pair. Live
929        // mysql:8.4.10: every shape grammar-accepts (PREPAREs); a table list with `PARTITION`,
930        // a `PARTITION` after the key list, `IGNORE LEAVES` before the key list, and a trailing
931        // `IN <cache>` on `LOAD INDEX` all ER_PARSE_ERROR.
932        key_cache_statements: true,
933        // MySQL's standalone `RENAME TABLE`/`RENAME USER` object-rename statements — a
934        // leading-keyword gate like `kill`. MySQL-only (bar the Lenient superset).
935        rename_statement: true,
936        signal_diagnostics: true,
937        // MySQL's `USE <schema>` catalogue switch. `use_qualified_name` stays off (inherited
938        // from ANSI): MySQL's `USE ident` takes a single unqualified schema and
939        // `ER_PARSE_ERROR`s any dotted name.
940        use_statement: true,
941        // MySQL's `DO <expr-list>` evaluate-and-discard statement — a distinct behaviour on
942        // the `DO` keyword from PostgreSQL's anonymous code block (`do_statement`, off here).
943        do_expression_list: true,
944        // MySQL's `PREPARE ... FROM {'text' | @var}` / `EXECUTE ... USING @var` /
945        // `{DEALLOCATE | DROP} PREPARE name` lifecycle — a distinct grammar on the same three
946        // keywords from DuckDB's typed-`AS` `prepared_statements` (off here). Live mysql:8.4.10:
947        // all forms grammar-accept (ER_UNSUPPORTED_PS 1295, not preparable over the wire).
948        prepared_statements_from: true,
949        // MySQL's `LOCK/UNLOCK {TABLES|TABLE}` per-table lock-kind statements — the MySQL
950        // reading of the leading `LOCK` keyword (PostgreSQL's statement-level mode list is a
951        // different, unimplemented behaviour with its own future gate). Engine-measured on
952        // mysql:8.4.10: the lock kind is mandatory (`LOCK TABLES t1` is 1064) and the pre-8.0
953        // `LOW_PRIORITY WRITE` modifier is gone (1064).
954        lock_tables: true,
955        // MySQL's `LOCK INSTANCE FOR BACKUP`/`UNLOCK INSTANCE` backup-lock pair
956        // (both `ER_UNSUPPORTED_PS` under the PREPARE oracle — grammar-positive).
957        lock_instance: true,
958        // MySQL's `CALL sp_name opt_paren_expr_list` stored-procedure invocation. The
959        // parenthesized argument list is *optional* — `CALL p`, `CALL p()`, and `CALL p(1, 2)`
960        // all grammar-accept on mysql:8.4.10 (the bare and empty forms resolve to
961        // ER_SP_DOES_NOT_EXIST 1305 for an absent routine, a grammar-positive binding reject),
962        // so the bare-name widening (`call_bare_name`) rides the base `call` gate here.
963        call: true,
964        call_bare_name: true,
965        // MySQL's `FLUSH [NO_WRITE_TO_BINLOG | LOCAL] <target>` and `PURGE BINARY LOGS {TO
966        // '<log>' | BEFORE <expr>}` server-administration statements — leading-keyword gates
967        // like `kill`. Live mysql:8.4.10: FLUSH prepares (accept), PURGE grammar-accepts
968        // (ER_UNSUPPORTED_PS 1295, not preparable); the removed `HOSTS` target and `PURGE
969        // MASTER LOGS` synonym both `ER_PARSE_ERROR`.
970        flush: true,
971        purge_binary_logs: true,
972        replication_statements: true,
973        // MySQL's `XA` distributed-transaction family (`XA START/END/PREPARE/COMMIT/ROLLBACK/
974        // RECOVER`) — a leading-keyword gate like `kill`. Live mysql:8.4.10: every grammar-valid
975        // form is `ER_UNSUPPORTED_PS` 1295 (recognized, not preparable over the wire).
976        // MySQL's `LOAD {DATA | XML} … INFILE … INTO TABLE …` bulk-import statement — the MySQL
977        // reading of the leading `LOAD` keyword (the PostgreSQL/DuckDB `load_extension`
978        // shared-library load is a different behaviour, off here; the two dispatch on the
979        // `LOAD DATA`/`LOAD XML` two-word lookahead). Engine-measured on mysql:8.4.10: the clause
980        // train is strictly order-sensitive (any out-of-order clause is 1064), `FIELDS`/`COLUMNS`
981        // and `LINES`/`ROWS` spellings are interchangeable, and every clause parses under both
982        // `DATA` and `XML` (the format restrictions are semantic, enforced post-parse).
983        load_data: true,
984        // Every remaining utility statement head is explicitly pinned below.
985        copy: false,
986        copy_into: false,
987        stage_references: false,
988        comment_on: false,
989        comment_if_exists: false,
990        pragma: false,
991        attach: false,
992        use_qualified_name: false,
993        // MySQL's `USE ident` rejects a string name (`USE 'db'` is `ER_PARSE_ERROR` on
994        // mysql:8); the DuckDB Sconst form stays off.
995        use_string_literal_name: false,
996        prepared_statements: false,
997        prepare_typed_parameters: false,
998        load_extension: false,
999        load_bare_name: false,
1000        reset_scope: false,
1001        detach_if_exists: false,
1002        do_statement: false,
1003        export_import_database: false,
1004        update_extensions: false,
1005    };
1006}
1007impl TransactionSyntax {
1008    /// Transaction-control surface for the `MYSQL` preset (split from UtilitySyntax).
1009    pub const MYSQL: Self = Self {
1010        xa_transactions: true,
1011        start_transaction: true,
1012        start_transaction_block_optional: false,
1013        transaction_work_keyword: true,
1014        begin_transaction_keyword: false,
1015        commit_transaction_keyword: false,
1016        rollback_transaction_keyword: false,
1017        transaction_name: false,
1018        begin_transaction_modes: false,
1019        transaction_savepoints: true,
1020        set_transaction: true,
1021        transaction_isolation_mode: true,
1022        transaction_access_mode: true,
1023        transaction_deferrable_mode: false,
1024        start_transaction_isolation_mode: false,
1025        start_transaction_deferrable_mode: false,
1026        start_transaction_consistent_snapshot: true,
1027        transaction_multiple_modes: true,
1028        transaction_modes_require_commas: true,
1029        transaction_modes_reject_duplicates: true,
1030        abort_transaction_alias: false,
1031        end_transaction_alias: false,
1032        transaction_release: true,
1033        transaction_chain: true,
1034        release_savepoint_keyword_optional: false,
1035        begin_transaction_mode: false,
1036    };
1037}
1038
1039impl ShowSyntax {
1040    /// The `MYSQL` preset for show syntax.
1041    pub const MYSQL: Self = Self {
1042        describe: true,
1043        // MySQL's `SHOW [EXTENDED] [FULL] TABLES [{FROM|IN} db] [LIKE | WHERE]` — the typed
1044        // catalogue listing, distinct from the generic session `SHOW <var>` it also has.
1045        show_tables: true,
1046        // MySQL's `SHOW [EXTENDED] [FULL] {COLUMNS|FIELDS} {FROM|IN} tbl [{FROM|IN} db]
1047        // [LIKE | WHERE]` — MySQL-only (DuckDB has no such grammar), so its own gate.
1048        show_columns: true,
1049        // MySQL's `SHOW CREATE TABLE tbl` — the DDL that recreates the table. No
1050        // EXTENDED/FULL modifiers on this subform; MySQL-only (DuckDB has no such grammar),
1051        // so its own gate. Only TABLE is modelled; the other `SHOW CREATE …` object kinds
1052        // are deferred to sibling tickets.
1053        show_create_table: true,
1054        // MySQL's `SHOW {FUNCTION | PROCEDURE} STATUS [LIKE | WHERE]` stored-routine
1055        // catalogue listing — a *different* statement from the Spark/Databricks bare `SHOW
1056        // FUNCTIONS` (`show_functions`, off here), so its own gate. MySQL-only (engine-probed
1057        // accept on mysql:8; `SHOW FUNCTION STATUS FROM db` and bare `SHOW FUNCTIONS` both
1058        // `ER_PARSE_ERROR`), off in every other preset.
1059        show_routine_status: true,
1060        // MySQL's server-administration / catalogue-introspection `SHOW` family (~40
1061        // sub-commands: DATABASES, STATUS/VARIABLES, ENGINES, PLUGINS, CREATE VIEW/…,
1062        // INDEX, GRANTS, WARNINGS/ERRORS, …). One behaviour gate for the whole family —
1063        // sub-command is DATA on the `ShowTarget` axis, reached by one table-driven
1064        // dispatch. MySQL-only, off in every other preset (bar the Lenient superset).
1065        show_admin: true,
1066        describe_summarize: false,
1067        session_statements: true,
1068        // MySQL routes `SET` through the expression-valued variable-assignment grammar;
1069        // these generic-value refinements are therefore inert.
1070        set_value_reserved_words: KeywordSet::EMPTY,
1071        set_value_on_keyword: false,
1072        set_value_null_keyword: false,
1073        show_functions: false,
1074        show_verbose: false,
1075    };
1076}
1077
1078impl MaintenanceSyntax {
1079    /// The `MYSQL` preset for maintenance syntax.
1080    pub const MYSQL: Self = Self {
1081        // MySQL's admin-table verb family (`ANALYZE/CHECK/CHECKSUM/OPTIMIZE/REPAIR TABLE`).
1082        // One behaviour gate for the whole family — the verb is DATA on the
1083        // `TableMaintenanceKind` axis, reached by one table-driven dispatch. MySQL-only,
1084        // off in every other preset (bar the Lenient superset).
1085        table_maintenance: true,
1086        // The non-MySQL maintenance heads remain off.
1087        vacuum: false,
1088        vacuum_analyze: false,
1089        reindex: false,
1090        analyze: false,
1091        analyze_columns: false,
1092        checkpoint: false,
1093        checkpoint_database: false,
1094    };
1095}
1096
1097impl AccessControlSyntax {
1098    /// The `MYSQL` preset for access control syntax.
1099    pub const MYSQL: Self = Self {
1100        alter_role_rename: false,
1101        // `show_functions` stays off (from `..ANSI`): MySQL has no bare `SHOW FUNCTIONS`
1102        // listing. Its `SHOW FUNCTION STATUS [LIKE | WHERE]` is a *different* routine
1103        // catalogue over `mysql.proc`, carried by the `show_routine_status` gate on
1104        // `ShowSyntax::MYSQL` (a distinct statement, not an overload of the Spark/Databricks
1105        // `SHOW FUNCTIONS` this gate governs).
1106        // MySQL grants/revokes, but not the schema-scoped objects (`ON SCHEMA`/`ON
1107        // DATABASE`, `ON ALL … IN SCHEMA`) or the `{GRANT|ADMIN} OPTION FOR` prefix — all
1108        // engine-measured `ER_PARSE_ERROR` on mysql:8 (`SCHEMA`/`DATABASE` are reserved and
1109        // cannot introduce a priv_level). Only the extended object/prefix surface is off.
1110        access_control_extended_objects: false,
1111        // MySQL owns the account-management DDL family this gate names.
1112        user_role_management: true,
1113        // MySQL routes GRANT/REVOKE through its account-based grammar (priv-level objects,
1114        // `user@host` grantees, PROXY grants, `AS … WITH ROLE`, `IF EXISTS`/`IGNORE UNKNOWN USER`).
1115        access_control_account_grants: true,
1116        // GRANT and REVOKE remain enabled through the account-oriented grammar.
1117        access_control: true,
1118    };
1119}
1120
1121impl TypeNameSyntax {
1122    /// The `MYSQL` preset for type name syntax.
1123    pub const MYSQL: Self = Self {
1124        extended_scalar_type_names: true,
1125        enum_type: true,
1126        set_type: true,
1127        numeric_modifiers: true,
1128        integer_display_width: true,
1129        composite_types: false,
1130        // MySQL's `VARCHAR`/`VARBINARY` require an explicit length (`ER_PARSE_ERROR` on
1131        // mysql:8 without one), unlike the fixed-width `CHAR`/`BINARY` that default to 1.
1132        varchar_requires_length: true,
1133        // MySQL has no zoned temporal type: `TIMESTAMPTZ` / `TIMESTAMP WITH TIME ZONE` /
1134        // `TIMETZ` are `ER_PARSE_ERROR` on mysql:8 (its `TIMESTAMP` carries no zone
1135        // qualifier).
1136        zoned_temporal_types: false,
1137        // MySQL requires a precision inside `DECIMAL(...)`; the empty-paren `DECIMAL()`
1138        // form is a DuckDB spelling, off here.
1139        empty_type_parens: false,
1140        // MySQL's char-family type carries the `opt_charset_with_opt_binary` annotation —
1141        // `CHARACTER SET x` / `CHARSET x` / `ASCII` / `UNICODE` / `BYTE` / trailing `BINARY`
1142        // — in both cast-target and column-definition positions (engine-measured on
1143        // mysql:8.4: `CAST(x AS CHAR(5) CHARACTER SET utf8mb4)`, `CHAR ASCII`, `CHAR(5)
1144        // BINARY`).
1145        character_set_annotation: true,
1146        // MySQL requires an unsigned `DECIMAL` modifier — a negative scale is a syntax error.
1147        signed_type_modifier: false,
1148        // ClickHouse's `Nullable(T)` combinator is a no-oracle ClickHouse/Lenient addition;
1149        // MySQL has no such type.
1150        nullable_type: false,
1151        // Same for the sibling `LowCardinality(T)` combinator — ClickHouse/Lenient, no oracle;
1152        // MySQL has no such type.
1153        low_cardinality_type: false,
1154        // Same for `FixedString(N)` — ClickHouse/Lenient, no oracle; MySQL has no such type.
1155        fixed_string_type: false,
1156        // Same for `DateTime64(P[, 'tz'])` — ClickHouse/Lenient, no oracle; MySQL has no such type.
1157        datetime64_type: false,
1158        // Same for `Nested(name Type, ...)` — ClickHouse/Lenient, no oracle; MySQL has no such type.
1159        nested_type: false,
1160        // Same for the `Int8`…`Int256`/`UInt*` bit-width integer names — ClickHouse/Lenient, no
1161        // oracle; MySQL spells its widths `TINYINT`/`INT`/`BIGINT`, not `Int32`.
1162        bit_width_integer_names: false,
1163        // SQLite's liberal multi-word / two-argument affinity type names; MySQL has a closed
1164        // type vocabulary and rejects `LONG INTEGER` / `VARCHAR(123,456)` (`ER_PARSE_ERROR`).
1165        liberal_type_names: false,
1166        string_type_modifiers: false,
1167        angle_bracket_types: false,
1168    };
1169}
1170
1171/// MySQL binding powers, explicitly enumerated with the engine-measured comparison
1172/// associativity and bitwise precedence rows.
1173///
1174/// **Comparison associativity.** The comparison row (`= <> < <= > >=`, plus
1175/// `RLIKE`/`REGEXP`, which fold onto it) is `Assoc::Left`, not `Assoc::NonAssoc`: real
1176/// MySQL parses a comparison chain left-associatively — `1 < 2 < 3` means `(1 < 2) < 3`,
1177/// the boolean 0/1 result feeding the outer one — where ANSI/PostgreSQL reject the chain.
1178///
1179/// **Bitwise ranks (grammar-derived).** MySQL is the dialect that splits the bitwise
1180/// family across *four* distinct precedences, unlike PostgreSQL/SQLite/DuckDB's single
1181/// shared rank. Per the MySQL 8.0 operator-precedence manual (tight→loose):
1182/// `~` (unary) > `^` (XOR) > `* /` > `+ -` > `<< >>` > `&` > `|` > comparison. So `^`
1183/// binds *tighter than* multiplicative, the shifts sit between additive and `&`, and
1184/// `|` < `&`. Derived from the manual, not live-probed: a live `mysql:8` oracle should
1185/// confirm these ranks when one is available. `~` takes the tight [`prefix_sign`](crate::precedence::BindingPowerTable)
1186/// rank (`80`), matching the manual's "unary minus / bit inversion" row.
1187pub const MYSQL_BINDING_POWERS: BindingPowerTable = BindingPowerTable {
1188    or: BindingPower {
1189        left: 10,
1190        right: 11,
1191        assoc: Assoc::Left,
1192    },
1193    xor: BindingPower {
1194        left: 15,
1195        right: 16,
1196        assoc: Assoc::Left,
1197    },
1198    and: BindingPower {
1199        left: 20,
1200        right: 21,
1201        assoc: Assoc::Left,
1202    },
1203    comparison: BindingPower {
1204        left: 40,
1205        right: 41,
1206        assoc: Assoc::Left,
1207    },
1208    range_predicate_override: None,
1209    is_predicate_override: None,
1210    double_equals: BindingPower {
1211        left: 40,
1212        right: 41,
1213        assoc: Assoc::Left,
1214    },
1215    additive: BindingPower {
1216        left: 50,
1217        right: 51,
1218        assoc: Assoc::Left,
1219    },
1220    multiplicative: BindingPower {
1221        left: 60,
1222        right: 61,
1223        assoc: Assoc::Left,
1224    },
1225    exponent: BindingPower {
1226        left: 65,
1227        right: 66,
1228        assoc: Assoc::Left,
1229    },
1230    string_concat: BindingPower {
1231        left: 45,
1232        right: 46,
1233        assoc: Assoc::Left,
1234    },
1235    any_operator: BindingPower {
1236        left: 45,
1237        right: 46,
1238        assoc: Assoc::Left,
1239    },
1240    json_get: BindingPower {
1241        left: 45,
1242        right: 46,
1243        assoc: Assoc::Left,
1244    },
1245    // `|` < `&` < `<< >>` < additive (`50`); `^` > multiplicative (`60`). Values chosen so
1246    // each pair's left rank orders correctly against its neighbours; all left-associative.
1247    bitwise_or: BindingPower {
1248        left: 42,
1249        right: 43,
1250        assoc: Assoc::Left,
1251    },
1252    bitwise_and: BindingPower {
1253        left: 44,
1254        right: 45,
1255        assoc: Assoc::Left,
1256    },
1257    bitwise_shift: BindingPower {
1258        left: 47,
1259        right: 48,
1260        assoc: Assoc::Left,
1261    },
1262    bitwise_xor: BindingPower {
1263        left: 65,
1264        right: 66,
1265        assoc: Assoc::Left,
1266    },
1267    // MySQL groups unary `~` with unary minus (the tight sign rank), not the loose
1268    // PostgreSQL/DuckDB placement.
1269    prefix_bitwise_not: 80,
1270    prefix_not: 30,
1271    prefix_sign: 80,
1272    at_time_zone: BindingPower {
1273        left: 70,
1274        right: 71,
1275        assoc: Assoc::Left,
1276    },
1277    collate: BindingPower {
1278        left: 74,
1279        right: 75,
1280        assoc: Assoc::Left,
1281    },
1282    subscript: BindingPower {
1283        left: 84,
1284        right: 85,
1285        assoc: Assoc::Left,
1286    },
1287    typecast: BindingPower {
1288        left: 88,
1289        right: 89,
1290        assoc: Assoc::Left,
1291    },
1292    field_selection: BindingPower {
1293        left: 92,
1294        right: 93,
1295        assoc: Assoc::Left,
1296    },
1297};
1298
1299impl FeatureSet {
1300    /// MySQL as dialect data: every parser/tokenizer choice below is read through
1301    /// [`FeatureSet`] fields, not a dialect-identity branch.
1302    pub const MYSQL: Self = Self {
1303        // MySQL columns/aliases compare case-insensitively while preserving the
1304        // written text; `Lower` models that identity (fold lower, render exact).
1305        identifier_casing: Casing::Lower,
1306        // Backtick only: `"` is a *string* under MySQL's default `ANSI_QUOTES`-off
1307        // mode (see `StringLiteralSyntax::MYSQL`), so it must not also quote idents.
1308        identifier_quotes: MYSQL_IDENTIFIER_QUOTES,
1309        // MySQL sorts NULLs first under ascending order (NULL ranks lowest).
1310        default_null_ordering: NullOrdering::NullsFirst,
1311        // MySQL's reserved-word set differs from the shared ANSI/PostgreSQL one in
1312        // both directions (it reserves `RLIKE`/`DIV`/`XOR`/… and frees
1313        // `OFFSET`/`SYMMETRIC`/…), so it gets its own per-position sets from the MySQL
1314        // reserved list (mysql-reserved-word-set), pinned toward the 8.4 LTS behaviour the
1315        // oracle runs: the set-op/sampling keywords `INTERSECT`/`PARALLEL`/`QUALIFY`/
1316        // `TABLESAMPLE` were added off an m3 sweep, then the three residual 8.4 over-
1317        // rejections that sweep found were closed (mysql-reserved-word-set-8-4-over-
1318        // rejections): `array` moved to the `function_only` class (reserved as a call head
1319        // only), and the removed-in-8.4 `MASTER_BIND`/`MASTER_SSL_VERIFY_SERVER_CERT`
1320        // replication words were dropped from the list entirely (see the CSV header).
1321        reserved_column_name: MYSQL_RESERVED_COLUMN_NAME,
1322        reserved_function_name: MYSQL_RESERVED_FUNCTION_NAME,
1323        reserved_type_name: MYSQL_RESERVED_TYPE_NAME,
1324        reserved_bare_alias: MYSQL_RESERVED_BARE_ALIAS,
1325        // MySQL rejects a reserved word as an `AS`-introduced alias (`SELECT 1 AS range`)
1326        // but *admits* one in the dotted-name-continuation position (`t.select` /
1327        // `schema.case` parse — engine-measured on mysql:8.4, only bind-failing; a full
1328        // 889-keyword m3 sweep confirms every keyword is admitted syntactically after a
1329        // dot). The two positions are split: the AS-alias projection is tightened by
1330        // [`SelectSyntax::as_alias_rejects_reserved`] (above) rerouting it to the stricter
1331        // `reserved_bare_alias`, so `reserved_as_label` governs only the permissive
1332        // dotted-continuation and stays empty (a non-empty set would over-reject the valid
1333        // dotted form).
1334        reserved_as_label: KeywordSet::EMPTY,
1335        // MySQL relation names are `db.table` — two parts at most; it has no catalog
1336        // qualifier, so a three-part `a.b.c` in table/index/view position is the syntax
1337        // error MySQL reports (engine-measured-rejected on mysql:8). Column references reach
1338        // one part deeper through a separate grammar position and are unaffected.
1339        catalog_qualified_names: false,
1340        // The shared M1 table plus the vertical tab (`0x0b`) in the whitespace class:
1341        // MySQL's tokenizer folds the same flex `space` set `[ \t\n\r\f\v]` as PostgreSQL,
1342        // and the vertical tab is the one member Rust's `is_ascii_whitespace` (hence
1343        // `STANDARD_BYTE_CLASSES`) omits. Engine-verified on the live `mysql:8` oracle: a
1344        // lone `0x0b` prepares as an empty statement and `SELECT\x0b1` prepares as
1345        // `SELECT 1`, while SQLite/DuckDB fold `0x0b` only position-dependently (their own
1346        // tables), so full whitespace-class membership rides only this table
1347        // (see [`MYSQL_BYTE_CLASSES`]). `#` comments, backtick quotes, `&&`, `$`-in-ident,
1348        // and `?` placeholders all still dispatch from the standard byte classes gated by
1349        // the knobs below — the vertical tab is the sole byte-class divergence.
1350        byte_classes: MYSQL_BYTE_CLASSES,
1351        // MySQL's comparison family is left-associative, not `STANDARD`'s
1352        // `NonAssoc` (`SELECT a < b < c` is legal MySQL meaning `(a < b) < c`),
1353        // so it needs its own table (`MYSQL_BINDING_POWERS`) rather than reusing
1354        // the shared one (ADR-0008; mysql-comparison-operators-are-left-associative).
1355        binding_powers: MYSQL_BINDING_POWERS,
1356        set_operation_powers: STANDARD_SET_OPERATION_BINDING_POWERS,
1357        string_literals: StringLiteralSyntax::MYSQL,
1358        numeric_literals: NumericLiteralSyntax::MYSQL,
1359        parameters: ParameterSyntax::MYSQL,
1360        session_variables: SessionVariableSyntax::MYSQL,
1361        // `$` as an identifier-continue byte is the same policy as PostgreSQL, but
1362        // MySQL keeps its own copy so this module never depends on `postgres`.
1363        identifier_syntax: IdentifierSyntax::MYSQL,
1364        // MySQL's join grammar adds the `STRAIGHT_JOIN` hint over the ANSI surface, so
1365        // it needs its own `TableExpressionSyntax` preset (`straight_join: true`)
1366        // rather than reusing `TableExpressionSyntax::ANSI` directly.
1367        table_expressions: TableExpressionSyntax::MYSQL,
1368        join_syntax: JoinSyntax::MYSQL,
1369        table_factor_syntax: TableFactorSyntax::MYSQL,
1370        // MySQL's aggregate grammar adds the `GROUP_CONCAT(... SEPARATOR …)` delimiter
1371        // over the ANSI expression surface, so it needs its own `ExpressionSyntax` preset
1372        // rather than reusing `ExpressionSyntax::ANSI` directly.
1373        expression_syntax: ExpressionSyntax::MYSQL,
1374        operator_syntax: OperatorSyntax::MYSQL,
1375        call_syntax: CallSyntax::MYSQL,
1376        string_func_forms: StringFuncForms::MYSQL,
1377        aggregate_call_syntax: AggregateCallSyntax::MYSQL,
1378        // MySQL has the standard `LIKE` predicate but neither `ILIKE` nor `SIMILAR TO`.
1379        predicate_syntax: PredicateSyntax::ANSI,
1380        pipe_operator: PipeOperator::LogicalOr,
1381        double_ampersand: DoubleAmpersand::LogicalAnd,
1382        keyword_operators: KeywordOperators::MySql,
1383        // MySQL spells bitwise XOR `^` (distinct from the logical `XOR` keyword above);
1384        // grammar-derived precedence (tighter than `*`) lives in `MYSQL_BINDING_POWERS`.
1385        caret_operator: CaretOperator::BitwiseXor,
1386        // MySQL's `#` is a line comment, so the PostgreSQL `#` XOR spelling is rejected.
1387        hash_bitwise_xor: false,
1388        comment_syntax: CommentSyntax::MYSQL,
1389        mutation_syntax: MutationSyntax::MYSQL,
1390        statement_ddl_gates: StatementDdlGates::MYSQL,
1391        view_sequence_clause_syntax: ViewSequenceClauseSyntax::MYSQL,
1392        create_table_clause_syntax: CreateTableClauseSyntax::MYSQL,
1393        column_definition_syntax: ColumnDefinitionSyntax::MYSQL,
1394        constraint_syntax: ConstraintSyntax::MYSQL,
1395        index_alter_syntax: IndexAlterSyntax::MYSQL,
1396        existence_guards: ExistenceGuards::MYSQL,
1397        select_syntax: SelectSyntax::MYSQL,
1398        query_tail_syntax: QueryTailSyntax::MYSQL,
1399        grouping_syntax: GroupingSyntax::MYSQL,
1400        // MySQL has no `COPY` (its bulk load is `LOAD DATA`) and none of the SQLite utility
1401        // statements, but it does have `KILL` and the `DESCRIBE`/`DESC` EXPLAIN synonyms, so
1402        // it takes its own preset (those two on, the rest off) rather than the ANSI baseline.
1403        utility_syntax: UtilitySyntax::MYSQL,
1404        transaction_syntax: TransactionSyntax::MYSQL,
1405        show_syntax: ShowSyntax::MYSQL,
1406        maintenance_syntax: MaintenanceSyntax::MYSQL,
1407        access_control_syntax: AccessControlSyntax::MYSQL,
1408        // The MySQL type-name vocabulary diverges from the shared standard set, so
1409        // it is recognized as its own gated data rather than reusing it.
1410        type_name_syntax: TypeNameSyntax::MYSQL,
1411        // No MySQL-specific Tier-1 output spelling yet: a target-dialect render of
1412        // MySQL falls back to the portable ANSI canonical spellings, exactly as it did
1413        // when the renderer only special-cased PostgreSQL.
1414        target_spelling: TargetSpelling::Ansi,
1415    };
1416}
1417
1418/// Prefer [`FeatureSet::MYSQL`] for struct update.
1419pub const MYSQL: FeatureSet = FeatureSet::MYSQL;
1420
1421// Compile-time proof the MySQL preset claims no shared tokenizer trigger twice —
1422// notably that `user_variables`/`system_variables` (on here) never meet a contending
1423// `named_at` or containment `<@`, and `double_quoted_strings` never meets a `"` quote.
1424// The ratchet fails the build if a future edit adds a conflict, rather than silently
1425// shadowing a meaning (uniform with `LENIENT`'s assert).
1426const _: () = assert!(FeatureSet::MYSQL.is_lexically_consistent());
1427// The two sibling self-consistency registries are ratcheted the same way, so the
1428// parse-entry `debug_assert!` folds all three to dead code for this preset: every
1429// refinement flag (`call_bare_name`, the account-grant grammar) rides its enabled base,
1430// and no two features contend for one parser-position head (`prepared_statements` and
1431// `do_statement` stay off, so the `FROM`/`USING` lifecycle and `DO` expression list are
1432// each unrivalled).
1433const _: () = assert!(FeatureSet::MYSQL.has_satisfied_feature_dependencies());
1434const _: () = assert!(FeatureSet::MYSQL.has_no_grammar_conflict());
1435
1436#[cfg(test)]
1437mod tests {
1438    use super::super::{
1439        Keyword, RESERVED_BARE_ALIAS, RESERVED_COLUMN_NAME, RESERVED_FUNCTION_NAME,
1440        RESERVED_TYPE_NAME,
1441    };
1442    use super::*;
1443    use crate::ast::{BinaryOperator, EqualsSpelling, RegexpSpelling};
1444    use crate::precedence::{STANDARD_BINDING_POWERS, Side};
1445
1446    #[test]
1447    fn mysql_reserved_sets_diverge_from_the_shared_sets_in_both_directions() {
1448        // Forward divergence (mysql-reserved-word-set): MySQL 8.0 reserves words the
1449        // shared ANSI/PostgreSQL model leaves free. They enter the shared inventory
1450        // but are reserved *only* under MySQL — every position rejects them, and the
1451        // shared sets do not.
1452        for keyword in [
1453            Keyword::Rlike,
1454            Keyword::Div,
1455            Keyword::Xor,
1456            Keyword::Zerofill,
1457        ] {
1458            assert!(MYSQL_RESERVED_COLUMN_NAME.contains(keyword));
1459            assert!(MYSQL_RESERVED_FUNCTION_NAME.contains(keyword));
1460            assert!(MYSQL_RESERVED_TYPE_NAME.contains(keyword));
1461            assert!(MYSQL_RESERVED_BARE_ALIAS.contains(keyword));
1462            assert!(!RESERVED_COLUMN_NAME.contains(keyword));
1463            assert!(!RESERVED_FUNCTION_NAME.contains(keyword));
1464            assert!(!RESERVED_TYPE_NAME.contains(keyword));
1465            assert!(!RESERVED_BARE_ALIAS.contains(keyword));
1466        }
1467
1468        // Reverse divergence: PostgreSQL reserves `OFFSET`/`SYMMETRIC`, MySQL does
1469        // not, so under MySQL they are free identifiers in every position.
1470        for keyword in [Keyword::Offset, Keyword::Symmetric] {
1471            assert!(RESERVED_COLUMN_NAME.contains(keyword));
1472            assert!(!MYSQL_RESERVED_COLUMN_NAME.contains(keyword));
1473            assert!(!MYSQL_RESERVED_FUNCTION_NAME.contains(keyword));
1474            assert!(!MYSQL_RESERVED_TYPE_NAME.contains(keyword));
1475            assert!(!MYSQL_RESERVED_BARE_ALIAS.contains(keyword));
1476        }
1477
1478        // Position-specific within MySQL: a `type_func_name` built-in (`LEFT`) is
1479        // rejected as a column/type/bare-alias name but admitted as a function name,
1480        // so `SELECT left FROM t` fails while `LEFT(s, 3)` parses.
1481        assert!(MYSQL_RESERVED_COLUMN_NAME.contains(Keyword::Left));
1482        assert!(MYSQL_RESERVED_TYPE_NAME.contains(Keyword::Left));
1483        assert!(MYSQL_RESERVED_BARE_ALIAS.contains(Keyword::Left));
1484        assert!(!MYSQL_RESERVED_FUNCTION_NAME.contains(Keyword::Left));
1485    }
1486
1487    #[test]
1488    fn mysql_window_function_names_are_admitted_only_as_call_heads() {
1489        // The 11 dedicated window-function names are fully reserved words (rejected as a
1490        // column/type/bare-alias name, matching `SELECT ROW_NUMBER` / `AS row_number` →
1491        // ER_PARSE_ERROR on mysql:8) but are carved out of the function-name reject so
1492        // MySQL's dedicated window grammar can admit `ROW_NUMBER() OVER (…)` as a call
1493        // head (mysql-reserved-window-function-names).
1494        for keyword in [
1495            Keyword::RowNumber,
1496            Keyword::Rank,
1497            Keyword::DenseRank,
1498            Keyword::PercentRank,
1499            Keyword::CumeDist,
1500            Keyword::Ntile,
1501            Keyword::Lead,
1502            Keyword::Lag,
1503            Keyword::FirstValue,
1504            Keyword::LastValue,
1505            Keyword::NthValue,
1506        ] {
1507            assert!(
1508                MYSQL_RESERVED_KEYWORDS.contains(keyword),
1509                "{keyword:?} is a fully reserved MySQL word",
1510            );
1511            assert!(
1512                !MYSQL_RESERVED_FUNCTION_NAME.contains(keyword),
1513                "{keyword:?} must be admissible as a call head",
1514            );
1515            assert!(
1516                MYSQL_RESERVED_COLUMN_NAME.contains(keyword),
1517                "{keyword:?} must stay reserved as a column name",
1518            );
1519            assert!(
1520                MYSQL_RESERVED_TYPE_NAME.contains(keyword),
1521                "{keyword:?} must stay reserved as a type name",
1522            );
1523            assert!(
1524                MYSQL_RESERVED_BARE_ALIAS.contains(keyword),
1525                "{keyword:?} must stay reserved as a bare/AS alias",
1526            );
1527        }
1528        // The carve-out removes *only* the 11 window names: another fully reserved word
1529        // (`SELECT`) is still rejected as a call head.
1530        assert!(MYSQL_RESERVED_FUNCTION_NAME.contains(Keyword::Select));
1531    }
1532
1533    #[test]
1534    fn mysql_array_is_reserved_only_as_a_call_head() {
1535        // The inverse of the `type_func_name` carve-out (mysql-reserved-word-set-8-4-over-
1536        // rejections): MySQL 8.4 admits `array` as a plain identifier in every position
1537        // (`SELECT 1 AS array` / `SELECT 1 array` both prepare, engine-verified on 8.4.10)
1538        // but syntax-rejects `array(...)` as a call (1064). The `function_only` class adds it
1539        // to the function-name reject set alone, closing both the `ARRAY(...)` over-acceptance
1540        // and the `AS array` over-rejection at once.
1541        assert!(
1542            MYSQL_RESERVED_FUNCTION_NAME.contains(Keyword::Array),
1543            "array must be rejected as a call head",
1544        );
1545        assert!(
1546            !MYSQL_RESERVED_COLUMN_NAME.contains(Keyword::Array),
1547            "array must be admissible as a column name",
1548        );
1549        assert!(
1550            !MYSQL_RESERVED_TYPE_NAME.contains(Keyword::Array),
1551            "array must be admissible as a type name",
1552        );
1553        assert!(
1554            !MYSQL_RESERVED_BARE_ALIAS.contains(Keyword::Array),
1555            "array must be admissible as a bare/AS alias",
1556        );
1557    }
1558
1559    #[test]
1560    fn mysql_binding_powers_differ_from_standard_in_comparison_assoc_and_bitwise_ranks() {
1561        use crate::ast::NotEqSpelling;
1562        // Two documented delta families over STANDARD: the comparison-row associativity
1563        // (`NonAssoc` -> `Left`, mysql-comparison-operators-are-left-associative) and the
1564        // four-way bitwise precedence split MySQL's grammar mandates. Mutating a copy of
1565        // STANDARD pins the exact shape rather than trusting the struct update by inspection.
1566        let mut expected = STANDARD_BINDING_POWERS;
1567        expected.comparison.assoc = Assoc::Left;
1568        // `==` rides the comparison row with `=` (the `double_equals` field tracks
1569        // `comparison`); MySQL does not even lex `==`, but the shape must still match.
1570        expected.double_equals.assoc = Assoc::Left;
1571        expected.bitwise_or = BindingPower {
1572            left: 42,
1573            right: 43,
1574            assoc: Assoc::Left,
1575        };
1576        expected.bitwise_and = BindingPower {
1577            left: 44,
1578            right: 45,
1579            assoc: Assoc::Left,
1580        };
1581        expected.bitwise_shift = BindingPower {
1582            left: 47,
1583            right: 48,
1584            assoc: Assoc::Left,
1585        };
1586        expected.bitwise_xor = BindingPower {
1587            left: 65,
1588            right: 66,
1589            assoc: Assoc::Left,
1590        };
1591        expected.prefix_bitwise_not = 80;
1592        assert_eq!(MYSQL_BINDING_POWERS, expected);
1593
1594        // The load-bearing MySQL ordering (grammar-derived): `|` < `&` < `<<`/`>>` <
1595        // additive, and `^` (XOR) tighter than multiplicative — the split that makes
1596        // `1 | 2 & 3` group `1 | (2 & 3)` where PostgreSQL/SQLite group `(1 | 2) & 3`.
1597        use crate::ast::BitwiseXorSpelling;
1598        let or = MYSQL_BINDING_POWERS.binary(&BinaryOperator::BitwiseOr);
1599        let and = MYSQL_BINDING_POWERS.binary(&BinaryOperator::BitwiseAnd);
1600        let shift = MYSQL_BINDING_POWERS.binary(&BinaryOperator::BitwiseShiftLeft);
1601        let add = MYSQL_BINDING_POWERS.binary(&BinaryOperator::Plus);
1602        let mul = MYSQL_BINDING_POWERS.binary(&BinaryOperator::Multiply);
1603        let xor =
1604            MYSQL_BINDING_POWERS.binary(&BinaryOperator::BitwiseXor(BitwiseXorSpelling::Caret));
1605        assert!(or.left < and.left, "`|` looser than `&`");
1606        assert!(and.left < shift.left, "`&` looser than `<<`/`>>`");
1607        assert!(shift.left < add.left, "shift looser than additive");
1608        assert!(mul.left < xor.left, "`^` tighter than multiplicative");
1609
1610        // Every comparison operator — and `RLIKE`/`REGEXP`, which folds onto the
1611        // same row (`crate::precedence::BindingPowerTable::binary`) — rides the
1612        // delta together: MySQL ranks the whole family at one precedence level
1613        // where "operators of equal precedence evaluate left to right" (MySQL
1614        // Reference Manual 12.3.1), which is exactly `Assoc::Left`.
1615        for op in [
1616            BinaryOperator::Eq(EqualsSpelling::Single),
1617            BinaryOperator::NotEq(NotEqSpelling::AngleBracket),
1618            BinaryOperator::Lt,
1619            BinaryOperator::LtEq,
1620            BinaryOperator::Gt,
1621            BinaryOperator::GtEq,
1622            BinaryOperator::Regexp(RegexpSpelling::Rlike),
1623            BinaryOperator::Regexp(RegexpSpelling::Regexp),
1624        ] {
1625            let bp = MYSQL_BINDING_POWERS.binary(&op);
1626            assert_eq!(bp.assoc, Assoc::Left, "{op:?} should be left-associative");
1627            assert_eq!(bp.left, 40, "{op:?} keeps the STANDARD left rank");
1628            assert_eq!(bp.right, 41, "{op:?} keeps the STANDARD right rank");
1629        }
1630
1631        // `predicate()` (`IS [NOT] NULL` / `[NOT] BETWEEN` / `[NOT] IN`) is a
1632        // derived accessor onto `comparison`, so it rides the same delta without a
1633        // second field to keep in sync.
1634        assert_eq!(MYSQL_BINDING_POWERS.predicate().assoc, Assoc::Left);
1635
1636        // Render-time parenthesization (the other half of the one binding-power
1637        // table, ADR-0008) picks the delta up automatically: a same-precedence
1638        // child on the left needs no parens under `Left` (`a < b < c` renders
1639        // bare), while the right still does (`a < (b < c)` keeps its parens
1640        // either way — that side was never where NonAssoc vs. Left differed).
1641        assert!(!MYSQL_BINDING_POWERS.needs_parens(
1642            &BinaryOperator::Lt,
1643            &BinaryOperator::Eq(EqualsSpelling::Single),
1644            Side::Left
1645        ));
1646        assert!(MYSQL_BINDING_POWERS.needs_parens(
1647            &BinaryOperator::Lt,
1648            &BinaryOperator::Eq(EqualsSpelling::Single),
1649            Side::Right
1650        ));
1651    }
1652}