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}