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