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