Skip to main content

sqlglot_rust/dialects/
time.rs

1//! Time Format Mapping for cross-dialect transpilation.
2//!
3//! This module handles conversion of date/time format specifiers between different
4//! SQL dialects. Each dialect has its own conventions for formatting dates and times:
5//!
6//! - **strftime** (Python, SQLite): `%Y`, `%m`, `%d`, `%H`, `%M`, `%S`
7//! - **MySQL**: `%Y`, `%m`, `%d`, `%H`, `%i`, `%s`
8//! - **PostgreSQL/Oracle**: `YYYY`, `MM`, `DD`, `HH24`, `MI`, `SS`
9//! - **BigQuery**: strftime-like (`%Y`, `%m`, `%d`, `%H`, `%M`, `%S`)
10//! - **Snowflake**: `YYYY`, `MM`, `DD`, `HH24`, `MI`, `SS`, `FF`
11//! - **Spark/Hive**: Java DateTimeFormatter (`yyyy`, `MM`, `dd`, `HH`, `mm`, `ss`)
12//! - **T-SQL**: Primarily uses numeric style codes (120, 121, etc.)
13//!
14//! # Example
15//!
16//! ```rust
17//! use sqlglot_rust::dialects::time::{format_time, TimeFormatStyle};
18//!
19//! // Convert MySQL format to PostgreSQL format
20//! let pg_format = format_time("%Y-%m-%d %H:%i:%s", 
21//!     TimeFormatStyle::Mysql, 
22//!     TimeFormatStyle::Postgres);
23//! assert_eq!(pg_format, "YYYY-MM-DD HH24:MI:SS");
24//! ```
25
26use super::Dialect;
27use std::collections::HashMap;
28
29/// Time format styles used by different dialect families.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum TimeFormatStyle {
32    /// Python strftime / SQLite / BigQuery: `%Y`, `%m`, `%d`, `%H`, `%M`, `%S`
33    Strftime,
34    /// MySQL: `%Y`, `%m`, `%d`, `%H`, `%i`, `%s` (note: `%i` for minutes)
35    Mysql,
36    /// PostgreSQL / Oracle / Redshift: `YYYY`, `MM`, `DD`, `HH24`, `MI`, `SS`
37    Postgres,
38    /// Snowflake: `YYYY`, `MM`, `DD`, `HH24`, `MI`, `SS`, `FF`
39    Snowflake,
40    /// Spark / Hive / Databricks: Java DateTimeFormatter `yyyy`, `MM`, `dd`, `HH`, `mm`, `ss`
41    Java,
42    /// T-SQL: Uses numeric style codes (FORMAT function uses .NET patterns)
43    Tsql,
44    /// ClickHouse: Similar to strftime but with some differences
45    ClickHouse,
46}
47
48impl TimeFormatStyle {
49    /// Determine the time format style for a given dialect.
50    #[must_use]
51    pub fn for_dialect(dialect: Dialect) -> Self {
52        match dialect {
53            // strftime-style dialects
54            Dialect::Ansi | Dialect::Sqlite | Dialect::BigQuery | Dialect::DuckDb => {
55                TimeFormatStyle::Strftime
56            }
57
58            // MySQL family
59            Dialect::Mysql
60            | Dialect::Doris
61            | Dialect::SingleStore
62            | Dialect::StarRocks => TimeFormatStyle::Mysql,
63
64            // Postgres family
65            Dialect::Postgres
66            | Dialect::Oracle
67            | Dialect::Redshift
68            | Dialect::Materialize
69            | Dialect::RisingWave
70            | Dialect::Exasol
71            | Dialect::Teradata => TimeFormatStyle::Postgres,
72
73            // Snowflake
74            Dialect::Snowflake => TimeFormatStyle::Snowflake,
75
76            // Hive/Spark family (Java DateTimeFormatter)
77            Dialect::Hive | Dialect::Spark | Dialect::Databricks => TimeFormatStyle::Java,
78
79            // T-SQL family
80            Dialect::Tsql | Dialect::Fabric => TimeFormatStyle::Tsql,
81
82            // Presto family uses Java-like patterns
83            Dialect::Presto | Dialect::Trino | Dialect::Athena => TimeFormatStyle::Java,
84
85            // ClickHouse
86            Dialect::ClickHouse => TimeFormatStyle::ClickHouse,
87
88            // Others - default to strftime
89            Dialect::Dremio | Dialect::Drill | Dialect::Druid | Dialect::Tableau | Dialect::Prql => {
90                TimeFormatStyle::Strftime
91            }
92        }
93    }
94}
95
96// ═══════════════════════════════════════════════════════════════════════════
97// Format specifier mappings
98// ═══════════════════════════════════════════════════════════════════════════
99
100/// Mapping entry representing a format specifier's equivalent across styles.
101#[derive(Debug, Clone)]
102struct FormatMapping {
103    strftime: &'static str,
104    mysql: &'static str,
105    postgres: &'static str,
106    snowflake: &'static str,
107    java: &'static str,
108    tsql: &'static str,
109    clickhouse: &'static str,
110}
111
112impl FormatMapping {
113    /// Get the specifier for a given style.
114    fn get(&self, style: TimeFormatStyle) -> &'static str {
115        match style {
116            TimeFormatStyle::Strftime => self.strftime,
117            TimeFormatStyle::Mysql => self.mysql,
118            TimeFormatStyle::Postgres => self.postgres,
119            TimeFormatStyle::Snowflake => self.snowflake,
120            TimeFormatStyle::Java => self.java,
121            TimeFormatStyle::Tsql => self.tsql,
122            TimeFormatStyle::ClickHouse => self.clickhouse,
123        }
124    }
125}
126
127/// Build the canonical format mappings table.
128/// Each entry maps a semantic time component to its representation in each style.
129fn build_format_mappings() -> Vec<FormatMapping> {
130    vec![
131        // ── Year ───────────────────────────────────────────────────────
132        FormatMapping {
133            strftime: "%Y",      // 4-digit year
134            mysql: "%Y",
135            postgres: "YYYY",
136            snowflake: "YYYY",
137            java: "yyyy",
138            tsql: "yyyy",
139            clickhouse: "%Y",
140        },
141        FormatMapping {
142            strftime: "%y",      // 2-digit year
143            mysql: "%y",
144            postgres: "YY",
145            snowflake: "YY",
146            java: "yy",
147            tsql: "yy",
148            clickhouse: "%y",
149        },
150        // ── Month ──────────────────────────────────────────────────────
151        FormatMapping {
152            strftime: "%m",      // Month as zero-padded decimal (01-12)
153            mysql: "%m",
154            postgres: "MM",
155            snowflake: "MM",
156            java: "MM",
157            tsql: "MM",
158            clickhouse: "%m",
159        },
160        FormatMapping {
161            strftime: "%b",      // Abbreviated month name (Jan, Feb, ...)
162            mysql: "%b",
163            postgres: "Mon",
164            snowflake: "MON",
165            java: "MMM",
166            tsql: "MMM",
167            clickhouse: "%b",
168        },
169        FormatMapping {
170            strftime: "%B",      // Full month name
171            mysql: "%M",
172            postgres: "Month",
173            snowflake: "MMMM",
174            java: "MMMM",
175            tsql: "MMMM",
176            clickhouse: "%B",
177        },
178        // ── Day ────────────────────────────────────────────────────────
179        FormatMapping {
180            strftime: "%d",      // Day of month as zero-padded decimal (01-31)
181            mysql: "%d",
182            postgres: "DD",
183            snowflake: "DD",
184            java: "dd",
185            tsql: "dd",
186            clickhouse: "%d",
187        },
188        FormatMapping {
189            strftime: "%e",      // Day of month as space-padded decimal
190            mysql: "%e",
191            postgres: "FMDD",
192            snowflake: "DD",     // Snowflake doesn't have space-padded
193            java: "d",
194            tsql: "d",
195            clickhouse: "%e",
196        },
197        FormatMapping {
198            strftime: "%j",      // Day of year (001-366)
199            mysql: "%j",
200            postgres: "DDD",
201            snowflake: "DDD",
202            java: "DDD",
203            tsql: "",            // T-SQL doesn't have direct equivalent
204            clickhouse: "%j",
205        },
206        // ── Weekday ────────────────────────────────────────────────────
207        FormatMapping {
208            strftime: "%a",      // Abbreviated weekday name
209            mysql: "%a",
210            postgres: "Dy",
211            snowflake: "DY",
212            java: "EEE",
213            tsql: "ddd",
214            clickhouse: "%a",
215        },
216        FormatMapping {
217            strftime: "%A",      // Full weekday name
218            mysql: "%W",
219            postgres: "Day",
220            snowflake: "DY",     // Snowflake uses uppercase abbreviated
221            java: "EEEE",
222            tsql: "dddd",
223            clickhouse: "%A",
224        },
225        FormatMapping {
226            strftime: "%w",      // Weekday as number (0=Sunday, 6=Saturday)
227            mysql: "%w",
228            postgres: "D",
229            snowflake: "D",
230            java: "e",
231            tsql: "",
232            clickhouse: "%w",
233        },
234        FormatMapping {
235            strftime: "%u",      // Weekday as number (1=Monday, 7=Sunday)
236            mysql: "%u",
237            postgres: "ID",
238            snowflake: "ID",
239            java: "u",
240            tsql: "",
241            clickhouse: "%u",
242        },
243        // ── Week ───────────────────────────────────────────────────────
244        FormatMapping {
245            strftime: "%W",      // Week number of year (Monday as first day)
246            mysql: "%v",         // MySQL uses %v for ISO week
247            postgres: "IW",
248            snowflake: "WW",
249            java: "ww",
250            tsql: "ww",
251            clickhouse: "%V",
252        },
253        FormatMapping {
254            strftime: "%U",      // Week number of year (Sunday as first day)
255            mysql: "%U",
256            postgres: "WW",
257            snowflake: "WW",
258            java: "ww",
259            tsql: "ww",
260            clickhouse: "%U",
261        },
262        // ── Hour ───────────────────────────────────────────────────────
263        FormatMapping {
264            strftime: "%H",      // Hour (24-hour) as zero-padded decimal (00-23)
265            mysql: "%H",
266            postgres: "HH24",
267            snowflake: "HH24",
268            java: "HH",
269            tsql: "HH",
270            clickhouse: "%H",
271        },
272        FormatMapping {
273            strftime: "%I",      // Hour (12-hour) as zero-padded decimal (01-12)
274            mysql: "%h",
275            postgres: "HH12",
276            snowflake: "HH12",
277            java: "hh",
278            tsql: "hh",
279            clickhouse: "%I",
280        },
281        // ── Minute ─────────────────────────────────────────────────────
282        FormatMapping {
283            strftime: "%M",      // Minute as zero-padded decimal (00-59)
284            mysql: "%i",         // NOTE: MySQL uses %i for minutes!
285            postgres: "MI",
286            snowflake: "MI",
287            java: "mm",
288            tsql: "mm",
289            clickhouse: "%M",
290        },
291        // ── Second ─────────────────────────────────────────────────────
292        FormatMapping {
293            strftime: "%S",      // Second as zero-padded decimal (00-59)
294            mysql: "%s",
295            postgres: "SS",
296            snowflake: "SS",
297            java: "ss",
298            tsql: "ss",
299            clickhouse: "%S",
300        },
301        // ── Fractional seconds ─────────────────────────────────────────
302        FormatMapping {
303            strftime: "%f",      // Microseconds (6 digits)
304            mysql: "%f",
305            postgres: "US",      // Microseconds
306            snowflake: "FF6",
307            java: "SSSSSS",
308            tsql: "ffffff",
309            clickhouse: "%f",
310        },
311        // ── AM/PM ──────────────────────────────────────────────────────
312        FormatMapping {
313            strftime: "%p",      // AM or PM
314            mysql: "%p",
315            postgres: "AM",
316            snowflake: "AM",
317            java: "a",
318            tsql: "tt",
319            clickhouse: "%p",
320        },
321        // ── Timezone ───────────────────────────────────────────────────
322        FormatMapping {
323            strftime: "%z",      // UTC offset as +HHMM or -HHMM
324            mysql: "",           // MySQL doesn't support timezone in format
325            postgres: "OF",
326            snowflake: "TZH:TZM",
327            java: "Z",
328            tsql: "zzz",
329            clickhouse: "%z",
330        },
331        FormatMapping {
332            strftime: "%Z",      // Timezone name
333            mysql: "",
334            postgres: "TZ",
335            snowflake: "TZR",
336            java: "z",
337            tsql: "",
338            clickhouse: "%Z",
339        },
340        // ── Special ────────────────────────────────────────────────────
341        FormatMapping {
342            strftime: "%%",      // Literal %
343            mysql: "%%",
344            postgres: "",        // Postgres doesn't need escaping
345            snowflake: "",
346            java: "",
347            tsql: "",
348            clickhouse: "%%",
349        },
350    ]
351}
352
353/// Lazily build and cache the format mappings.
354fn get_format_mappings() -> &'static Vec<FormatMapping> {
355    use std::sync::OnceLock;
356    static MAPPINGS: OnceLock<Vec<FormatMapping>> = OnceLock::new();
357    MAPPINGS.get_or_init(build_format_mappings)
358}
359
360/// Build a lookup table from source style specifiers to FormatMapping index.
361///
362/// This function is available for potential future optimization of format
363/// string parsing, allowing O(1) lookups instead of linear scans.
364#[allow(dead_code)]
365fn build_style_lookup(style: TimeFormatStyle) -> HashMap<&'static str, usize> {
366    let mappings = get_format_mappings();
367    let mut lookup = HashMap::new();
368    for (i, mapping) in mappings.iter().enumerate() {
369        let spec = mapping.get(style);
370        if !spec.is_empty() {
371            lookup.insert(spec, i);
372        }
373    }
374    lookup
375}
376
377// ═══════════════════════════════════════════════════════════════════════════
378// Format conversion
379// ═══════════════════════════════════════════════════════════════════════════
380
381/// Convert a time format string from one dialect style to another.
382///
383/// # Arguments
384///
385/// * `format_str` - The format string to convert
386/// * `source` - The source format style
387/// * `target` - The target format style
388///
389/// # Returns
390///
391/// The converted format string with specifiers replaced according to the target style.
392/// Literal text (not matching any known specifier) is preserved as-is.
393///
394/// # Example
395///
396/// ```rust
397/// use sqlglot_rust::dialects::time::{format_time, TimeFormatStyle};
398///
399/// let result = format_time("%Y-%m-%d", TimeFormatStyle::Strftime, TimeFormatStyle::Postgres);
400/// assert_eq!(result, "YYYY-MM-DD");
401/// ```
402#[must_use]
403pub fn format_time(format_str: &str, source: TimeFormatStyle, target: TimeFormatStyle) -> String {
404    if source == target {
405        return format_str.to_string();
406    }
407
408    // Use the appropriate parser based on source style
409    match source {
410        TimeFormatStyle::Strftime
411        | TimeFormatStyle::Mysql
412        | TimeFormatStyle::ClickHouse => convert_strftime_style(format_str, source, target),
413        TimeFormatStyle::Postgres => convert_postgres_style(format_str, target),
414        TimeFormatStyle::Snowflake => convert_snowflake_style(format_str, target),
415        TimeFormatStyle::Java | TimeFormatStyle::Tsql => convert_java_style(format_str, source, target),
416    }
417}
418
419/// Convert a format string from strftime-style (%, MySQL, ClickHouse) to target.
420fn convert_strftime_style(format_str: &str, source: TimeFormatStyle, target: TimeFormatStyle) -> String {
421    let mappings = get_format_mappings();
422    let mut result = String::with_capacity(format_str.len() * 2);
423    let mut chars = format_str.chars().peekable();
424
425    while let Some(ch) = chars.next() {
426        if ch == '%' {
427            if let Some(&next) = chars.peek() {
428                chars.next();
429                let spec = format!("%{}", next);
430                
431                // Find matching mapping
432                let mapped = mappings.iter().find(|m| m.get(source) == spec);
433                
434                if let Some(mapping) = mapped {
435                    let target_spec = mapping.get(target);
436                    if target_spec.is_empty() {
437                        // No equivalent in target - keep original or use placeholder
438                        result.push_str(&spec);
439                    } else {
440                        result.push_str(target_spec);
441                    }
442                } else {
443                    // Unknown specifier - keep as-is
444                    result.push_str(&spec);
445                }
446            } else {
447                // Trailing % - keep it
448                result.push('%');
449            }
450        } else {
451            result.push(ch);
452        }
453    }
454
455    result
456}
457
458/// Convert a format string from Postgres style to target.
459fn convert_postgres_style(format_str: &str, target: TimeFormatStyle) -> String {
460    let mappings = get_format_mappings();
461    let mut result = String::with_capacity(format_str.len() * 2);
462    let chars: Vec<char> = format_str.chars().collect();
463    let mut i = 0;
464
465    // Postgres specifiers to check, ordered by length (longest first)
466    let pg_specifiers: &[&str] = &[
467        "YYYY", "MMMM", "Month", "Mon", "MM", "DDD", "DD", "Day", "Dy", "D",
468        "HH24", "HH12", "HH", "MI", "SS", "US", "AM", "PM", "TZH:TZM", "TZR",
469        "TZ", "OF", "IW", "WW", "YY", "ID", "FMDD",
470    ];
471
472    while i < chars.len() {
473        let remaining: String = chars[i..].iter().collect();
474        let mut matched = false;
475
476        // Try to match longest specifier first
477        for spec in pg_specifiers {
478            if remaining.starts_with(spec) || remaining.to_uppercase().starts_with(&spec.to_uppercase()) {
479                // Find the mapping
480                let mapping = mappings.iter().find(|m|
481                    m.postgres.eq_ignore_ascii_case(spec)
482                );
483                
484                if let Some(m) = mapping {
485                    let target_spec = m.get(target);
486                    if !target_spec.is_empty() {
487                        result.push_str(target_spec);
488                    } else {
489                        result.push_str(spec);
490                    }
491                } else {
492                    result.push_str(spec);
493                }
494                i += spec.len();
495                matched = true;
496                break;
497            }
498        }
499
500        if !matched {
501            // Check for quoted literal (in Postgres, text in double quotes is literal)
502            if chars[i] == '"' {
503                result.push(chars[i]);
504                i += 1;
505                while i < chars.len() && chars[i] != '"' {
506                    result.push(chars[i]);
507                    i += 1;
508                }
509                if i < chars.len() {
510                    result.push(chars[i]); // closing quote
511                    i += 1;
512                }
513            } else {
514                result.push(chars[i]);
515                i += 1;
516            }
517        }
518    }
519
520    result
521}
522
523/// Convert a format string from Snowflake style to target.
524fn convert_snowflake_style(format_str: &str, target: TimeFormatStyle) -> String {
525    let mappings = get_format_mappings();
526    let mut result = String::with_capacity(format_str.len() * 2);
527    let chars: Vec<char> = format_str.chars().collect();
528    let mut i = 0;
529
530    // Snowflake specifiers (similar to Postgres but with some differences)
531    let sf_specifiers: &[&str] = &[
532        "YYYY", "MMMM", "MON", "MM", "DDD", "DD", "DY", "D",
533        "HH24", "HH12", "HH", "MI", "SS", "FF6", "FF3", "FF",
534        "AM", "PM", "TZH:TZM", "TZR", "WW", "YY", "ID",
535    ];
536
537    while i < chars.len() {
538        let remaining: String = chars[i..].iter().collect();
539        let mut matched = false;
540
541        for spec in sf_specifiers {
542            if remaining.starts_with(spec) || remaining.to_uppercase().starts_with(&spec.to_uppercase()) {
543                let mapping = mappings.iter().find(|m| 
544                    m.snowflake.eq_ignore_ascii_case(spec)
545                );
546                
547                if let Some(m) = mapping {
548                    let target_spec = m.get(target);
549                    if !target_spec.is_empty() {
550                        result.push_str(target_spec);
551                    } else {
552                        result.push_str(spec);
553                    }
554                } else {
555                    result.push_str(spec);
556                }
557                i += spec.len();
558                matched = true;
559                break;
560            }
561        }
562
563        if !matched {
564            // Check for quoted literal
565            if chars[i] == '"' {
566                result.push(chars[i]);
567                i += 1;
568                while i < chars.len() && chars[i] != '"' {
569                    result.push(chars[i]);
570                    i += 1;
571                }
572                if i < chars.len() {
573                    result.push(chars[i]);
574                    i += 1;
575                }
576            } else {
577                result.push(chars[i]);
578                i += 1;
579            }
580        }
581    }
582
583    result
584}
585
586/// Convert a format string from Java/T-SQL style to target.
587fn convert_java_style(format_str: &str, source: TimeFormatStyle, target: TimeFormatStyle) -> String {
588    let mappings = get_format_mappings();
589    let mut result = String::with_capacity(format_str.len() * 2);
590    let chars: Vec<char> = format_str.chars().collect();
591    let mut i = 0;
592
593    // Java DateTimeFormatter patterns
594    let java_specifiers: &[&str] = &[
595        "yyyy", "YYYY", "yy", "YY",
596        "MMMM", "MMM", "MM", "M",
597        "dd", "d", "DDD",
598        "EEEE", "EEE", "e", "u",
599        "HH", "hh", "H", "h",
600        "mm", "m",
601        "ss", "s",
602        "SSSSSS", "SSS", "SS", "S",
603        "a", "Z", "z",
604        "ww",
605    ];
606
607    while i < chars.len() {
608        let remaining: String = chars[i..].iter().collect();
609        let mut matched = false;
610
611        // Check for quoted literals (Java uses single quotes)
612        if chars[i] == '\'' {
613            result.push(chars[i]);
614            i += 1;
615            while i < chars.len() && chars[i] != '\'' {
616                result.push(chars[i]);
617                i += 1;
618            }
619            if i < chars.len() {
620                result.push(chars[i]);
621                i += 1;
622            }
623            continue;
624        }
625
626        for spec in java_specifiers {
627            if remaining.starts_with(spec) {
628                let mapping = mappings.iter().find(|m| {
629                    let src_spec = m.get(source);
630                    src_spec == *spec
631                });
632                
633                if let Some(m) = mapping {
634                    let target_spec = m.get(target);
635                    if !target_spec.is_empty() {
636                        result.push_str(target_spec);
637                    } else {
638                        result.push_str(spec);
639                    }
640                } else {
641                    result.push_str(spec);
642                }
643                i += spec.len();
644                matched = true;
645                break;
646            }
647        }
648
649        if !matched {
650            result.push(chars[i]);
651            i += 1;
652        }
653    }
654
655    result
656}
657
658// ═══════════════════════════════════════════════════════════════════════════
659// Dialect-aware conversion
660// ═══════════════════════════════════════════════════════════════════════════
661
662/// Convert a time format string from one SQL dialect to another.
663///
664/// This is the main entry point for dialect-to-dialect format conversion.
665///
666/// # Arguments
667///
668/// * `format_str` - The format string to convert
669/// * `source_dialect` - The source SQL dialect
670/// * `target_dialect` - The target SQL dialect
671///
672/// # Returns
673///
674/// The converted format string appropriate for the target dialect.
675///
676/// # Example
677///
678/// ```rust
679/// use sqlglot_rust::dialects::time::format_time_dialect;
680/// use sqlglot_rust::Dialect;
681///
682/// // Convert MySQL format to PostgreSQL
683/// let result = format_time_dialect("%Y-%m-%d %H:%i:%s", Dialect::Mysql, Dialect::Postgres);
684/// assert_eq!(result, "YYYY-MM-DD HH24:MI:SS");
685/// ```
686#[must_use]
687pub fn format_time_dialect(format_str: &str, source_dialect: Dialect, target_dialect: Dialect) -> String {
688    let source_style = TimeFormatStyle::for_dialect(source_dialect);
689    let target_style = TimeFormatStyle::for_dialect(target_dialect);
690    format_time(format_str, source_style, target_style)
691}
692
693// ═══════════════════════════════════════════════════════════════════════════
694// T-SQL Style Codes
695// ═══════════════════════════════════════════════════════════════════════════
696
697/// T-SQL date/time style codes used with CONVERT function.
698///
699/// T-SQL primarily uses numeric style codes for date formatting with CONVERT,
700/// rather than format patterns. This provides mappings for common styles.
701#[derive(Debug, Clone, Copy, PartialEq, Eq)]
702pub enum TsqlStyleCode {
703    /// Style 100: mon dd yyyy hh:miAM (or PM)
704    Default100 = 100,
705    /// Style 101: mm/dd/yyyy (USA)
706    UsaDate = 101,
707    /// Style 102: yyyy.mm.dd (ANSI)
708    AnsiDate = 102,
709    /// Style 103: dd/mm/yyyy (British/French)
710    BritishDate = 103,
711    /// Style 104: dd.mm.yyyy (German)
712    GermanDate = 104,
713    /// Style 105: dd-mm-yyyy (Italian)
714    ItalianDate = 105,
715    /// Style 106: dd mon yyyy
716    DayMonYear = 106,
717    /// Style 107: Mon dd, yyyy
718    MonDayYear = 107,
719    /// Style 108: hh:mi:ss
720    TimeOnly = 108,
721    /// Style 110: mm-dd-yyyy (USA with dashes)
722    UsaDashes = 110,
723    /// Style 111: yyyy/mm/dd (Japan)
724    JapanDate = 111,
725    /// Style 112: yyyymmdd (ISO basic)
726    IsoBasic = 112,
727    /// Style 114: hh:mi:ss:mmm
728    TimeWithMs = 114,
729    /// Style 120: yyyy-mm-dd hh:mi:ss (ODBC canonical)
730    OdbcCanonical = 120,
731    /// Style 121: yyyy-mm-dd hh:mi:ss.mmm (ODBC with milliseconds)
732    OdbcWithMs = 121,
733    /// Style 126: yyyy-mm-ddThh:mi:ss.mmm (ISO8601)
734    Iso8601 = 126,
735    /// Style 127: yyyy-mm-ddThh:mi:ss.mmmZ (ISO8601 with timezone)
736    Iso8601Tz = 127,
737}
738
739impl TsqlStyleCode {
740    /// Get the equivalent format pattern for a T-SQL style code.
741    ///
742    /// Returns the pattern in strftime style for use in other dialects.
743    #[must_use]
744    pub fn to_format_pattern(&self) -> &'static str {
745        match self {
746            TsqlStyleCode::Default100 => "%b %d %Y %I:%M%p",
747            TsqlStyleCode::UsaDate => "%m/%d/%Y",
748            TsqlStyleCode::AnsiDate => "%Y.%m.%d",
749            TsqlStyleCode::BritishDate => "%d/%m/%Y",
750            TsqlStyleCode::GermanDate => "%d.%m.%Y",
751            TsqlStyleCode::ItalianDate => "%d-%m-%Y",
752            TsqlStyleCode::DayMonYear => "%d %b %Y",
753            TsqlStyleCode::MonDayYear => "%b %d, %Y",
754            TsqlStyleCode::TimeOnly => "%H:%M:%S",
755            TsqlStyleCode::UsaDashes => "%m-%d-%Y",
756            TsqlStyleCode::JapanDate => "%Y/%m/%d",
757            TsqlStyleCode::IsoBasic => "%Y%m%d",
758            TsqlStyleCode::TimeWithMs => "%H:%M:%S:%f",
759            TsqlStyleCode::OdbcCanonical => "%Y-%m-%d %H:%M:%S",
760            TsqlStyleCode::OdbcWithMs => "%Y-%m-%d %H:%M:%S.%f",
761            TsqlStyleCode::Iso8601 => "%Y-%m-%dT%H:%M:%S.%f",
762            TsqlStyleCode::Iso8601Tz => "%Y-%m-%dT%H:%M:%S.%fZ",
763        }
764    }
765
766    /// Try to parse a T-SQL style code from a number.
767    pub fn from_code(code: i32) -> Option<Self> {
768        match code {
769            100 => Some(TsqlStyleCode::Default100),
770            101 => Some(TsqlStyleCode::UsaDate),
771            102 => Some(TsqlStyleCode::AnsiDate),
772            103 => Some(TsqlStyleCode::BritishDate),
773            104 => Some(TsqlStyleCode::GermanDate),
774            105 => Some(TsqlStyleCode::ItalianDate),
775            106 => Some(TsqlStyleCode::DayMonYear),
776            107 => Some(TsqlStyleCode::MonDayYear),
777            108 => Some(TsqlStyleCode::TimeOnly),
778            110 => Some(TsqlStyleCode::UsaDashes),
779            111 => Some(TsqlStyleCode::JapanDate),
780            112 => Some(TsqlStyleCode::IsoBasic),
781            114 => Some(TsqlStyleCode::TimeWithMs),
782            120 => Some(TsqlStyleCode::OdbcCanonical),
783            121 => Some(TsqlStyleCode::OdbcWithMs),
784            126 => Some(TsqlStyleCode::Iso8601),
785            127 => Some(TsqlStyleCode::Iso8601Tz),
786            _ => None,
787        }
788    }
789
790    /// Get the numeric style code.
791    pub fn code(&self) -> i32 {
792        *self as i32
793    }
794}
795
796// ═══════════════════════════════════════════════════════════════════════════
797// Warnings for unsupported conversions
798// ═══════════════════════════════════════════════════════════════════════════
799
800/// Result of a format conversion that may include warnings.
801#[derive(Debug, Clone)]
802pub struct FormatConversionResult {
803    /// The converted format string.
804    pub format: String,
805    /// Warnings about unsupported or potentially lossy conversions.
806    pub warnings: Vec<String>,
807}
808
809/// Convert a time format string with warning collection.
810///
811/// Similar to `format_time` but collects warnings about specifiers
812/// that don't have direct equivalents in the target format.
813#[must_use]
814pub fn format_time_with_warnings(
815    format_str: &str,
816    source: TimeFormatStyle,
817    target: TimeFormatStyle,
818) -> FormatConversionResult {
819    let mut warnings = Vec::new();
820    let mappings = get_format_mappings();
821
822    // Pre-scan for problematic specifiers
823    match source {
824        TimeFormatStyle::Strftime | TimeFormatStyle::Mysql | TimeFormatStyle::ClickHouse => {
825            let mut chars = format_str.chars().peekable();
826            while let Some(ch) = chars.next() {
827                if ch == '%'
828                    && let Some(&next) = chars.peek()
829                {
830                    chars.next();
831                    let spec = format!("%{}", next);
832                    let mapping = mappings.iter().find(|m| m.get(source) == spec);
833                    if let Some(m) = mapping
834                        && m.get(target).is_empty()
835                    {
836                        warnings.push(format!(
837                            "Format specifier '{}' has no equivalent in target format",
838                            spec
839                        ));
840                    }
841                }
842            }
843        }
844        _ => {
845            // For other styles, simplified warning check
846            // Full implementation would scan for style-specific specifiers
847        }
848    }
849
850    let format = format_time(format_str, source, target);
851    FormatConversionResult { format, warnings }
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857
858    #[test]
859    fn test_strftime_to_postgres() {
860        assert_eq!(
861            format_time("%Y-%m-%d", TimeFormatStyle::Strftime, TimeFormatStyle::Postgres),
862            "YYYY-MM-DD"
863        );
864        assert_eq!(
865            format_time("%H:%M:%S", TimeFormatStyle::Strftime, TimeFormatStyle::Postgres),
866            "HH24:MI:SS"
867        );
868        assert_eq!(
869            format_time("%Y-%m-%d %H:%M:%S", TimeFormatStyle::Strftime, TimeFormatStyle::Postgres),
870            "YYYY-MM-DD HH24:MI:SS"
871        );
872    }
873
874    #[test]
875    fn test_mysql_to_postgres() {
876        // MySQL uses %i for minutes
877        assert_eq!(
878            format_time("%Y-%m-%d %H:%i:%s", TimeFormatStyle::Mysql, TimeFormatStyle::Postgres),
879            "YYYY-MM-DD HH24:MI:SS"
880        );
881    }
882
883    #[test]
884    fn test_postgres_to_mysql() {
885        assert_eq!(
886            format_time("YYYY-MM-DD HH24:MI:SS", TimeFormatStyle::Postgres, TimeFormatStyle::Mysql),
887            "%Y-%m-%d %H:%i:%s"
888        );
889    }
890
891    #[test]
892    fn test_postgres_to_strftime() {
893        assert_eq!(
894            format_time("YYYY-MM-DD", TimeFormatStyle::Postgres, TimeFormatStyle::Strftime),
895            "%Y-%m-%d"
896        );
897    }
898
899    #[test]
900    fn test_strftime_to_java() {
901        assert_eq!(
902            format_time("%Y-%m-%d", TimeFormatStyle::Strftime, TimeFormatStyle::Java),
903            "yyyy-MM-dd"
904        );
905        assert_eq!(
906            format_time("%H:%M:%S", TimeFormatStyle::Strftime, TimeFormatStyle::Java),
907            "HH:mm:ss"
908        );
909    }
910
911    #[test]
912    fn test_java_to_strftime() {
913        assert_eq!(
914            format_time("yyyy-MM-dd", TimeFormatStyle::Java, TimeFormatStyle::Strftime),
915            "%Y-%m-%d"
916        );
917        assert_eq!(
918            format_time("HH:mm:ss", TimeFormatStyle::Java, TimeFormatStyle::Strftime),
919            "%H:%M:%S"
920        );
921    }
922
923    #[test]
924    fn test_strftime_to_snowflake() {
925        assert_eq!(
926            format_time("%Y-%m-%d", TimeFormatStyle::Strftime, TimeFormatStyle::Snowflake),
927            "YYYY-MM-DD"
928        );
929    }
930
931    #[test]
932    fn test_same_style_noop() {
933        let format = "%Y-%m-%d %H:%M:%S";
934        assert_eq!(
935            format_time(format, TimeFormatStyle::Strftime, TimeFormatStyle::Strftime),
936            format
937        );
938    }
939
940    #[test]
941    fn test_dialect_conversion() {
942        assert_eq!(
943            format_time_dialect("%Y-%m-%d %H:%i:%s", Dialect::Mysql, Dialect::Postgres),
944            "YYYY-MM-DD HH24:MI:SS"
945        );
946        assert_eq!(
947            format_time_dialect("YYYY-MM-DD HH24:MI:SS", Dialect::Postgres, Dialect::Spark),
948            "yyyy-MM-dd HH:mm:ss"
949        );
950    }
951
952    #[test]
953    fn test_literal_preservation() {
954        // Literal characters should be preserved
955        assert_eq!(
956            format_time("%Y/%m/%d", TimeFormatStyle::Strftime, TimeFormatStyle::Postgres),
957            "YYYY/MM/DD"
958        );
959        assert_eq!(
960            format_time("%Y at %H:%M", TimeFormatStyle::Strftime, TimeFormatStyle::Postgres),
961            "YYYY at HH24:MI"
962        );
963    }
964
965    #[test]
966    fn test_tsql_style_codes() {
967        assert_eq!(TsqlStyleCode::OdbcCanonical.to_format_pattern(), "%Y-%m-%d %H:%M:%S");
968        assert_eq!(TsqlStyleCode::UsaDate.to_format_pattern(), "%m/%d/%Y");
969        assert_eq!(TsqlStyleCode::from_code(120), Some(TsqlStyleCode::OdbcCanonical));
970        assert_eq!(TsqlStyleCode::from_code(999), None);
971    }
972
973    #[test]
974    fn test_12hour_format() {
975        assert_eq!(
976            format_time("%I:%M %p", TimeFormatStyle::Strftime, TimeFormatStyle::Postgres),
977            "HH12:MI AM"
978        );
979    }
980
981    #[test]
982    fn test_month_names() {
983        assert_eq!(
984            format_time("%b %d, %Y", TimeFormatStyle::Strftime, TimeFormatStyle::Postgres),
985            "Mon DD, YYYY"
986        );
987        assert_eq!(
988            format_time("%B", TimeFormatStyle::Strftime, TimeFormatStyle::Mysql),
989            "%M"
990        );
991    }
992
993    #[test]
994    fn test_format_style_for_dialect() {
995        assert_eq!(TimeFormatStyle::for_dialect(Dialect::Mysql), TimeFormatStyle::Mysql);
996        assert_eq!(TimeFormatStyle::for_dialect(Dialect::Postgres), TimeFormatStyle::Postgres);
997        assert_eq!(TimeFormatStyle::for_dialect(Dialect::Spark), TimeFormatStyle::Java);
998        assert_eq!(TimeFormatStyle::for_dialect(Dialect::Snowflake), TimeFormatStyle::Snowflake);
999        assert_eq!(TimeFormatStyle::for_dialect(Dialect::BigQuery), TimeFormatStyle::Strftime);
1000    }
1001}