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