Skip to main content

polyglot_sql/dialects/
mysql.rs

1//! MySQL Dialect
2//!
3//! MySQL-specific transformations based on sqlglot patterns.
4//! Key differences from standard SQL:
5//! - || is OR operator, not string concatenation (use CONCAT)
6//! - Uses backticks for identifiers
7//! - No TRY_CAST, no ILIKE
8//! - Different date/time function names
9
10use super::{DialectImpl, DialectType};
11use crate::error::Result;
12use crate::expressions::{
13    BinaryFunc, BinaryOp, Cast, DataType, Expression, Function, JsonExtractFunc, LikeOp, Literal,
14    Paren, UnaryFunc,
15};
16use crate::generator::GeneratorConfig;
17use crate::tokens::TokenizerConfig;
18
19/// Helper to wrap JSON arrow expressions in parentheses when they appear
20/// in contexts that require it (Binary, In, Not expressions)
21/// This matches Python sqlglot's WRAPPED_JSON_EXTRACT_EXPRESSIONS behavior
22fn wrap_if_json_arrow(expr: Expression) -> Expression {
23    match &expr {
24        Expression::JsonExtract(f) if f.arrow_syntax => Expression::Paren(Box::new(Paren {
25            this: expr,
26            trailing_comments: Vec::new(),
27        })),
28        Expression::JsonExtractScalar(f) if f.arrow_syntax => Expression::Paren(Box::new(Paren {
29            this: expr,
30            trailing_comments: Vec::new(),
31        })),
32        _ => expr,
33    }
34}
35
36/// Convert JSON arrow expression (-> or ->>) to JSON_EXTRACT function form
37/// This is needed for contexts like MEMBER OF where arrow syntax must become function form
38fn json_arrow_to_function(expr: Expression) -> Expression {
39    match expr {
40        Expression::JsonExtract(f) if f.arrow_syntax => Expression::Function(Box::new(
41            Function::new("JSON_EXTRACT".to_string(), vec![f.this, f.path]),
42        )),
43        Expression::JsonExtractScalar(f) if f.arrow_syntax => {
44            // ->> becomes JSON_UNQUOTE(JSON_EXTRACT(...)) but can be simplified to JSON_EXTRACT_SCALAR
45            // For MySQL, use JSON_UNQUOTE(JSON_EXTRACT(...))
46            let json_extract = Expression::Function(Box::new(Function::new(
47                "JSON_EXTRACT".to_string(),
48                vec![f.this, f.path],
49            )));
50            Expression::Function(Box::new(Function::new(
51                "JSON_UNQUOTE".to_string(),
52                vec![json_extract],
53            )))
54        }
55        other => other,
56    }
57}
58
59/// MySQL dialect
60pub struct MySQLDialect;
61
62impl DialectImpl for MySQLDialect {
63    fn dialect_type(&self) -> DialectType {
64        DialectType::MySQL
65    }
66
67    fn tokenizer_config(&self) -> TokenizerConfig {
68        use crate::tokens::TokenType;
69        let mut config = TokenizerConfig::default();
70        // MySQL uses backticks for identifiers
71        config.identifiers.insert('`', '`');
72        // Remove double quotes from identifiers - in MySQL they are string delimiters
73        // (unless ANSI_QUOTES mode is set, but default mode uses them as strings)
74        config.identifiers.remove(&'"');
75        // MySQL supports double quotes as string literals by default
76        config.quotes.insert("\"".to_string(), "\"".to_string());
77        // MySQL supports backslash escapes in strings
78        config.string_escapes.push('\\');
79        // MySQL has XOR as a logical operator keyword
80        config.keywords.insert("XOR".to_string(), TokenType::Xor);
81        // MySQL: backslash followed by chars NOT in this list -> discard backslash
82        // See: https://dev.mysql.com/doc/refman/8.4/en/string-literals.html
83        config.escape_follow_chars = vec!['0', 'b', 'n', 'r', 't', 'Z', '%', '_'];
84        // MySQL allows identifiers to start with digits (e.g., 1a, 1_a)
85        config.identifiers_can_start_with_digit = true;
86        config.hex_number_strings = true;
87        config
88    }
89
90    fn generator_config(&self) -> GeneratorConfig {
91        use crate::generator::IdentifierQuoteStyle;
92        GeneratorConfig {
93            identifier_quote: '`',
94            identifier_quote_style: IdentifierQuoteStyle::BACKTICK,
95            dialect: Some(DialectType::MySQL),
96            // MySQL doesn't support null ordering in most contexts
97            null_ordering_supported: false,
98            // MySQL LIMIT only
99            limit_only_literals: true,
100            // MySQL doesn't support semi/anti join
101            semi_anti_join_with_side: false,
102            // MySQL doesn't support table alias columns in some contexts
103            supports_table_alias_columns: false,
104            // MySQL VALUES not used as table
105            values_as_table: false,
106            // MySQL doesn't support TABLESAMPLE
107            tablesample_requires_parens: false,
108            tablesample_with_method: false,
109            // MySQL doesn't support aggregate FILTER
110            aggregate_filter_supported: false,
111            // MySQL doesn't support TRY
112            try_supported: false,
113            // MySQL doesn't support CONVERT_TIMEZONE
114            supports_convert_timezone: false,
115            // MySQL doesn't support UESCAPE
116            supports_uescape: false,
117            // MySQL doesn't support BETWEEN flags
118            supports_between_flags: false,
119            // MySQL supports EXPLAIN but not query hints in standard way
120            query_hints: false,
121            // MySQL parameter token
122            parameter_token: "?",
123            // MySQL doesn't support window EXCLUDE
124            supports_window_exclude: false,
125            // MySQL doesn't support exploding projections
126            supports_exploding_projections: false,
127            identifiers_can_start_with_digit: true,
128            // MySQL supports FOR UPDATE/SHARE
129            locking_reads_supported: true,
130            ..Default::default()
131        }
132    }
133
134    fn transform_expr(&self, expr: Expression) -> Result<Expression> {
135        match expr {
136            // ===== Data Type Mappings =====
137            Expression::DataType(dt) => self.transform_data_type(dt),
138
139            // NVL -> IFNULL in MySQL
140            Expression::Nvl(f) => Ok(Expression::IfNull(f)),
141
142            // Note: COALESCE is valid in MySQL and should be preserved.
143            // Unlike some other dialects, we do NOT convert COALESCE to IFNULL
144            // as this would break identity tests.
145
146            // TryCast -> CAST or TIMESTAMP() (MySQL doesn't support TRY_CAST)
147            Expression::TryCast(c) => self.transform_cast(*c),
148
149            // SafeCast -> CAST or TIMESTAMP() (MySQL doesn't support safe casts)
150            Expression::SafeCast(c) => self.transform_cast(*c),
151
152            // Cast -> Transform cast type according to MySQL restrictions
153            // CAST AS TIMESTAMP -> TIMESTAMP() function in MySQL
154            Expression::Cast(c) => self.transform_cast(*c),
155
156            // ILIKE -> LOWER() LIKE LOWER() in MySQL
157            Expression::ILike(op) => {
158                // Transform ILIKE to: LOWER(left) LIKE LOWER(right)
159                let lower_left = Expression::Lower(Box::new(UnaryFunc::new(op.left)));
160                let lower_right = Expression::Lower(Box::new(UnaryFunc::new(op.right)));
161                Ok(Expression::Like(Box::new(LikeOp {
162                    left: lower_left,
163                    right: lower_right,
164                    escape: op.escape,
165                    quantifier: op.quantifier,
166                    inferred_type: None,
167                })))
168            }
169
170            // Preserve semantic string concatenation expressions.
171            // MySQL generation renders these as CONCAT(...).
172            Expression::Concat(op) => Ok(Expression::Concat(op)),
173
174            // RANDOM -> RAND in MySQL
175            Expression::Random(_) => Ok(Expression::Rand(Box::new(crate::expressions::Rand {
176                seed: None,
177                lower: None,
178                upper: None,
179            }))),
180
181            // ArrayAgg -> GROUP_CONCAT in MySQL
182            Expression::ArrayAgg(f) => Ok(Expression::Function(Box::new(Function::new(
183                "GROUP_CONCAT".to_string(),
184                vec![f.this],
185            )))),
186
187            // StringAgg -> GROUP_CONCAT in MySQL
188            Expression::StringAgg(f) => {
189                let mut args = vec![f.this.clone()];
190                if let Some(separator) = &f.separator {
191                    args.push(separator.clone());
192                }
193                Ok(Expression::Function(Box::new(Function::new(
194                    "GROUP_CONCAT".to_string(),
195                    args,
196                ))))
197            }
198
199            // UNNEST -> Not directly supported in MySQL, use JSON_TABLE or inline
200            // For basic cases, pass through (may need manual handling)
201            Expression::Unnest(f) => {
202                // MySQL 8.0+ has JSON_TABLE which can be used for unnesting
203                // For now, pass through with a function call
204                Ok(Expression::Function(Box::new(Function::new(
205                    "JSON_TABLE".to_string(),
206                    vec![f.this],
207                ))))
208            }
209
210            // Substring: Use comma syntax (not FROM/FOR) in MySQL
211            Expression::Substring(mut f) => {
212                f.from_for_syntax = false;
213                Ok(Expression::Substring(f))
214            }
215
216            // ===== Bitwise operations =====
217            // BitwiseAndAgg -> BIT_AND
218            Expression::BitwiseAndAgg(f) => Ok(Expression::Function(Box::new(Function::new(
219                "BIT_AND".to_string(),
220                vec![f.this],
221            )))),
222
223            // BitwiseOrAgg -> BIT_OR
224            Expression::BitwiseOrAgg(f) => Ok(Expression::Function(Box::new(Function::new(
225                "BIT_OR".to_string(),
226                vec![f.this],
227            )))),
228
229            // BitwiseXorAgg -> BIT_XOR
230            Expression::BitwiseXorAgg(f) => Ok(Expression::Function(Box::new(Function::new(
231                "BIT_XOR".to_string(),
232                vec![f.this],
233            )))),
234
235            // BitwiseCount -> BIT_COUNT
236            Expression::BitwiseCount(f) => Ok(Expression::Function(Box::new(Function::new(
237                "BIT_COUNT".to_string(),
238                vec![f.this],
239            )))),
240
241            // TimeFromParts -> MAKETIME
242            Expression::TimeFromParts(f) => {
243                let mut args = Vec::new();
244                if let Some(h) = f.hour {
245                    args.push(*h);
246                }
247                if let Some(m) = f.min {
248                    args.push(*m);
249                }
250                if let Some(s) = f.sec {
251                    args.push(*s);
252                }
253                Ok(Expression::Function(Box::new(Function::new(
254                    "MAKETIME".to_string(),
255                    args,
256                ))))
257            }
258
259            // ===== Boolean aggregates =====
260            // In MySQL, there's no BOOL_AND/BOOL_OR, use MIN/MAX on boolean values
261            // LogicalAnd -> MIN (0 is false, non-0 is true)
262            Expression::LogicalAnd(f) => Ok(Expression::Function(Box::new(Function::new(
263                "MIN".to_string(),
264                vec![f.this],
265            )))),
266
267            // LogicalOr -> MAX
268            Expression::LogicalOr(f) => Ok(Expression::Function(Box::new(Function::new(
269                "MAX".to_string(),
270                vec![f.this],
271            )))),
272
273            // ===== Date/time functions =====
274            // DayOfMonth -> DAYOFMONTH
275            Expression::DayOfMonth(f) => Ok(Expression::Function(Box::new(Function::new(
276                "DAYOFMONTH".to_string(),
277                vec![f.this],
278            )))),
279
280            // DayOfWeek -> DAYOFWEEK
281            Expression::DayOfWeek(f) => Ok(Expression::Function(Box::new(Function::new(
282                "DAYOFWEEK".to_string(),
283                vec![f.this],
284            )))),
285
286            // DayOfYear -> DAYOFYEAR
287            Expression::DayOfYear(f) => Ok(Expression::Function(Box::new(Function::new(
288                "DAYOFYEAR".to_string(),
289                vec![f.this],
290            )))),
291
292            // WeekOfYear -> WEEKOFYEAR
293            Expression::WeekOfYear(f) => Ok(Expression::Function(Box::new(Function::new(
294                "WEEKOFYEAR".to_string(),
295                vec![f.this],
296            )))),
297
298            // DateDiff -> DATEDIFF
299            Expression::DateDiff(f) => Ok(Expression::Function(Box::new(Function::new(
300                "DATEDIFF".to_string(),
301                vec![f.this, f.expression],
302            )))),
303
304            // TimeStrToUnix -> UNIX_TIMESTAMP
305            Expression::TimeStrToUnix(f) => Ok(Expression::Function(Box::new(Function::new(
306                "UNIX_TIMESTAMP".to_string(),
307                vec![f.this],
308            )))),
309
310            // TimestampDiff -> TIMESTAMPDIFF
311            Expression::TimestampDiff(f) => Ok(Expression::Function(Box::new(Function::new(
312                "TIMESTAMPDIFF".to_string(),
313                vec![*f.this, *f.expression],
314            )))),
315
316            // ===== String functions =====
317            // StrPosition -> LOCATE in MySQL
318            // STRPOS(str, substr) -> LOCATE(substr, str) (args are swapped)
319            Expression::StrPosition(f) => {
320                let mut args = vec![];
321                if let Some(substr) = f.substr {
322                    args.push(*substr);
323                }
324                args.push(*f.this);
325                if let Some(pos) = f.position {
326                    args.push(*pos);
327                }
328                Ok(Expression::Function(Box::new(Function::new(
329                    "LOCATE".to_string(),
330                    args,
331                ))))
332            }
333
334            // Stuff -> INSERT in MySQL
335            Expression::Stuff(f) => {
336                let mut args = vec![*f.this];
337                if let Some(start) = f.start {
338                    args.push(*start);
339                }
340                if let Some(length) = f.length {
341                    args.push(Expression::number(length));
342                }
343                args.push(*f.expression);
344                Ok(Expression::Function(Box::new(Function::new(
345                    "INSERT".to_string(),
346                    args,
347                ))))
348            }
349
350            // ===== Session/User functions =====
351            // SessionUser -> SESSION_USER()
352            Expression::SessionUser(_) => Ok(Expression::Function(Box::new(Function::new(
353                "SESSION_USER".to_string(),
354                vec![],
355            )))),
356
357            // CurrentDate -> CURRENT_DATE (no parentheses in MySQL) - keep as CurrentDate
358            Expression::CurrentDate(_) => {
359                Ok(Expression::CurrentDate(crate::expressions::CurrentDate))
360            }
361
362            // ===== Null-safe comparison =====
363            // NullSafeNeq -> NOT (a <=> b) in MySQL
364            Expression::NullSafeNeq(op) => {
365                // Create: NOT (left <=> right)
366                let null_safe_eq = Expression::NullSafeEq(Box::new(crate::expressions::BinaryOp {
367                    left: op.left,
368                    right: op.right,
369                    left_comments: Vec::new(),
370                    operator_comments: Vec::new(),
371                    trailing_comments: Vec::new(),
372                    inferred_type: None,
373                }));
374                Ok(Expression::Not(Box::new(crate::expressions::UnaryOp {
375                    this: null_safe_eq,
376                    inferred_type: None,
377                })))
378            }
379
380            // ParseJson: handled by generator (emits just the string literal for MySQL)
381
382            // JSONExtract with variant_extract (Snowflake colon syntax) -> JSON_EXTRACT
383            Expression::JSONExtract(e) if e.variant_extract.is_some() => {
384                let path = match *e.expression {
385                    Expression::Literal(lit) if matches!(lit.as_ref(), Literal::String(_)) => {
386                        let Literal::String(s) = lit.as_ref() else {
387                            unreachable!()
388                        };
389                        // Convert bracket notation ["key"] to quoted dot notation ."key"
390                        let s = Self::convert_bracket_to_quoted_path(&s);
391                        let normalized = if s.starts_with('$') {
392                            s
393                        } else if s.starts_with('[') {
394                            format!("${}", s)
395                        } else {
396                            format!("$.{}", s)
397                        };
398                        Expression::Literal(Box::new(Literal::String(normalized)))
399                    }
400                    other => other,
401                };
402                Ok(Expression::Function(Box::new(Function::new(
403                    "JSON_EXTRACT".to_string(),
404                    vec![*e.this, path],
405                ))))
406            }
407
408            // Generic function transformations
409            Expression::Function(f) => self.transform_function(*f),
410
411            // Generic aggregate function transformations
412            Expression::AggregateFunction(f) => self.transform_aggregate_function(f),
413
414            // ===== Context-aware JSON arrow wrapping =====
415            // When JSON arrow expressions appear in Binary/In/Not contexts,
416            // they need to be wrapped in parentheses for correct precedence.
417            // This matches Python sqlglot's WRAPPED_JSON_EXTRACT_EXPRESSIONS behavior.
418
419            // Binary operators that need JSON wrapping
420            Expression::Eq(op) => Ok(Expression::Eq(Box::new(BinaryOp {
421                left: wrap_if_json_arrow(op.left),
422                right: wrap_if_json_arrow(op.right),
423                ..*op
424            }))),
425            Expression::Neq(op) => Ok(Expression::Neq(Box::new(BinaryOp {
426                left: wrap_if_json_arrow(op.left),
427                right: wrap_if_json_arrow(op.right),
428                ..*op
429            }))),
430            Expression::Lt(op) => Ok(Expression::Lt(Box::new(BinaryOp {
431                left: wrap_if_json_arrow(op.left),
432                right: wrap_if_json_arrow(op.right),
433                ..*op
434            }))),
435            Expression::Lte(op) => Ok(Expression::Lte(Box::new(BinaryOp {
436                left: wrap_if_json_arrow(op.left),
437                right: wrap_if_json_arrow(op.right),
438                ..*op
439            }))),
440            Expression::Gt(op) => Ok(Expression::Gt(Box::new(BinaryOp {
441                left: wrap_if_json_arrow(op.left),
442                right: wrap_if_json_arrow(op.right),
443                ..*op
444            }))),
445            Expression::Gte(op) => Ok(Expression::Gte(Box::new(BinaryOp {
446                left: wrap_if_json_arrow(op.left),
447                right: wrap_if_json_arrow(op.right),
448                ..*op
449            }))),
450
451            // In expression - wrap the this part if it's JSON arrow
452            Expression::In(mut i) => {
453                i.this = wrap_if_json_arrow(i.this);
454                Ok(Expression::In(i))
455            }
456
457            // Not expression - wrap the this part if it's JSON arrow
458            Expression::Not(mut n) => {
459                n.this = wrap_if_json_arrow(n.this);
460                Ok(Expression::Not(n))
461            }
462
463            // && in MySQL is logical AND, not array overlaps
464            // Transform ArrayOverlaps -> And for MySQL identity
465            Expression::ArrayOverlaps(op) => Ok(Expression::And(op)),
466
467            // MOD(x, y) -> x % y in MySQL
468            Expression::ModFunc(f) => Ok(Expression::Mod(Box::new(BinaryOp {
469                left: f.this,
470                right: f.expression,
471                left_comments: Vec::new(),
472                operator_comments: Vec::new(),
473                trailing_comments: Vec::new(),
474                inferred_type: None,
475            }))),
476
477            // SHOW SLAVE STATUS -> SHOW REPLICA STATUS
478            Expression::Show(mut s) => {
479                if s.this == "SLAVE STATUS" {
480                    s.this = "REPLICA STATUS".to_string();
481                }
482                if matches!(s.this.as_str(), "INDEX" | "COLUMNS") && s.db.is_none() {
483                    if let Some(Expression::Table(mut t)) = s.target.take() {
484                        if let Some(db_ident) = t.schema.take().or(t.catalog.take()) {
485                            s.db = Some(Expression::Identifier(db_ident));
486                            s.target = Some(Expression::Identifier(t.name));
487                        } else {
488                            s.target = Some(Expression::Table(t));
489                        }
490                    }
491                }
492                Ok(Expression::Show(s))
493            }
494
495            // AT TIME ZONE -> strip timezone (MySQL doesn't support AT TIME ZONE)
496            // But keep it for CURRENT_DATE/CURRENT_TIMESTAMP with timezone (transpiled from BigQuery)
497            Expression::AtTimeZone(atz) => {
498                let is_current = match &atz.this {
499                    Expression::CurrentDate(_) | Expression::CurrentTimestamp(_) => true,
500                    Expression::Function(f) => {
501                        let n = f.name.to_uppercase();
502                        (n == "CURRENT_DATE" || n == "CURRENT_TIMESTAMP") && f.no_parens
503                    }
504                    _ => false,
505                };
506                if is_current {
507                    Ok(Expression::AtTimeZone(atz)) // Keep AT TIME ZONE for CURRENT_DATE/CURRENT_TIMESTAMP
508                } else {
509                    Ok(atz.this) // Strip timezone for other expressions
510                }
511            }
512
513            // MEMBER OF with JSON arrow -> convert arrow to JSON_EXTRACT function
514            // MySQL's MEMBER OF requires JSON_EXTRACT function form, not arrow syntax
515            Expression::MemberOf(mut op) => {
516                op.right = json_arrow_to_function(op.right);
517                Ok(Expression::MemberOf(op))
518            }
519
520            // Pass through everything else
521            _ => Ok(expr),
522        }
523    }
524}
525
526impl MySQLDialect {
527    fn normalize_mysql_date_format(fmt: &str) -> String {
528        fmt.replace("%H:%i:%s", "%T").replace("%H:%i:%S", "%T")
529    }
530
531    /// Convert bracket notation ["key with spaces"] to quoted dot notation ."key with spaces"
532    /// in JSON path strings.
533    fn convert_bracket_to_quoted_path(path: &str) -> String {
534        let mut result = String::new();
535        let mut chars = path.chars().peekable();
536        while let Some(c) = chars.next() {
537            if c == '[' && chars.peek() == Some(&'"') {
538                chars.next(); // consume "
539                let mut key = String::new();
540                while let Some(kc) = chars.next() {
541                    if kc == '"' && chars.peek() == Some(&']') {
542                        chars.next(); // consume ]
543                        break;
544                    }
545                    key.push(kc);
546                }
547                if !result.is_empty() && !result.ends_with('.') {
548                    result.push('.');
549                }
550                result.push('"');
551                result.push_str(&key);
552                result.push('"');
553            } else {
554                result.push(c);
555            }
556        }
557        result
558    }
559
560    /// Transform data types according to MySQL TYPE_MAPPING
561    /// Note: MySQL's TIMESTAMP is kept as TIMESTAMP (not converted to DATETIME)
562    /// because MySQL's TIMESTAMP has timezone awareness built-in
563    fn transform_data_type(&self, dt: crate::expressions::DataType) -> Result<Expression> {
564        use crate::expressions::DataType;
565        let transformed = match dt {
566            // All TIMESTAMP variants (with or without timezone) -> TIMESTAMP in MySQL
567            DataType::Timestamp {
568                precision,
569                timezone: _,
570            } => DataType::Timestamp {
571                precision,
572                timezone: false,
573            },
574            // TIMESTAMPTZ / TIMESTAMPLTZ parsed as Custom -> normalize to TIMESTAMP
575            DataType::Custom { name }
576                if name.to_uppercase() == "TIMESTAMPTZ"
577                    || name.to_uppercase() == "TIMESTAMPLTZ" =>
578            {
579                DataType::Timestamp {
580                    precision: None,
581                    timezone: false,
582                }
583            }
584            // Keep native MySQL types as-is
585            // MySQL supports TEXT, MEDIUMTEXT, LONGTEXT, BLOB, etc. natively
586            other => other,
587        };
588        Ok(Expression::DataType(transformed))
589    }
590
591    /// Transform CAST expression
592    /// MySQL uses TIMESTAMP() function instead of CAST(x AS TIMESTAMP)
593    /// For Generic->MySQL, TIMESTAMP (no tz) is pre-converted to DATETIME in cross_dialect_normalize
594    fn transform_cast(&self, cast: Cast) -> Result<Expression> {
595        // CAST AS TIMESTAMP/TIMESTAMPTZ/TIMESTAMPLTZ -> TIMESTAMP() function
596        match &cast.to {
597            DataType::Timestamp { .. } => Ok(Expression::Function(Box::new(Function::new(
598                "TIMESTAMP".to_string(),
599                vec![cast.this],
600            )))),
601            DataType::Custom { name }
602                if name.to_uppercase() == "TIMESTAMPTZ"
603                    || name.to_uppercase() == "TIMESTAMPLTZ" =>
604            {
605                Ok(Expression::Function(Box::new(Function::new(
606                    "TIMESTAMP".to_string(),
607                    vec![cast.this],
608                ))))
609            }
610            // All other casts go through normal type transformation
611            _ => Ok(Expression::Cast(Box::new(self.transform_cast_type(cast)))),
612        }
613    }
614
615    /// Transform CAST type according to MySQL restrictions
616    /// MySQL doesn't support many types in CAST - they get mapped to CHAR or SIGNED
617    /// Based on Python sqlglot's CHAR_CAST_MAPPING and SIGNED_CAST_MAPPING
618    fn transform_cast_type(&self, cast: Cast) -> Cast {
619        let new_type = match &cast.to {
620            // CHAR_CAST_MAPPING: These types become CHAR in MySQL CAST, preserving length
621            DataType::VarChar { length, .. } => DataType::Char { length: *length },
622            DataType::Text => DataType::Char { length: None },
623
624            // SIGNED_CAST_MAPPING: These integer types become SIGNED in MySQL CAST
625            DataType::BigInt { .. } => DataType::Custom {
626                name: "SIGNED".to_string(),
627            },
628            DataType::Int { .. } => DataType::Custom {
629                name: "SIGNED".to_string(),
630            },
631            DataType::SmallInt { .. } => DataType::Custom {
632                name: "SIGNED".to_string(),
633            },
634            DataType::TinyInt { .. } => DataType::Custom {
635                name: "SIGNED".to_string(),
636            },
637            DataType::Boolean => DataType::Custom {
638                name: "SIGNED".to_string(),
639            },
640
641            // Custom types that need mapping
642            DataType::Custom { name } => {
643                let upper = name.to_uppercase();
644                match upper.as_str() {
645                    // Text/Blob types -> keep as Custom for cross-dialect mapping
646                    // MySQL generator will output CHAR for these in CAST context
647                    "LONGTEXT" | "MEDIUMTEXT" | "TINYTEXT" | "LONGBLOB" | "MEDIUMBLOB"
648                    | "TINYBLOB" => DataType::Custom { name: upper },
649                    // MEDIUMINT -> SIGNED in MySQL CAST
650                    "MEDIUMINT" => DataType::Custom {
651                        name: "SIGNED".to_string(),
652                    },
653                    // Unsigned integer types -> UNSIGNED
654                    "UBIGINT" | "UINT" | "USMALLINT" | "UTINYINT" | "UMEDIUMINT" => {
655                        DataType::Custom {
656                            name: "UNSIGNED".to_string(),
657                        }
658                    }
659                    // Keep other custom types
660                    _ => cast.to.clone(),
661                }
662            }
663
664            // Types that are valid in MySQL CAST - pass through
665            DataType::Binary { .. } => cast.to.clone(),
666            DataType::VarBinary { .. } => cast.to.clone(),
667            DataType::Date => cast.to.clone(),
668            DataType::Time { .. } => cast.to.clone(),
669            DataType::Decimal { .. } => cast.to.clone(),
670            DataType::Json => cast.to.clone(),
671            DataType::Float { .. } => cast.to.clone(),
672            DataType::Double { .. } => cast.to.clone(),
673            DataType::Char { .. } => cast.to.clone(),
674            DataType::CharacterSet { .. } => cast.to.clone(),
675            DataType::Enum { .. } => cast.to.clone(),
676            DataType::Set { .. } => cast.to.clone(),
677            DataType::Timestamp { .. } => cast.to.clone(),
678
679            // All other unsupported types -> CHAR
680            _ => DataType::Char { length: None },
681        };
682
683        Cast {
684            this: cast.this,
685            to: new_type,
686            trailing_comments: cast.trailing_comments,
687            double_colon_syntax: cast.double_colon_syntax,
688            format: cast.format,
689            default: cast.default,
690            inferred_type: None,
691        }
692    }
693
694    fn transform_function(&self, f: Function) -> Result<Expression> {
695        let name_upper = f.name.to_uppercase();
696        match name_upper.as_str() {
697            // Normalize DATE_FORMAT short-hands to canonical MySQL forms.
698            "DATE_FORMAT" if f.args.len() >= 2 => {
699                let mut f = f;
700                if let Some(Expression::Literal(lit)) = f.args.get(1) {
701                    if let Literal::String(fmt) = lit.as_ref() {
702                        let normalized = Self::normalize_mysql_date_format(fmt);
703                        if normalized != *fmt {
704                            f.args[1] = Expression::Literal(Box::new(Literal::String(normalized)));
705                        }
706                    }
707                }
708                Ok(Expression::Function(Box::new(f)))
709            }
710
711            // NVL -> IFNULL
712            "NVL" if f.args.len() == 2 => {
713                let mut args = f.args;
714                let second = args.pop().unwrap();
715                let first = args.pop().unwrap();
716                Ok(Expression::IfNull(Box::new(BinaryFunc {
717                    original_name: None,
718                    this: first,
719                    expression: second,
720                    inferred_type: None,
721                })))
722            }
723
724            // Note: COALESCE is native to MySQL. We do NOT convert it to IFNULL
725            // because this would break identity tests (Python SQLGlot preserves COALESCE).
726
727            // ARRAY_AGG -> GROUP_CONCAT
728            "ARRAY_AGG" if f.args.len() == 1 => {
729                let mut args = f.args;
730                Ok(Expression::Function(Box::new(Function::new(
731                    "GROUP_CONCAT".to_string(),
732                    vec![args.pop().unwrap()],
733                ))))
734            }
735
736            // STRING_AGG -> GROUP_CONCAT
737            "STRING_AGG" if !f.args.is_empty() => Ok(Expression::Function(Box::new(
738                Function::new("GROUP_CONCAT".to_string(), f.args),
739            ))),
740
741            // RANDOM -> RAND
742            "RANDOM" => Ok(Expression::Rand(Box::new(crate::expressions::Rand {
743                seed: None,
744                lower: None,
745                upper: None,
746            }))),
747
748            // CURRENT_TIMESTAMP -> NOW() or CURRENT_TIMESTAMP (both work)
749            // Preserve precision if specified: CURRENT_TIMESTAMP(6)
750            "CURRENT_TIMESTAMP" => {
751                let precision = if let Some(Expression::Literal(lit)) = f.args.first() {
752                    if let crate::expressions::Literal::Number(n) = lit.as_ref() {
753                        n.parse::<u32>().ok()
754                    } else {
755                        None
756                    }
757                } else {
758                    None
759                };
760                Ok(Expression::CurrentTimestamp(
761                    crate::expressions::CurrentTimestamp {
762                        precision,
763                        sysdate: false,
764                    },
765                ))
766            }
767
768            // POSITION -> LOCATE in MySQL (argument order is different)
769            // POSITION(substr IN str) -> LOCATE(substr, str)
770            "POSITION" if f.args.len() == 2 => Ok(Expression::Function(Box::new(Function::new(
771                "LOCATE".to_string(),
772                f.args,
773            )))),
774
775            // LENGTH is native to MySQL (returns bytes, not characters)
776            // CHAR_LENGTH for character count
777            "LENGTH" => Ok(Expression::Function(Box::new(f))),
778
779            // CEIL -> CEILING in MySQL (both work)
780            "CEIL" if f.args.len() == 1 => Ok(Expression::Function(Box::new(Function::new(
781                "CEILING".to_string(),
782                f.args,
783            )))),
784
785            // STDDEV -> STD or STDDEV_POP in MySQL
786            "STDDEV" => Ok(Expression::Function(Box::new(Function::new(
787                "STD".to_string(),
788                f.args,
789            )))),
790
791            // STDDEV_SAMP -> STDDEV in MySQL
792            "STDDEV_SAMP" => Ok(Expression::Function(Box::new(Function::new(
793                "STDDEV".to_string(),
794                f.args,
795            )))),
796
797            // TO_DATE -> STR_TO_DATE in MySQL
798            "TO_DATE" => Ok(Expression::Function(Box::new(Function::new(
799                "STR_TO_DATE".to_string(),
800                f.args,
801            )))),
802
803            // TO_TIMESTAMP -> STR_TO_DATE in MySQL
804            "TO_TIMESTAMP" => Ok(Expression::Function(Box::new(Function::new(
805                "STR_TO_DATE".to_string(),
806                f.args,
807            )))),
808
809            // DATE_TRUNC -> Complex transformation
810            // Typically uses DATE() or DATE_FORMAT() depending on unit
811            "DATE_TRUNC" if f.args.len() >= 2 => {
812                // Simplified: DATE_TRUNC('day', x) -> DATE(x)
813                // Full implementation would handle different units
814                let mut args = f.args;
815                let _unit = args.remove(0);
816                let date = args.remove(0);
817                Ok(Expression::Function(Box::new(Function::new(
818                    "DATE".to_string(),
819                    vec![date],
820                ))))
821            }
822
823            // EXTRACT is native but syntax varies
824
825            // COALESCE is native to MySQL (keep as-is for more than 2 args)
826            "COALESCE" if f.args.len() > 2 => Ok(Expression::Function(Box::new(f))),
827
828            // DAYOFMONTH -> DAY (both work)
829            "DAY" => Ok(Expression::Function(Box::new(Function::new(
830                "DAYOFMONTH".to_string(),
831                f.args,
832            )))),
833
834            // DAYOFWEEK is native to MySQL
835            "DAYOFWEEK" => Ok(Expression::Function(Box::new(f))),
836
837            // DAYOFYEAR is native to MySQL
838            "DAYOFYEAR" => Ok(Expression::Function(Box::new(f))),
839
840            // WEEKOFYEAR is native to MySQL
841            "WEEKOFYEAR" => Ok(Expression::Function(Box::new(f))),
842
843            // LAST_DAY is native to MySQL
844            "LAST_DAY" => Ok(Expression::Function(Box::new(f))),
845
846            // TIMESTAMPADD -> DATE_ADD
847            "TIMESTAMPADD" => Ok(Expression::Function(Box::new(Function::new(
848                "DATE_ADD".to_string(),
849                f.args,
850            )))),
851
852            // TIMESTAMPDIFF is native to MySQL
853            "TIMESTAMPDIFF" => Ok(Expression::Function(Box::new(f))),
854
855            // CONVERT_TIMEZONE(from_tz, to_tz, timestamp) -> CONVERT_TZ(timestamp, from_tz, to_tz) in MySQL
856            "CONVERT_TIMEZONE" if f.args.len() == 3 => {
857                let mut args = f.args;
858                let from_tz = args.remove(0);
859                let to_tz = args.remove(0);
860                let timestamp = args.remove(0);
861                Ok(Expression::Function(Box::new(Function::new(
862                    "CONVERT_TZ".to_string(),
863                    vec![timestamp, from_tz, to_tz],
864                ))))
865            }
866
867            // UTC_TIMESTAMP is native to MySQL
868            "UTC_TIMESTAMP" => Ok(Expression::Function(Box::new(f))),
869
870            // UTC_TIME is native to MySQL
871            "UTC_TIME" => Ok(Expression::Function(Box::new(f))),
872
873            // MAKETIME is native to MySQL (TimeFromParts)
874            "MAKETIME" => Ok(Expression::Function(Box::new(f))),
875
876            // TIME_FROM_PARTS -> MAKETIME
877            "TIME_FROM_PARTS" if f.args.len() == 3 => Ok(Expression::Function(Box::new(
878                Function::new("MAKETIME".to_string(), f.args),
879            ))),
880
881            // STUFF -> INSERT in MySQL
882            "STUFF" if f.args.len() == 4 => Ok(Expression::Function(Box::new(Function::new(
883                "INSERT".to_string(),
884                f.args,
885            )))),
886
887            // LOCATE is native to MySQL (reverse of POSITION args)
888            "LOCATE" => Ok(Expression::Function(Box::new(f))),
889
890            // FIND_IN_SET is native to MySQL
891            "FIND_IN_SET" => Ok(Expression::Function(Box::new(f))),
892
893            // FORMAT is native to MySQL (NumberToStr)
894            "FORMAT" => Ok(Expression::Function(Box::new(f))),
895
896            // JSON_EXTRACT is native to MySQL
897            "JSON_EXTRACT" => Ok(Expression::Function(Box::new(f))),
898
899            // JSON_UNQUOTE is native to MySQL
900            "JSON_UNQUOTE" => Ok(Expression::Function(Box::new(f))),
901
902            // JSON_EXTRACT_PATH_TEXT -> JSON_UNQUOTE(JSON_EXTRACT(...))
903            "JSON_EXTRACT_PATH_TEXT" if f.args.len() >= 2 => {
904                let extract = Expression::Function(Box::new(Function::new(
905                    "JSON_EXTRACT".to_string(),
906                    f.args,
907                )));
908                Ok(Expression::Function(Box::new(Function::new(
909                    "JSON_UNQUOTE".to_string(),
910                    vec![extract],
911                ))))
912            }
913
914            // GEN_RANDOM_UUID / UUID -> UUID()
915            "GEN_RANDOM_UUID" | "GENERATE_UUID" => Ok(Expression::Function(Box::new(
916                Function::new("UUID".to_string(), vec![]),
917            ))),
918
919            // DATABASE() -> SCHEMA() in MySQL (both return current database name)
920            "DATABASE" => Ok(Expression::Function(Box::new(Function::new(
921                "SCHEMA".to_string(),
922                f.args,
923            )))),
924
925            // INSTR -> LOCATE in MySQL (with swapped arguments)
926            // INSTR(str, substr) -> LOCATE(substr, str)
927            "INSTR" if f.args.len() == 2 => {
928                let mut args = f.args;
929                let str_arg = args.remove(0);
930                let substr_arg = args.remove(0);
931                Ok(Expression::Function(Box::new(Function::new(
932                    "LOCATE".to_string(),
933                    vec![substr_arg, str_arg],
934                ))))
935            }
936
937            // TIME_STR_TO_UNIX -> UNIX_TIMESTAMP in MySQL
938            "TIME_STR_TO_UNIX" => Ok(Expression::Function(Box::new(Function::new(
939                "UNIX_TIMESTAMP".to_string(),
940                f.args,
941            )))),
942
943            // TIME_STR_TO_TIME -> CAST AS DATETIME(N) or TIMESTAMP() in MySQL
944            "TIME_STR_TO_TIME" if f.args.len() >= 1 => {
945                let mut args = f.args.into_iter();
946                let arg = args.next().unwrap();
947
948                // If there's a timezone arg, use TIMESTAMP() function instead
949                if args.next().is_some() {
950                    return Ok(Expression::Function(Box::new(Function::new(
951                        "TIMESTAMP".to_string(),
952                        vec![arg],
953                    ))));
954                }
955
956                // Extract sub-second precision from the string literal
957                let precision = if let Expression::Literal(ref lit) = arg {
958                    if let crate::expressions::Literal::String(ref s) = lit.as_ref() {
959                        // Find fractional seconds: look for .NNN pattern after HH:MM:SS
960                        if let Some(dot_pos) = s.rfind('.') {
961                            let after_dot = &s[dot_pos + 1..];
962                            // Count digits until non-digit
963                            let frac_digits =
964                                after_dot.chars().take_while(|c| c.is_ascii_digit()).count();
965                            if frac_digits > 0 {
966                                // Round up: 1-3 digits → 3, 4-6 digits → 6
967                                if frac_digits <= 3 {
968                                    Some(3)
969                                } else {
970                                    Some(6)
971                                }
972                            } else {
973                                None
974                            }
975                        } else {
976                            None
977                        }
978                    } else {
979                        None
980                    }
981                } else {
982                    None
983                };
984
985                let type_name = match precision {
986                    Some(p) => format!("DATETIME({})", p),
987                    None => "DATETIME".to_string(),
988                };
989
990                Ok(Expression::Cast(Box::new(Cast {
991                    this: arg,
992                    to: DataType::Custom { name: type_name },
993                    trailing_comments: Vec::new(),
994                    double_colon_syntax: false,
995                    format: None,
996                    default: None,
997                    inferred_type: None,
998                })))
999            }
1000
1001            // UCASE -> UPPER in MySQL
1002            "UCASE" => Ok(Expression::Function(Box::new(Function::new(
1003                "UPPER".to_string(),
1004                f.args,
1005            )))),
1006
1007            // LCASE -> LOWER in MySQL
1008            "LCASE" => Ok(Expression::Function(Box::new(Function::new(
1009                "LOWER".to_string(),
1010                f.args,
1011            )))),
1012
1013            // DAY_OF_MONTH -> DAYOFMONTH in MySQL
1014            "DAY_OF_MONTH" => Ok(Expression::Function(Box::new(Function::new(
1015                "DAYOFMONTH".to_string(),
1016                f.args,
1017            )))),
1018
1019            // DAY_OF_WEEK -> DAYOFWEEK in MySQL
1020            "DAY_OF_WEEK" => Ok(Expression::Function(Box::new(Function::new(
1021                "DAYOFWEEK".to_string(),
1022                f.args,
1023            )))),
1024
1025            // DAY_OF_YEAR -> DAYOFYEAR in MySQL
1026            "DAY_OF_YEAR" => Ok(Expression::Function(Box::new(Function::new(
1027                "DAYOFYEAR".to_string(),
1028                f.args,
1029            )))),
1030
1031            // WEEK_OF_YEAR -> WEEKOFYEAR in MySQL
1032            "WEEK_OF_YEAR" => Ok(Expression::Function(Box::new(Function::new(
1033                "WEEKOFYEAR".to_string(),
1034                f.args,
1035            )))),
1036
1037            // MOD(x, y) -> x % y in MySQL
1038            "MOD" if f.args.len() == 2 => {
1039                let mut args = f.args;
1040                let left = args.remove(0);
1041                let right = args.remove(0);
1042                Ok(Expression::Mod(Box::new(BinaryOp {
1043                    left,
1044                    right,
1045                    left_comments: Vec::new(),
1046                    operator_comments: Vec::new(),
1047                    trailing_comments: Vec::new(),
1048                    inferred_type: None,
1049                })))
1050            }
1051
1052            // PARSE_JSON -> strip in MySQL (just keep the string argument)
1053            "PARSE_JSON" if f.args.len() == 1 => Ok(f.args.into_iter().next().unwrap()),
1054
1055            // GET_PATH(obj, path) -> JSON_EXTRACT(obj, json_path) in MySQL
1056            "GET_PATH" if f.args.len() == 2 => {
1057                let mut args = f.args;
1058                let this = args.remove(0);
1059                let path = args.remove(0);
1060                let json_path = match &path {
1061                    Expression::Literal(lit) if matches!(lit.as_ref(), Literal::String(_)) => {
1062                        let Literal::String(s) = lit.as_ref() else {
1063                            unreachable!()
1064                        };
1065                        // Convert bracket notation ["key"] to quoted dot notation ."key"
1066                        let s = Self::convert_bracket_to_quoted_path(s);
1067                        let normalized = if s.starts_with('$') {
1068                            s
1069                        } else if s.starts_with('[') {
1070                            format!("${}", s)
1071                        } else {
1072                            format!("$.{}", s)
1073                        };
1074                        Expression::Literal(Box::new(Literal::String(normalized)))
1075                    }
1076                    _ => path,
1077                };
1078                Ok(Expression::JsonExtract(Box::new(JsonExtractFunc {
1079                    this,
1080                    path: json_path,
1081                    returning: None,
1082                    arrow_syntax: false,
1083                    hash_arrow_syntax: false,
1084                    wrapper_option: None,
1085                    quotes_option: None,
1086                    on_scalar_string: false,
1087                    on_error: None,
1088                })))
1089            }
1090
1091            // REGEXP -> REGEXP_LIKE (MySQL standard form)
1092            "REGEXP" if f.args.len() >= 2 => Ok(Expression::Function(Box::new(Function::new(
1093                "REGEXP_LIKE".to_string(),
1094                f.args,
1095            )))),
1096
1097            // CURTIME -> CURRENT_TIME
1098            "CURTIME" => Ok(Expression::CurrentTime(crate::expressions::CurrentTime {
1099                precision: None,
1100            })),
1101
1102            // TRUNC -> TRUNCATE in MySQL
1103            "TRUNC" => Ok(Expression::Function(Box::new(Function::new(
1104                "TRUNCATE".to_string(),
1105                f.args,
1106            )))),
1107
1108            // Pass through everything else
1109            _ => Ok(Expression::Function(Box::new(f))),
1110        }
1111    }
1112
1113    fn transform_aggregate_function(
1114        &self,
1115        f: Box<crate::expressions::AggregateFunction>,
1116    ) -> Result<Expression> {
1117        let name_upper = f.name.to_uppercase();
1118        match name_upper.as_str() {
1119            // STRING_AGG -> GROUP_CONCAT
1120            "STRING_AGG" if !f.args.is_empty() => Ok(Expression::Function(Box::new(
1121                Function::new("GROUP_CONCAT".to_string(), f.args),
1122            ))),
1123
1124            // ARRAY_AGG -> GROUP_CONCAT
1125            "ARRAY_AGG" if !f.args.is_empty() => Ok(Expression::Function(Box::new(Function::new(
1126                "GROUP_CONCAT".to_string(),
1127                f.args,
1128            )))),
1129
1130            // Pass through everything else
1131            _ => Ok(Expression::AggregateFunction(f)),
1132        }
1133    }
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::*;
1139    use crate::dialects::Dialect;
1140
1141    fn transpile_to_mysql(sql: &str) -> String {
1142        let dialect = Dialect::get(DialectType::Generic);
1143        let result = dialect
1144            .transpile(sql, DialectType::MySQL)
1145            .expect("Transpile failed");
1146        result[0].clone()
1147    }
1148
1149    #[test]
1150    fn test_nvl_to_ifnull() {
1151        let result = transpile_to_mysql("SELECT NVL(a, b)");
1152        assert!(
1153            result.contains("IFNULL"),
1154            "Expected IFNULL, got: {}",
1155            result
1156        );
1157    }
1158
1159    #[test]
1160    fn test_coalesce_preserved() {
1161        // COALESCE should be preserved in MySQL (it's a native function)
1162        let result = transpile_to_mysql("SELECT COALESCE(a, b)");
1163        assert!(
1164            result.contains("COALESCE"),
1165            "Expected COALESCE to be preserved, got: {}",
1166            result
1167        );
1168    }
1169
1170    #[test]
1171    fn test_random_to_rand() {
1172        let result = transpile_to_mysql("SELECT RANDOM()");
1173        assert!(result.contains("RAND"), "Expected RAND, got: {}", result);
1174    }
1175
1176    #[test]
1177    fn test_basic_select() {
1178        let result = transpile_to_mysql("SELECT a, b FROM users WHERE id = 1");
1179        assert!(result.contains("SELECT"));
1180        assert!(result.contains("FROM users"));
1181    }
1182
1183    #[test]
1184    fn test_string_agg_to_group_concat() {
1185        let result = transpile_to_mysql("SELECT STRING_AGG(name)");
1186        assert!(
1187            result.contains("GROUP_CONCAT"),
1188            "Expected GROUP_CONCAT, got: {}",
1189            result
1190        );
1191    }
1192
1193    #[test]
1194    fn test_array_agg_to_group_concat() {
1195        let result = transpile_to_mysql("SELECT ARRAY_AGG(name)");
1196        assert!(
1197            result.contains("GROUP_CONCAT"),
1198            "Expected GROUP_CONCAT, got: {}",
1199            result
1200        );
1201    }
1202
1203    #[test]
1204    fn test_to_date_to_str_to_date() {
1205        let result = transpile_to_mysql("SELECT TO_DATE('2023-01-01')");
1206        assert!(
1207            result.contains("STR_TO_DATE"),
1208            "Expected STR_TO_DATE, got: {}",
1209            result
1210        );
1211    }
1212
1213    #[test]
1214    fn test_backtick_identifiers() {
1215        // MySQL uses backticks for identifiers
1216        let dialect = MySQLDialect;
1217        let config = dialect.generator_config();
1218        assert_eq!(config.identifier_quote, '`');
1219    }
1220
1221    #[test]
1222    fn test_create_procedure_signal_sqlstate_regression() {
1223        let sql = "CREATE PROCEDURE CheckSalary(IN p_salary DECIMAL(10,2))
1224BEGIN
1225  IF p_salary <= 0 THEN
1226    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Salary must be positive';
1227  END IF;
1228  INSERT INTO employees (salary) VALUES (p_salary);
1229END;";
1230
1231        let dialect = Dialect::get(DialectType::MySQL);
1232        let ast = dialect.parse(sql).expect("Parse failed");
1233        let output = dialect.generate(&ast[0]).expect("Generate failed");
1234
1235        assert!(output.contains("CREATE PROCEDURE CheckSalary"));
1236        assert!(output.contains("IF p_salary <= 0 THEN"));
1237        assert!(
1238            output.contains("SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Salary must be positive'")
1239        );
1240        assert!(output.contains("END IF"));
1241        assert!(output.contains("INSERT INTO employees (salary) VALUES (p_salary)"));
1242    }
1243
1244    fn mysql_identity(sql: &str, expected: &str) {
1245        let dialect = Dialect::get(DialectType::MySQL);
1246        let ast = dialect.parse(sql).expect("Parse failed");
1247        let transformed = dialect.transform(ast[0].clone()).expect("Transform failed");
1248        let result = dialect.generate(&transformed).expect("Generate failed");
1249        assert_eq!(result, expected, "SQL: {}", sql);
1250    }
1251
1252    #[test]
1253    fn test_ucase_to_upper() {
1254        mysql_identity("SELECT UCASE('foo')", "SELECT UPPER('foo')");
1255    }
1256
1257    #[test]
1258    fn test_lcase_to_lower() {
1259        mysql_identity("SELECT LCASE('foo')", "SELECT LOWER('foo')");
1260    }
1261
1262    #[test]
1263    fn test_day_of_month() {
1264        mysql_identity(
1265            "SELECT DAY_OF_MONTH('2023-01-01')",
1266            "SELECT DAYOFMONTH('2023-01-01')",
1267        );
1268    }
1269
1270    #[test]
1271    fn test_day_of_week() {
1272        mysql_identity(
1273            "SELECT DAY_OF_WEEK('2023-01-01')",
1274            "SELECT DAYOFWEEK('2023-01-01')",
1275        );
1276    }
1277
1278    #[test]
1279    fn test_day_of_year() {
1280        mysql_identity(
1281            "SELECT DAY_OF_YEAR('2023-01-01')",
1282            "SELECT DAYOFYEAR('2023-01-01')",
1283        );
1284    }
1285
1286    #[test]
1287    fn test_week_of_year() {
1288        mysql_identity(
1289            "SELECT WEEK_OF_YEAR('2023-01-01')",
1290            "SELECT WEEKOFYEAR('2023-01-01')",
1291        );
1292    }
1293
1294    #[test]
1295    fn test_mod_func_to_percent() {
1296        // MOD(x, y) function is transformed to x % y in MySQL
1297        mysql_identity("MOD(x, y)", "x % y");
1298    }
1299
1300    #[test]
1301    fn test_database_to_schema() {
1302        mysql_identity("DATABASE()", "SCHEMA()");
1303    }
1304
1305    #[test]
1306    fn test_and_operator() {
1307        mysql_identity("SELECT 1 && 0", "SELECT 1 AND 0");
1308    }
1309
1310    #[test]
1311    fn test_or_operator() {
1312        mysql_identity("SELECT a || b", "SELECT a OR b");
1313    }
1314}