Skip to main content

citadel_sql/
datetime.rs

1//! Date/Time/Timestamp/Interval support for Citadel SQL.
2//!
3//! Thin wrapper around [`jiff`] so the rest of the codebase doesn't depend on it
4//! directly. Timestamps are UTC microseconds since 1970-01-01. INTERVAL is
5//! PG-compatible: `(months: i32, days: i32, micros: i64)`.
6
7use crate::error::{Result, SqlError};
8use crate::types::Value;
9use jiff::civil::{Date as JDate, DateTime as JDateTime, Time as JTime};
10use jiff::tz::TimeZone;
11use jiff::{Span, Timestamp as JTimestamp, ToSpan, Unit, Zoned};
12
13pub const MICROS_PER_SEC: i64 = 1_000_000;
14pub const MICROS_PER_MIN: i64 = 60 * MICROS_PER_SEC;
15pub const MICROS_PER_HOUR: i64 = 60 * MICROS_PER_MIN;
16pub const MICROS_PER_DAY: i64 = 24 * MICROS_PER_HOUR;
17
18/// i64 µs / 86_400_000_000 wraps to i32 for max representable date: ~292k years.
19pub const DATE_INFINITY_DAYS: i32 = i32::MAX;
20pub const DATE_NEG_INFINITY_DAYS: i32 = i32::MIN;
21pub const TS_INFINITY_MICROS: i64 = i64::MAX;
22pub const TS_NEG_INFINITY_MICROS: i64 = i64::MIN;
23
24pub fn is_infinity_date(d: i32) -> bool {
25    d == DATE_INFINITY_DAYS || d == DATE_NEG_INFINITY_DAYS
26}
27
28pub fn is_infinity_ts(t: i64) -> bool {
29    t == TS_INFINITY_MICROS || t == TS_NEG_INFINITY_MICROS
30}
31
32/// Unix epoch as a jiff civil Date (avoid `JDate::ZERO` which is year 1, not 1970).
33fn epoch_date() -> JDate {
34    JDate::new(1970, 1, 1).expect("1970-01-01 is a valid date")
35}
36
37/// Convert i32 days-since-1970 to civil Gregorian (year, month, day).
38pub fn days_to_ymd(days: i32) -> (i32, u8, u8) {
39    let epoch = epoch_date();
40    let d = epoch.checked_add((days as i64).days()).unwrap_or(epoch);
41    (d.year() as i32, d.month() as u8, d.day() as u8)
42}
43
44/// Convert (year, month, day) Gregorian to i32 days-since-1970.
45pub fn ymd_to_days(y: i32, m: u8, d: u8) -> Option<i32> {
46    let date = JDate::new(y as i16, m as i8, d as i8).ok()?;
47    let span = date.since((Unit::Day, epoch_date())).ok()?;
48    let days = span.get_days() as i64;
49    if (i32::MIN as i64..=i32::MAX as i64).contains(&days) {
50        Some(days as i32)
51    } else {
52        None
53    }
54}
55
56/// Convert µs-since-midnight to (hour, minute, second, subsec_micros).
57pub fn micros_to_hmsn(micros: i64) -> (u8, u8, u8, u32) {
58    let hour = (micros / MICROS_PER_HOUR) as u8;
59    let rem = micros % MICROS_PER_HOUR;
60    let min = (rem / MICROS_PER_MIN) as u8;
61    let rem = rem % MICROS_PER_MIN;
62    let sec = (rem / MICROS_PER_SEC) as u8;
63    let subsec = (rem % MICROS_PER_SEC) as u32;
64    (hour, min, sec, subsec)
65}
66
67/// Convert (hour, minute, second, subsec_micros) to µs since midnight.
68pub fn hmsn_to_micros(h: u8, m: u8, s: u8, us: u32) -> Option<i64> {
69    let total = (h as i64) * MICROS_PER_HOUR
70        + (m as i64) * MICROS_PER_MIN
71        + (s as i64) * MICROS_PER_SEC
72        + us as i64;
73    if (0..=MICROS_PER_DAY).contains(&total) {
74        Some(total)
75    } else {
76        None
77    }
78}
79
80/// Split µs since 1970-UTC into `(date_days, time_micros)`.
81pub fn ts_split(micros: i64) -> (i32, i64) {
82    let days = micros.div_euclid(MICROS_PER_DAY);
83    let rem = micros.rem_euclid(MICROS_PER_DAY);
84    (days as i32, rem)
85}
86
87/// Combine i32 date-days and i64 µs-of-day into µs-since-1970-UTC.
88pub fn ts_combine(date_days: i32, time_micros: i64) -> i64 {
89    (date_days as i64) * MICROS_PER_DAY + time_micros
90}
91
92/// Convert a date to a timestamp at midnight UTC.
93pub fn date_to_ts(days: i32) -> i64 {
94    (days as i64).saturating_mul(MICROS_PER_DAY)
95}
96
97/// Floor-divide timestamp µs to date days (correct for pre-1970 negative values).
98pub fn ts_to_date_floor(micros: i64) -> i32 {
99    if micros == TS_INFINITY_MICROS {
100        DATE_INFINITY_DAYS
101    } else if micros == TS_NEG_INFINITY_MICROS {
102        DATE_NEG_INFINITY_DAYS
103    } else {
104        micros.div_euclid(MICROS_PER_DAY) as i32
105    }
106}
107
108/// Parse an ISO 8601 DATE literal (`YYYY-MM-DD`, optional `BC` suffix, `'infinity'` / `'-infinity'`).
109pub fn parse_date(s: &str) -> Result<i32> {
110    let trimmed = s.trim();
111    let lower = trimmed.to_ascii_lowercase();
112    if lower == "infinity" || lower == "+infinity" {
113        return Ok(DATE_INFINITY_DAYS);
114    }
115    if lower == "-infinity" {
116        return Ok(DATE_NEG_INFINITY_DAYS);
117    }
118
119    // PG BC convention: "0001 BC" == astronomical year 0; "N BC" == year -(N-1).
120    let (body, is_bc) = if let Some(stripped) = trimmed.strip_suffix(" BC") {
121        (stripped.trim(), true)
122    } else if let Some(stripped) = trimmed.strip_suffix(" bc") {
123        (stripped.trim(), true)
124    } else {
125        (trimmed, false)
126    };
127
128    let d = JDate::strptime("%Y-%m-%d", body)
129        .map_err(|e| SqlError::InvalidDateLiteral(format!("{body}: {e}")))?;
130    if d.year() == 0 {
131        return Err(SqlError::InvalidDateLiteral(
132            "year 0 is not supported; use '0001-01-01 BC' for 1 BC".into(),
133        ));
134    }
135    let year_adjusted = if is_bc {
136        -(d.year() as i32 - 1)
137    } else {
138        d.year() as i32
139    };
140    let canonical = JDate::new(year_adjusted as i16, d.month(), d.day())
141        .map_err(|e| SqlError::InvalidDateLiteral(format!("{body}: {e}")))?;
142    let span = canonical
143        .since((Unit::Day, epoch_date()))
144        .map_err(|e| SqlError::InvalidDateLiteral(format!("{body}: {e}")))?;
145    let days = span.get_days() as i64;
146    if (i32::MIN as i64..=i32::MAX as i64).contains(&days) {
147        Ok(days as i32)
148    } else {
149        Err(SqlError::InvalidDateLiteral(format!(
150            "{body}: date out of i32 range"
151        )))
152    }
153}
154
155/// Parse an ISO 8601 TIME literal (`HH:MM:SS[.ffffff]`).
156pub fn parse_time(s: &str) -> Result<i64> {
157    let trimmed = s.trim();
158    // Accept 24:00:00 as end-of-day sentinel (PG behavior).
159    if trimmed == "24:00:00" || trimmed == "24:00:00.000000" {
160        return Ok(MICROS_PER_DAY);
161    }
162    let t = JTime::strptime("%H:%M:%S%.f", trimmed)
163        .or_else(|_| JTime::strptime("%H:%M:%S", trimmed))
164        .or_else(|_| JTime::strptime("%H:%M", trimmed))
165        .map_err(|e| SqlError::InvalidTimeLiteral(format!("{trimmed}: {e}")))?;
166    let subsec_micros = (t.subsec_nanosecond() / 1000) as u32;
167    hmsn_to_micros(
168        t.hour() as u8,
169        t.minute() as u8,
170        t.second() as u8,
171        subsec_micros,
172    )
173    .ok_or_else(|| SqlError::InvalidTimeLiteral(format!("{trimmed}: out of range")))
174}
175
176/// Parse an ISO 8601 TIMESTAMP literal (naive `YYYY-MM-DD[T ]HH:MM:SS[.ffffff]` or with offset/zone).
177/// Accepts `Z`, fixed offsets (`+HH:MM`), and IANA zone names (`America/New_York`).
178/// `'infinity'` / `'-infinity'` map to sentinel values.
179pub fn parse_timestamp(s: &str) -> Result<i64> {
180    let trimmed = s.trim();
181    let lower = trimmed.to_ascii_lowercase();
182    if lower == "infinity" || lower == "+infinity" {
183        return Ok(TS_INFINITY_MICROS);
184    }
185    if lower == "-infinity" {
186        return Ok(TS_NEG_INFINITY_MICROS);
187    }
188
189    // Strip trailing " BC" (case-insensitive) same as parse_date.
190    let (body, is_bc) = if let Some(stripped) = trimmed.strip_suffix(" BC") {
191        (stripped.trim_end(), true)
192    } else if let Some(stripped) = trimmed.strip_suffix(" bc") {
193        (stripped.trim_end(), true)
194    } else {
195        (trimmed, false)
196    };
197
198    // Try fully-qualified (Zoned with IANA zone or offset). BC+zone combos are rare; skip if BC.
199    if !is_bc {
200        if let Ok(z) = body.parse::<Zoned>() {
201            reject_year_zero_ts(z.timestamp().as_microsecond())?;
202            return Ok(z.timestamp().as_microsecond());
203        }
204        // Try as bare RFC 3339 / ISO 8601 with offset / Z.
205        if let Ok(ts) = body.parse::<JTimestamp>() {
206            reject_year_zero_ts(ts.as_microsecond())?;
207            return Ok(ts.as_microsecond());
208        }
209    }
210
211    // Try as naive wall-clock: interpret as UTC.
212    // Accept both "2024-01-15 12:30:00" and "2024-01-15T12:30:00" and variations.
213    let parsers = [
214        "%Y-%m-%d %H:%M:%S%.f",
215        "%Y-%m-%dT%H:%M:%S%.f",
216        "%Y-%m-%d %H:%M:%S",
217        "%Y-%m-%dT%H:%M:%S",
218        "%Y-%m-%d %H:%M",
219        "%Y-%m-%dT%H:%M",
220        "%Y-%m-%d",
221    ];
222    for fmt in &parsers {
223        if let Ok(dt) = JDateTime::strptime(fmt, body) {
224            let adjusted = apply_bc_and_check_year_zero(dt, is_bc, body)?;
225            return adjusted
226                .to_zoned(TimeZone::UTC)
227                .map(|z| z.timestamp().as_microsecond())
228                .map_err(|e| SqlError::InvalidTimestampLiteral(format!("{body}: {e}")));
229        }
230    }
231    // Also try "IANA-zone-suffix" parsing: e.g. "2024-01-15 12:00:00 America/New_York".
232    if !is_bc {
233        if let Some(space_idx) = body.rfind(' ') {
234            let (ts_part, zone_part) = body.split_at(space_idx);
235            let zone_name = zone_part.trim();
236            if let Ok(tz) = TimeZone::get(zone_name) {
237                for fmt in &parsers {
238                    if let Ok(dt) = JDateTime::strptime(fmt, ts_part.trim()) {
239                        if dt.year() == 0 {
240                            return Err(SqlError::InvalidTimestampLiteral(
241                                "year 0 is not supported; use 'YYYY-MM-DD HH:MM:SS BC' for 1 BC"
242                                    .into(),
243                            ));
244                        }
245                        return dt
246                            .to_zoned(tz.clone())
247                            .map(|z| z.timestamp().as_microsecond())
248                            .map_err(|e| {
249                                SqlError::InvalidTimestampLiteral(format!("{body}: {e}"))
250                            });
251                    }
252                }
253            }
254        }
255    }
256    Err(SqlError::InvalidTimestampLiteral(format!(
257        "{trimmed}: unrecognized timestamp format"
258    )))
259}
260
261fn reject_year_zero_ts(micros: i64) -> Result<()> {
262    let date_days = ts_to_date_floor(micros);
263    let (y, _, _) = days_to_ymd(date_days);
264    if y == 0 {
265        return Err(SqlError::InvalidTimestampLiteral(
266            "year 0 is not supported; use 'YYYY-MM-DD HH:MM:SS BC' for 1 BC".into(),
267        ));
268    }
269    Ok(())
270}
271
272fn apply_bc_and_check_year_zero(dt: JDateTime, is_bc: bool, body: &str) -> Result<JDateTime> {
273    if dt.year() == 0 {
274        return Err(SqlError::InvalidTimestampLiteral(
275            "year 0 is not supported; use 'YYYY-MM-DD HH:MM:SS BC' for 1 BC".into(),
276        ));
277    }
278    if !is_bc {
279        return Ok(dt);
280    }
281    // N BC → astronomical year -(N-1).
282    let astro_year = -(dt.year() as i32 - 1);
283    let date = JDate::new(astro_year as i16, dt.month(), dt.day())
284        .map_err(|e| SqlError::InvalidTimestampLiteral(format!("{body}: {e}")))?;
285    let time = JTime::new(dt.hour(), dt.minute(), dt.second(), dt.subsec_nanosecond())
286        .map_err(|e| SqlError::InvalidTimestampLiteral(format!("{body}: {e}")))?;
287    Ok(JDateTime::from_parts(date, time))
288}
289
290/// Parse a SQL INTERVAL literal. Accepts PG verbose form (`'1 year 2 months 3 days 04:05:06.789'`),
291/// SQL standard qualified form (`'5' DAY`), and ISO 8601 duration (`'P1Y2M3DT4H5M6S'`).
292pub fn parse_interval(s: &str) -> Result<(i32, i32, i64)> {
293    let trimmed = s.trim();
294    if trimmed.is_empty() {
295        return Err(SqlError::InvalidIntervalLiteral("empty interval".into()));
296    }
297
298    // Try ISO 8601 duration first.
299    if let Some(rest) = trimmed
300        .strip_prefix('P')
301        .or_else(|| trimmed.strip_prefix('-').and_then(|r| r.strip_prefix('P')))
302    {
303        let negate = trimmed.starts_with('-');
304        return parse_iso8601_duration(rest, negate);
305    }
306
307    // PG verbose: "1 year 2 months 3 days 04:05:06.789" (optional @ prefix, `ago` suffix).
308    parse_pg_interval(trimmed)
309}
310
311fn parse_iso8601_duration(s: &str, global_negate: bool) -> Result<(i32, i32, i64)> {
312    // P[nY][nM][nW][nD][T[nH][nM][nS]]
313    let mut months: i64 = 0;
314    let mut days: i64 = 0;
315    let mut micros: i64 = 0;
316    let mut in_time = false;
317    let mut num_buf = String::new();
318    let sign = if global_negate { -1i64 } else { 1 };
319
320    for ch in s.chars() {
321        if ch == 'T' {
322            in_time = true;
323            continue;
324        }
325        if ch.is_ascii_digit() || ch == '.' || ch == '-' {
326            num_buf.push(ch);
327            continue;
328        }
329        if num_buf.is_empty() {
330            return Err(SqlError::InvalidIntervalLiteral(format!(
331                "expected number before '{ch}'"
332            )));
333        }
334        let v: f64 = num_buf
335            .parse()
336            .map_err(|_| SqlError::InvalidIntervalLiteral(format!("invalid number: {num_buf}")))?;
337        num_buf.clear();
338        let v_units = sign * v as i64;
339        let v_frac_micros = ((v.fract() * 1_000_000.0) as i64) * sign;
340        match ch {
341            'Y' if !in_time => months = months.saturating_add(v_units * 12),
342            'M' if !in_time => months = months.saturating_add(v_units),
343            'W' if !in_time => days = days.saturating_add(v_units * 7),
344            'D' if !in_time => days = days.saturating_add(v_units),
345            'H' if in_time => micros = micros.saturating_add(v_units * MICROS_PER_HOUR),
346            'M' if in_time => micros = micros.saturating_add(v_units * MICROS_PER_MIN),
347            'S' if in_time => {
348                micros = micros.saturating_add(v_units * MICROS_PER_SEC + v_frac_micros)
349            }
350            _ => {
351                return Err(SqlError::InvalidIntervalLiteral(format!(
352                    "unknown unit '{ch}' (in_time={in_time})"
353                )))
354            }
355        }
356    }
357    if !num_buf.is_empty() {
358        return Err(SqlError::InvalidIntervalLiteral(format!(
359            "trailing number without unit: {num_buf}"
360        )));
361    }
362    Ok((clamp_i32(months)?, clamp_i32(days)?, micros))
363}
364
365fn parse_pg_interval(s: &str) -> Result<(i32, i32, i64)> {
366    let mut s = s.trim().to_ascii_lowercase();
367    if let Some(rest) = s.strip_prefix('@') {
368        s = rest.trim().to_string();
369    }
370    let ago = s.ends_with(" ago");
371    if ago {
372        s.truncate(s.len() - 4);
373        s = s.trim().to_string();
374    }
375    let sign: i64 = if ago { -1 } else { 1 };
376
377    let mut months: i64 = 0;
378    let mut days: i64 = 0;
379    let mut micros: i64 = 0;
380
381    let tokens: Vec<&str> = s.split_whitespace().collect();
382    let mut i = 0;
383    while i < tokens.len() {
384        let tok = tokens[i];
385        // "HH:MM:SS[.fff]" form.
386        if tok.contains(':') {
387            let (h, m, sc, us) = parse_hms_token(tok)?;
388            let tok_sign = if tok.starts_with('-') { -1 } else { 1 };
389            let tok_micros =
390                (h * MICROS_PER_HOUR + m * MICROS_PER_MIN + sc * MICROS_PER_SEC + us as i64)
391                    * tok_sign
392                    * sign;
393            micros = micros.saturating_add(tok_micros);
394            i += 1;
395            continue;
396        }
397
398        // "N unit" form.
399        let num: f64 = tok.parse().map_err(|_| {
400            SqlError::InvalidIntervalLiteral(format!("expected number, got '{tok}'"))
401        })?;
402        if i + 1 >= tokens.len() {
403            return Err(SqlError::InvalidIntervalLiteral(format!(
404                "missing unit after '{tok}'"
405            )));
406        }
407        let unit = tokens[i + 1].trim_end_matches(',');
408        let v_units = sign * num as i64;
409        let v_frac_micros = ((num.fract() * 1_000_000.0) as i64) * sign;
410        match unit {
411            "year" | "years" | "yr" | "yrs" | "y" => months = months.saturating_add(v_units * 12),
412            "month" | "months" | "mon" | "mons" => months = months.saturating_add(v_units),
413            "week" | "weeks" | "w" => days = days.saturating_add(v_units * 7),
414            "day" | "days" | "d" => days = days.saturating_add(v_units),
415            "hour" | "hours" | "hr" | "hrs" | "h" => {
416                micros = micros.saturating_add(v_units * MICROS_PER_HOUR)
417            }
418            "minute" | "minutes" | "min" | "mins" | "m" => {
419                micros = micros.saturating_add(v_units * MICROS_PER_MIN)
420            }
421            "second" | "seconds" | "sec" | "secs" | "s" => {
422                micros = micros.saturating_add(v_units * MICROS_PER_SEC + v_frac_micros)
423            }
424            "millisecond" | "milliseconds" | "ms" => micros = micros.saturating_add(v_units * 1000),
425            "microsecond" | "microseconds" | "us" => micros = micros.saturating_add(v_units),
426            other => {
427                return Err(SqlError::InvalidIntervalLiteral(format!(
428                    "unknown unit: {other}"
429                )))
430            }
431        }
432        i += 2;
433    }
434    Ok((clamp_i32(months)?, clamp_i32(days)?, micros))
435}
436
437fn parse_hms_token(tok: &str) -> Result<(i64, i64, i64, u32)> {
438    let tok = tok.trim_start_matches('-').trim_start_matches('+');
439    let mut parts = tok.split(':');
440    let h: i64 = parts
441        .next()
442        .ok_or_else(|| SqlError::InvalidIntervalLiteral(format!("bad hms: {tok}")))?
443        .parse()
444        .map_err(|_| SqlError::InvalidIntervalLiteral(format!("bad hour: {tok}")))?;
445    let m: i64 = parts
446        .next()
447        .ok_or_else(|| SqlError::InvalidIntervalLiteral(format!("bad hms: {tok}")))?
448        .parse()
449        .map_err(|_| SqlError::InvalidIntervalLiteral(format!("bad minute: {tok}")))?;
450    let (sc, us) = if let Some(sec_part) = parts.next() {
451        if let Some((s_whole, s_frac)) = sec_part.split_once('.') {
452            let s: i64 = s_whole
453                .parse()
454                .map_err(|_| SqlError::InvalidIntervalLiteral(format!("bad second: {tok}")))?;
455            // Pad / truncate fractional to 6 digits.
456            let mut frac = s_frac.to_string();
457            while frac.len() < 6 {
458                frac.push('0');
459            }
460            frac.truncate(6);
461            let us: u32 = frac
462                .parse()
463                .map_err(|_| SqlError::InvalidIntervalLiteral(format!("bad subsec: {tok}")))?;
464            (s, us)
465        } else {
466            let s: i64 = sec_part
467                .parse()
468                .map_err(|_| SqlError::InvalidIntervalLiteral(format!("bad second: {tok}")))?;
469            (s, 0u32)
470        }
471    } else {
472        (0, 0u32)
473    };
474    Ok((h, m, sc, us))
475}
476
477fn clamp_i32(n: i64) -> Result<i32> {
478    if (i32::MIN as i64..=i32::MAX as i64).contains(&n) {
479        Ok(n as i32)
480    } else {
481        Err(SqlError::InvalidIntervalLiteral(format!(
482            "interval component overflow: {n}"
483        )))
484    }
485}
486
487pub fn format_date(days: i32) -> String {
488    if days == DATE_INFINITY_DAYS {
489        return "infinity".to_string();
490    }
491    if days == DATE_NEG_INFINITY_DAYS {
492        return "-infinity".to_string();
493    }
494    let (y, m, d) = days_to_ymd(days);
495    if y >= 1 {
496        format!("{y:04}-{m:02}-{d:02}")
497    } else {
498        // Astronomical year N ≤ 0 → (1 - N) BC; i.e., year 0 = 1 BC, year -1 = 2 BC.
499        format!("{:04}-{m:02}-{d:02} BC", 1 - y)
500    }
501}
502
503pub fn format_time(micros: i64) -> String {
504    if micros == MICROS_PER_DAY {
505        return "24:00:00".to_string();
506    }
507    let (h, m, s, us) = micros_to_hmsn(micros);
508    if us == 0 {
509        format!("{h:02}:{m:02}:{s:02}")
510    } else {
511        format!("{h:02}:{m:02}:{s:02}.{us:06}")
512    }
513}
514
515pub fn format_timestamp(micros: i64) -> String {
516    if micros == TS_INFINITY_MICROS {
517        return "infinity".to_string();
518    }
519    if micros == TS_NEG_INFINITY_MICROS {
520        return "-infinity".to_string();
521    }
522    let (date_days, time_micros) = ts_split(micros);
523    let date_part = format_date(date_days);
524    let time_part = format_time(time_micros);
525    format!("{date_part} {time_part}")
526}
527
528pub fn format_timestamp_in_zone(micros: i64, zone: &str) -> Result<String> {
529    if micros == TS_INFINITY_MICROS {
530        return Ok("infinity".to_string());
531    }
532    if micros == TS_NEG_INFINITY_MICROS {
533        return Ok("-infinity".to_string());
534    }
535    let tz = resolve_timezone(zone)?;
536    let ts = JTimestamp::from_microsecond(micros)
537        .map_err(|e| SqlError::InvalidTimestampLiteral(format!("{micros}: {e}")))?;
538    let z = ts.to_zoned(tz);
539    let subsec = z.subsec_nanosecond() / 1000;
540    let fmt = if subsec == 0 {
541        "%Y-%m-%d %H:%M:%S%:z"
542    } else {
543        "%Y-%m-%d %H:%M:%S%.6f%:z"
544    };
545    z.strftime(fmt).to_string().pipe(Ok)
546}
547
548/// Accepts IANA names, `Z`, `UTC`, and ISO-8601 fixed offsets; rejects POSIX
549/// `UTC+5` shorthand (sign-inverted in POSIX, ambiguous in practice).
550pub fn resolve_timezone(zone: &str) -> Result<TimeZone> {
551    let trimmed = zone.trim();
552    if let Ok(tz) = TimeZone::get(trimmed) {
553        return Ok(tz);
554    }
555    if let Some(offset) = parse_iso_fixed_offset(trimmed) {
556        return jiff::tz::Offset::from_seconds(offset)
557            .map(TimeZone::fixed)
558            .map_err(|e| SqlError::InvalidTimezone(format!("{zone}: {e}")));
559    }
560    let lower = trimmed.to_ascii_lowercase();
561    if lower.starts_with("utc+")
562        || lower.starts_with("utc-")
563        || lower.starts_with("gmt+")
564        || lower.starts_with("gmt-")
565    {
566        return Err(SqlError::InvalidTimezone(format!(
567            "{zone}: ambiguous POSIX form; use ISO-8601 offset like '+05:00' or a named zone"
568        )));
569    }
570    Err(SqlError::InvalidTimezone(format!(
571        "{zone}: not a recognized IANA name or ISO-8601 offset"
572    )))
573}
574
575/// Parse `Z`, `UTC`, `+HH:MM`, `-HH:MM`, `+HHMM`, `+HH` into signed seconds.
576fn parse_iso_fixed_offset(s: &str) -> Option<i32> {
577    if s.eq_ignore_ascii_case("z") || s.eq_ignore_ascii_case("utc") {
578        return Some(0);
579    }
580    let bytes = s.as_bytes();
581    if bytes.is_empty() {
582        return None;
583    }
584    let sign: i32 = match bytes[0] {
585        b'+' => 1,
586        b'-' => -1,
587        _ => return None,
588    };
589    let rest = &s[1..];
590    let (hh, mm) = if let Some((h, m)) = rest.split_once(':') {
591        (h, m)
592    } else if rest.len() == 4 {
593        (&rest[..2], &rest[2..])
594    } else if rest.len() == 2 {
595        (rest, "00")
596    } else {
597        return None;
598    };
599    let h: i32 = hh.parse().ok()?;
600    let m: i32 = mm.parse().ok()?;
601    if !(0..=23).contains(&h) || !(0..=59).contains(&m) {
602        return None;
603    }
604    Some(sign * (h * 3600 + m * 60))
605}
606
607pub fn format_interval(months: i32, days: i32, micros: i64) -> String {
608    if months == 0 && days == 0 && micros == 0 {
609        return "00:00:00".to_string();
610    }
611    let mut parts = Vec::with_capacity(4);
612    if months != 0 {
613        let years = months / 12;
614        let mon = months % 12;
615        if years != 0 {
616            parts.push(format!(
617                "{} year{}",
618                years,
619                if years.abs() == 1 { "" } else { "s" }
620            ));
621        }
622        if mon != 0 {
623            parts.push(format!(
624                "{} mon{}",
625                mon,
626                if mon.abs() == 1 { "" } else { "s" }
627            ));
628        }
629    }
630    if days != 0 {
631        parts.push(format!(
632            "{} day{}",
633            days,
634            if days.abs() == 1 { "" } else { "s" }
635        ));
636    }
637    if micros != 0 {
638        let sign = if micros < 0 { "-" } else { "" };
639        let abs_us = micros.unsigned_abs() as i64;
640        let (h, m, s, us) = micros_to_hmsn(abs_us);
641        if us == 0 {
642            parts.push(format!("{sign}{h:02}:{m:02}:{s:02}"));
643        } else {
644            parts.push(format!("{sign}{h:02}:{m:02}:{s:02}.{us:06}"));
645        }
646    }
647    parts.join(" ")
648}
649
650/// Extension to allow `x.pipe(Ok)` chaining for readability.
651trait Pipe: Sized {
652    fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
653        f(self)
654    }
655}
656impl<T> Pipe for T {}
657
658pub fn now_micros() -> i64 {
659    JTimestamp::now().as_microsecond()
660}
661
662thread_local! {
663    /// Scoped txn-start timestamp for PG-exact CURRENT_TIMESTAMP (stable per txn).
664    static TXN_CLOCK: std::cell::Cell<Option<i64>> = const { std::cell::Cell::new(None) };
665}
666
667/// Install a txn-start timestamp for the duration of `f`. Restores the previous
668/// value on return (nested-safe).
669pub fn with_txn_clock<R>(ts: Option<i64>, f: impl FnOnce() -> R) -> R {
670    TXN_CLOCK.with(|slot| {
671        let prev = slot.replace(ts);
672        let r = f();
673        slot.set(prev);
674        r
675    })
676}
677
678pub fn set_txn_clock(ts: Option<i64>) {
679    TXN_CLOCK.with(|slot| slot.set(ts));
680}
681
682/// Read the cached txn-start clock if one is installed, else a fresh `now_micros()`.
683/// Used by `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE` / `LOCALTIMESTAMP`.
684pub fn txn_or_clock_micros() -> i64 {
685    TXN_CLOCK.with(|slot| slot.get()).unwrap_or_else(now_micros)
686}
687
688pub fn today_days() -> i32 {
689    ts_to_date_floor(now_micros())
690}
691
692pub fn current_time_micros() -> i64 {
693    ts_split(now_micros()).1
694}
695
696pub fn add_interval_to_timestamp(ts: i64, months: i32, days: i32, micros: i64) -> Result<i64> {
697    if ts == TS_INFINITY_MICROS || ts == TS_NEG_INFINITY_MICROS {
698        return Ok(ts);
699    }
700    let jts =
701        JTimestamp::from_microsecond(ts).map_err(|e| SqlError::InvalidValue(format!("ts: {e}")))?;
702    // PG order: months, then days, then micros.
703    let span = Span::new()
704        .try_months(months as i64)
705        .map_err(|e| SqlError::InvalidValue(format!("months overflow: {e}")))?
706        .try_days(days as i64)
707        .map_err(|e| SqlError::InvalidValue(format!("days overflow: {e}")))?
708        .try_microseconds(micros)
709        .map_err(|e| SqlError::InvalidValue(format!("micros overflow: {e}")))?;
710    let result = jts
711        .to_zoned(TimeZone::UTC)
712        .checked_add(span)
713        .map_err(|_| SqlError::IntegerOverflow)?;
714    Ok(result.timestamp().as_microsecond())
715}
716
717/// PG rule: DATE + INTERVAL always yields TIMESTAMP.
718pub fn add_interval_to_date(days: i32, months: i32, i_days: i32, micros: i64) -> Result<i64> {
719    if is_infinity_date(days) {
720        return Ok(if days == DATE_INFINITY_DAYS {
721            TS_INFINITY_MICROS
722        } else {
723            TS_NEG_INFINITY_MICROS
724        });
725    }
726    let ts = date_to_ts(days);
727    add_interval_to_timestamp(ts, months, i_days, micros)
728}
729
730pub fn add_days_to_date(days: i32, n: i64) -> Result<i32> {
731    if is_infinity_date(days) {
732        return Ok(days);
733    }
734    let new_days = (days as i64)
735        .checked_add(n)
736        .ok_or(SqlError::IntegerOverflow)?;
737    if new_days >= i32::MIN as i64 && new_days <= i32::MAX as i64 {
738        Ok(new_days as i32)
739    } else {
740        Err(SqlError::IntegerOverflow)
741    }
742}
743
744pub fn add_interval_to_time(t: i64, months: i32, days: i32, micros: i64) -> Result<i64> {
745    if months != 0 || days != 0 {
746        return Err(SqlError::InvalidValue(
747            "cannot add month/day interval to TIME".into(),
748        ));
749    }
750    // PG: TIME + interval wraps mod 24h.
751    let combined = t.checked_add(micros).unwrap_or(t);
752    Ok(combined.rem_euclid(MICROS_PER_DAY))
753}
754
755/// PG `timestamp - timestamp`: returns `(days, remainder_micros)` with months = 0.
756pub fn subtract_timestamps(a: i64, b: i64) -> (i32, i64) {
757    let diff = a.saturating_sub(b);
758    let days = (diff / MICROS_PER_DAY) as i32;
759    let micros = diff % MICROS_PER_DAY;
760    (days, micros)
761}
762
763/// AGE(a, b) — symbolic diff preserving months/years. Uses jiff's Span rounding to Year unit.
764pub fn age(ts_a: i64, ts_b: i64) -> Result<(i32, i32, i64)> {
765    let a = JTimestamp::from_microsecond(ts_a)
766        .map_err(|e| SqlError::InvalidValue(format!("ts_a: {e}")))?
767        .to_zoned(TimeZone::UTC);
768    let b = JTimestamp::from_microsecond(ts_b)
769        .map_err(|e| SqlError::InvalidValue(format!("ts_b: {e}")))?
770        .to_zoned(TimeZone::UTC);
771    let span = a
772        .since((Unit::Year, &b))
773        .map_err(|e| SqlError::InvalidValue(format!("age: {e}")))?;
774    span_to_triple(&span)
775}
776
777fn span_to_triple(span: &Span) -> Result<(i32, i32, i64)> {
778    let months = i64::from(span.get_years()) * 12 + i64::from(span.get_months());
779    let days = i64::from(span.get_weeks()) * 7 + i64::from(span.get_days());
780    let micros = i64::from(span.get_hours()) * MICROS_PER_HOUR
781        + span.get_minutes() * MICROS_PER_MIN
782        + span.get_seconds() * MICROS_PER_SEC
783        + span.get_milliseconds() * 1000
784        + span.get_microseconds()
785        + span.get_nanoseconds() / 1000;
786    Ok((clamp_i32(months)?, clamp_i32(days)?, micros))
787}
788
789pub fn justify_days(months: i32, days: i32, micros: i64) -> (i32, i32, i64) {
790    // Convert every 30 days into 1 month.
791    let extra_months = days / 30;
792    let rem_days = days % 30;
793    let new_months = months.saturating_add(extra_months);
794    (new_months, rem_days, micros)
795}
796
797pub fn justify_hours(months: i32, days: i32, micros: i64) -> (i32, i32, i64) {
798    // Convert every 24 hours into 1 day.
799    let extra_days = (micros / MICROS_PER_DAY) as i32;
800    let rem_micros = micros % MICROS_PER_DAY;
801    let new_days = days.saturating_add(extra_days);
802    (months, new_days, rem_micros)
803}
804
805pub fn justify_interval(months: i32, days: i32, micros: i64) -> (i32, i32, i64) {
806    let (m1, d1, us1) = justify_hours(months, days, micros);
807    let (m2, d2, us2) = justify_days(m1, d1, us1);
808    (m2, d2, us2)
809}
810
811/// PG-normalized total µs for comparison purposes (30-day month, 24-hour day).
812pub fn interval_to_total_micros(months: i32, days: i32, micros: i64) -> i128 {
813    (months as i128) * 30 * (MICROS_PER_DAY as i128)
814        + (days as i128) * (MICROS_PER_DAY as i128)
815        + micros as i128
816}
817
818pub fn extract(field: &str, v: &Value) -> Result<Value> {
819    let f = field.trim();
820    match v {
821        Value::Null => Ok(Value::Null),
822        Value::Date(d) => extract_from_date(f, *d),
823        Value::Time(t) => extract_from_time(f, *t),
824        Value::Timestamp(t) => extract_from_timestamp(f, *t),
825        Value::Interval {
826            months,
827            days,
828            micros,
829        } => extract_from_interval(f, *months, *days, *micros),
830        _ => Err(SqlError::TypeMismatch {
831            expected: "temporal type".into(),
832            got: v.data_type().to_string(),
833        }),
834    }
835}
836
837fn extract_from_date(field: &str, days: i32) -> Result<Value> {
838    if field.eq_ignore_ascii_case("epoch") {
839        return Ok(Value::Integer((days as i64) * 86400));
840    }
841    let (y, m, d) = days_to_ymd(days);
842    if field.eq_ignore_ascii_case("year") {
843        return Ok(Value::Integer(y as i64));
844    }
845    if field.eq_ignore_ascii_case("month") {
846        return Ok(Value::Integer(m as i64));
847    }
848    if field.eq_ignore_ascii_case("day") {
849        return Ok(Value::Integer(d as i64));
850    }
851    if field.eq_ignore_ascii_case("hour")
852        || field.eq_ignore_ascii_case("minute")
853        || field.eq_ignore_ascii_case("second")
854        || field.eq_ignore_ascii_case("microseconds")
855        || field.eq_ignore_ascii_case("milliseconds")
856    {
857        return Ok(Value::Integer(0));
858    }
859    // Fall-through: use a canonical lowercase form for the remaining rare fields.
860    let f = field.to_ascii_lowercase();
861    match f.as_str() {
862        "dow" => {
863            let jd = JDate::new(y as i16, m as i8, d as i8)
864                .map_err(|e| SqlError::InvalidValue(format!("{e}")))?;
865            // Jiff weekday: Monday=1..Sunday=7. PG dow: Sunday=0..Saturday=6.
866            let w = jd.weekday().to_monday_one_offset() as i64;
867            let dow = if w == 7 { 0 } else { w }; // Sunday: 7 → 0
868            Ok(Value::Integer(dow))
869        }
870        "isodow" => {
871            let jd = JDate::new(y as i16, m as i8, d as i8)
872                .map_err(|e| SqlError::InvalidValue(format!("{e}")))?;
873            Ok(Value::Integer(jd.weekday().to_monday_one_offset() as i64))
874        }
875        "doy" => {
876            let jd = JDate::new(y as i16, m as i8, d as i8)
877                .map_err(|e| SqlError::InvalidValue(format!("{e}")))?;
878            Ok(Value::Integer(jd.day_of_year() as i64))
879        }
880        "quarter" => Ok(Value::Integer(((m - 1) / 3 + 1) as i64)),
881        "decade" => Ok(Value::Integer((y / 10) as i64)),
882        "century" => Ok(Value::Integer(if y > 0 {
883            ((y - 1) / 100 + 1) as i64
884        } else {
885            (y / 100 - 1) as i64
886        })),
887        "millennium" => Ok(Value::Integer(if y > 0 {
888            ((y - 1) / 1000 + 1) as i64
889        } else {
890            (y / 1000 - 1) as i64
891        })),
892        "julian" => Ok(Value::Integer(days as i64 + 2_440_588)),
893        "week" | "isoyear" => {
894            let jd = JDate::new(y as i16, m as i8, d as i8)
895                .map_err(|e| SqlError::InvalidValue(format!("{e}")))?;
896            let iso = jd.iso_week_date();
897            if field == "week" {
898                Ok(Value::Integer(iso.week() as i64))
899            } else {
900                Ok(Value::Integer(iso.year() as i64))
901            }
902        }
903        _ => Err(SqlError::InvalidExtractField(format!("{field} from DATE"))),
904    }
905}
906
907fn extract_from_time(field: &str, micros: i64) -> Result<Value> {
908    let (h, m, s, us) = micros_to_hmsn(micros);
909    match field {
910        "hour" => Ok(Value::Integer(h as i64)),
911        "minute" => Ok(Value::Integer(m as i64)),
912        "second" => {
913            if us == 0 {
914                Ok(Value::Integer(s as i64))
915            } else {
916                Ok(Value::Real(s as f64 + (us as f64) / 1_000_000.0))
917            }
918        }
919        "microseconds" => Ok(Value::Integer((s as i64) * 1_000_000 + us as i64)),
920        "milliseconds" => Ok(Value::Real(s as f64 * 1000.0 + (us as f64) / 1000.0)),
921        "epoch" => Ok(Value::Integer(micros / MICROS_PER_SEC)),
922        _ => Err(SqlError::InvalidExtractField(format!("{field} from TIME"))),
923    }
924}
925
926fn extract_from_timestamp(field: &str, ts: i64) -> Result<Value> {
927    if field.eq_ignore_ascii_case("hour") {
928        return Ok(Value::Integer(
929            ts.rem_euclid(MICROS_PER_DAY) / MICROS_PER_HOUR,
930        ));
931    }
932    if field.eq_ignore_ascii_case("minute") {
933        return Ok(Value::Integer(
934            ts.rem_euclid(MICROS_PER_HOUR) / MICROS_PER_MIN,
935        ));
936    }
937    if field.eq_ignore_ascii_case("epoch") {
938        return Ok(Value::Integer(ts / MICROS_PER_SEC));
939    }
940    let (date_days, time_micros) = ts_split(ts);
941    // Date-level fields.
942    let date_fields = [
943        "year",
944        "month",
945        "day",
946        "dow",
947        "isodow",
948        "doy",
949        "quarter",
950        "decade",
951        "century",
952        "millennium",
953        "julian",
954        "week",
955        "isoyear",
956    ];
957    if date_fields.iter().any(|&f| field.eq_ignore_ascii_case(f)) {
958        return extract_from_date(field, date_days);
959    }
960    // Time-of-day fields (second, microseconds, milliseconds).
961    let time_fields = ["second", "microseconds", "milliseconds"];
962    if time_fields.iter().any(|&f| field.eq_ignore_ascii_case(f)) {
963        return extract_from_time(field, time_micros);
964    }
965    Err(SqlError::InvalidExtractField(format!(
966        "{field} from TIMESTAMP"
967    )))
968}
969
970fn extract_from_interval(field: &str, months: i32, days: i32, micros: i64) -> Result<Value> {
971    match field {
972        "year" => Ok(Value::Integer((months / 12) as i64)),
973        "month" => Ok(Value::Integer((months % 12) as i64)),
974        "day" => Ok(Value::Integer(days as i64)),
975        "hour" => Ok(Value::Integer(micros / MICROS_PER_HOUR)),
976        "minute" => Ok(Value::Integer((micros % MICROS_PER_HOUR) / MICROS_PER_MIN)),
977        "second" => {
978            let rem = micros % MICROS_PER_MIN;
979            let sec_part = rem / MICROS_PER_SEC;
980            let us_part = rem % MICROS_PER_SEC;
981            if us_part == 0 {
982                Ok(Value::Integer(sec_part))
983            } else {
984                Ok(Value::Real(sec_part as f64 + us_part as f64 / 1_000_000.0))
985            }
986        }
987        "microseconds" => Ok(Value::Integer(micros % MICROS_PER_MIN)),
988        "epoch" => {
989            let total = interval_to_total_micros(months, days, micros);
990            Ok(Value::Real(total as f64 / 1_000_000.0))
991        }
992        _ => Err(SqlError::InvalidExtractField(format!(
993            "{field} from INTERVAL"
994        ))),
995    }
996}
997
998pub fn date_trunc(unit: &str, v: &Value) -> Result<Value> {
999    let u = unit.trim().to_ascii_lowercase();
1000    match v {
1001        Value::Null => Ok(Value::Null),
1002        Value::Date(d) => date_trunc_date(&u, *d).map(Value::Date),
1003        Value::Timestamp(t) => date_trunc_timestamp(&u, *t).map(Value::Timestamp),
1004        Value::Time(t) => date_trunc_time(&u, *t).map(Value::Time),
1005        Value::Interval {
1006            months,
1007            days,
1008            micros,
1009        } => date_trunc_interval(&u, *months, *days, *micros).map(|(m, d, us)| Value::Interval {
1010            months: m,
1011            days: d,
1012            micros: us,
1013        }),
1014        _ => Err(SqlError::TypeMismatch {
1015            expected: "temporal type".into(),
1016            got: v.data_type().to_string(),
1017        }),
1018    }
1019}
1020
1021fn date_trunc_date(unit: &str, days: i32) -> Result<i32> {
1022    if is_infinity_date(days) {
1023        return Ok(days);
1024    }
1025    let (y, m, d) = days_to_ymd(days);
1026    match unit {
1027        "microseconds" | "milliseconds" | "second" | "minute" | "hour" | "day" => Ok(days),
1028        "week" => {
1029            // Monday-based ISO 8601.
1030            let jd = JDate::new(y as i16, m as i8, d as i8)
1031                .map_err(|e| SqlError::InvalidValue(format!("{e}")))?;
1032            let dow = jd.weekday().to_monday_one_offset() as i32; // 1=Mon..7=Sun
1033            add_days_to_date(days, -(dow - 1) as i64)
1034        }
1035        "month" => {
1036            ymd_to_days(y, m, 1).ok_or_else(|| SqlError::InvalidValue("date_trunc month".into()))
1037        }
1038        "quarter" => {
1039            let qm = ((m - 1) / 3) * 3 + 1;
1040            ymd_to_days(y, qm, 1).ok_or_else(|| SqlError::InvalidValue("date_trunc quarter".into()))
1041        }
1042        "year" => {
1043            ymd_to_days(y, 1, 1).ok_or_else(|| SqlError::InvalidValue("date_trunc year".into()))
1044        }
1045        "decade" => ymd_to_days(y - (y % 10), 1, 1)
1046            .ok_or_else(|| SqlError::InvalidValue("date_trunc decade".into())),
1047        "century" => {
1048            let cy = if y > 0 {
1049                ((y - 1) / 100) * 100 + 1
1050            } else {
1051                (y / 100) * 100 - 99
1052            };
1053            ymd_to_days(cy, 1, 1).ok_or_else(|| SqlError::InvalidValue("date_trunc century".into()))
1054        }
1055        "millennium" => {
1056            let my = if y > 0 {
1057                ((y - 1) / 1000) * 1000 + 1
1058            } else {
1059                (y / 1000) * 1000 - 999
1060            };
1061            ymd_to_days(my, 1, 1)
1062                .ok_or_else(|| SqlError::InvalidValue("date_trunc millennium".into()))
1063        }
1064        _ => Err(SqlError::InvalidDateTruncUnit(unit.into())),
1065    }
1066}
1067
1068fn date_trunc_timestamp(unit: &str, ts: i64) -> Result<i64> {
1069    if is_infinity_ts(ts) {
1070        return Ok(ts);
1071    }
1072    let (date_days, time_micros) = ts_split(ts);
1073    // time_micros is in 0..MICROS_PER_DAY (ts_split uses div_euclid), so `% unit_size` works.
1074    match unit {
1075        "microseconds" => Ok(ts),
1076        "milliseconds" => Ok(ts_combine(date_days, time_micros - time_micros % 1000)),
1077        "second" => Ok(ts_combine(
1078            date_days,
1079            time_micros - time_micros % MICROS_PER_SEC,
1080        )),
1081        "minute" => Ok(ts_combine(
1082            date_days,
1083            time_micros - time_micros % MICROS_PER_MIN,
1084        )),
1085        "hour" => Ok(ts_combine(
1086            date_days,
1087            time_micros - time_micros % MICROS_PER_HOUR,
1088        )),
1089        "day" => Ok(ts_combine(date_days, 0)),
1090        _ => {
1091            // Weekly+ units delegate to date-level truncation (time zeroed).
1092            let trunc_days = date_trunc_date(unit, date_days)?;
1093            Ok(ts_combine(trunc_days, 0))
1094        }
1095    }
1096}
1097
1098fn date_trunc_time(unit: &str, micros: i64) -> Result<i64> {
1099    match unit {
1100        "microseconds" => Ok(micros),
1101        "milliseconds" => Ok(micros - (micros % 1000)),
1102        "second" => Ok(micros - (micros % MICROS_PER_SEC)),
1103        "minute" => Ok(micros - (micros % MICROS_PER_MIN)),
1104        "hour" => Ok(micros - (micros % MICROS_PER_HOUR)),
1105        _ => Err(SqlError::InvalidDateTruncUnit(format!(
1106            "{unit} is invalid for TIME"
1107        ))),
1108    }
1109}
1110
1111fn date_trunc_interval(unit: &str, months: i32, days: i32, micros: i64) -> Result<(i32, i32, i64)> {
1112    match unit {
1113        "microseconds" => Ok((months, days, micros)),
1114        "milliseconds" => Ok((months, days, micros - (micros % 1000))),
1115        "second" => Ok((months, days, micros - (micros % MICROS_PER_SEC))),
1116        "minute" => Ok((months, days, micros - (micros % MICROS_PER_MIN))),
1117        "hour" => Ok((months, days, micros - (micros % MICROS_PER_HOUR))),
1118        "day" => Ok((months, days, 0)),
1119        "month" => Ok((months, 0, 0)),
1120        "year" => Ok(((months / 12) * 12, 0, 0)),
1121        "quarter" => Ok(((months / 3) * 3, 0, 0)),
1122        "decade" => Ok(((months / 120) * 120, 0, 0)),
1123        "century" => Ok(((months / 1200) * 1200, 0, 0)),
1124        "millennium" => Ok(((months / 12000) * 12000, 0, 0)),
1125        _ => Err(SqlError::InvalidDateTruncUnit(unit.into())),
1126    }
1127}
1128
1129pub fn strftime(fmt: &str, v: &Value) -> Result<String> {
1130    let ts_micros = match v {
1131        Value::Null => return Ok(String::new()),
1132        Value::Timestamp(t) => *t,
1133        Value::Date(d) => date_to_ts(*d),
1134        Value::Time(t) => *t, // time-only: use epoch date as anchor
1135        _ => {
1136            return Err(SqlError::TypeMismatch {
1137                expected: "temporal type".into(),
1138                got: v.data_type().to_string(),
1139            })
1140        }
1141    };
1142    let z = JTimestamp::from_microsecond(ts_micros)
1143        .map_err(|e| SqlError::InvalidValue(format!("ts: {e}")))?
1144        .to_zoned(TimeZone::UTC);
1145    // Rewrite %J, %f, %s — not supported by jiff — before formatting.
1146    let mut prepared = String::with_capacity(fmt.len());
1147    let mut chars = fmt.chars().peekable();
1148    while let Some(c) = chars.next() {
1149        if c == '%' {
1150            match chars.peek() {
1151                Some('J') => {
1152                    chars.next();
1153                    // Julian Day 2440587.5 = 1970-01-01 00:00:00 UTC (Julian days start at noon).
1154                    let julian = ts_to_date_floor(ts_micros) as f64
1155                        + 2_440_587.5
1156                        + (ts_split(ts_micros).1 as f64) / (MICROS_PER_DAY as f64);
1157                    prepared.push_str(&format!("{julian}"));
1158                }
1159                Some('f') => {
1160                    chars.next();
1161                    let subsec = ts_split(ts_micros).1 % MICROS_PER_SEC;
1162                    prepared.push_str(&format!("{:06}", subsec));
1163                }
1164                Some('s') => {
1165                    chars.next();
1166                    prepared.push_str(&format!("{}", ts_micros / MICROS_PER_SEC));
1167                }
1168                Some(&next) => {
1169                    prepared.push('%');
1170                    prepared.push(next);
1171                    chars.next();
1172                }
1173                None => prepared.push('%'),
1174            }
1175        } else {
1176            prepared.push(c);
1177        }
1178    }
1179    Ok(z.strftime(&prepared).to_string())
1180}
1181
1182/// Session-agnostic util used by eval.rs for SQL INTERVAL comparison normalization.
1183pub fn pg_normalized_interval_cmp(a: (i32, i32, i64), b: (i32, i32, i64)) -> std::cmp::Ordering {
1184    let at = interval_to_total_micros(a.0, a.1, a.2);
1185    let bt = interval_to_total_micros(b.0, b.1, b.2);
1186    at.cmp(&bt)
1187}
1188
1189#[cfg(test)]
1190#[path = "datetime_tests.rs"]
1191mod tests;