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