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
13/// Microseconds per second.
14pub const MICROS_PER_SEC: i64 = 1_000_000;
15/// Microseconds per minute.
16pub const MICROS_PER_MIN: i64 = 60 * MICROS_PER_SEC;
17/// Microseconds per hour.
18pub const MICROS_PER_HOUR: i64 = 60 * MICROS_PER_MIN;
19/// Microseconds per day.
20pub const MICROS_PER_DAY: i64 = 24 * MICROS_PER_HOUR;
21
22/// i64 µs / 86_400_000_000 wraps to i32 for max representable date: ~292k years.
23pub const DATE_INFINITY_DAYS: i32 = i32::MAX;
24pub const DATE_NEG_INFINITY_DAYS: i32 = i32::MIN;
25pub const TS_INFINITY_MICROS: i64 = i64::MAX;
26pub const TS_NEG_INFINITY_MICROS: i64 = i64::MIN;
27
28pub fn is_infinity_date(d: i32) -> bool {
29    d == DATE_INFINITY_DAYS || d == DATE_NEG_INFINITY_DAYS
30}
31
32pub fn is_infinity_ts(t: i64) -> bool {
33    t == TS_INFINITY_MICROS || t == TS_NEG_INFINITY_MICROS
34}
35
36/// Unix epoch as a jiff civil Date (avoid `JDate::ZERO` which is year 1, not 1970).
37fn epoch_date() -> JDate {
38    JDate::new(1970, 1, 1).expect("1970-01-01 is a valid date")
39}
40
41/// Convert i32 days-since-1970 to civil Gregorian (year, month, day).
42pub fn days_to_ymd(days: i32) -> (i32, u8, u8) {
43    let epoch = epoch_date();
44    let d = epoch.checked_add((days as i64).days()).unwrap_or(epoch);
45    (d.year() as i32, d.month() as u8, d.day() as u8)
46}
47
48/// Convert (year, month, day) Gregorian to i32 days-since-1970.
49pub fn ymd_to_days(y: i32, m: u8, d: u8) -> Option<i32> {
50    let date = JDate::new(y as i16, m as i8, d as i8).ok()?;
51    let span = date.since((Unit::Day, epoch_date())).ok()?;
52    let days = span.get_days() as i64;
53    if (i32::MIN as i64..=i32::MAX as i64).contains(&days) {
54        Some(days as i32)
55    } else {
56        None
57    }
58}
59
60/// Convert µs-since-midnight to (hour, minute, second, subsec_micros).
61pub fn micros_to_hmsn(micros: i64) -> (u8, u8, u8, u32) {
62    let hour = (micros / MICROS_PER_HOUR) as u8;
63    let rem = micros % MICROS_PER_HOUR;
64    let min = (rem / MICROS_PER_MIN) as u8;
65    let rem = rem % MICROS_PER_MIN;
66    let sec = (rem / MICROS_PER_SEC) as u8;
67    let subsec = (rem % MICROS_PER_SEC) as u32;
68    (hour, min, sec, subsec)
69}
70
71/// Convert (hour, minute, second, subsec_micros) to µs since midnight.
72pub fn hmsn_to_micros(h: u8, m: u8, s: u8, us: u32) -> Option<i64> {
73    let total = (h as i64) * MICROS_PER_HOUR
74        + (m as i64) * MICROS_PER_MIN
75        + (s as i64) * MICROS_PER_SEC
76        + us as i64;
77    if (0..=MICROS_PER_DAY).contains(&total) {
78        Some(total)
79    } else {
80        None
81    }
82}
83
84/// Split µs since 1970-UTC into `(date_days, time_micros)`.
85pub fn ts_split(micros: i64) -> (i32, i64) {
86    let days = micros.div_euclid(MICROS_PER_DAY);
87    let rem = micros.rem_euclid(MICROS_PER_DAY);
88    (days as i32, rem)
89}
90
91/// Combine i32 date-days and i64 µs-of-day into µs-since-1970-UTC.
92pub fn ts_combine(date_days: i32, time_micros: i64) -> i64 {
93    (date_days as i64) * MICROS_PER_DAY + time_micros
94}
95
96/// Convert a date to a timestamp at midnight UTC.
97pub fn date_to_ts(days: i32) -> i64 {
98    (days as i64).saturating_mul(MICROS_PER_DAY)
99}
100
101/// Floor-divide timestamp µs to date days (correct for pre-1970 negative values).
102pub fn ts_to_date_floor(micros: i64) -> i32 {
103    if micros == TS_INFINITY_MICROS {
104        DATE_INFINITY_DAYS
105    } else if micros == TS_NEG_INFINITY_MICROS {
106        DATE_NEG_INFINITY_DAYS
107    } else {
108        micros.div_euclid(MICROS_PER_DAY) as i32
109    }
110}
111
112/// Parse an ISO 8601 DATE literal (`YYYY-MM-DD`, optional `BC` suffix, `'infinity'` / `'-infinity'`).
113pub fn parse_date(s: &str) -> Result<i32> {
114    let trimmed = s.trim();
115    let lower = trimmed.to_ascii_lowercase();
116    if lower == "infinity" || lower == "+infinity" {
117        return Ok(DATE_INFINITY_DAYS);
118    }
119    if lower == "-infinity" {
120        return Ok(DATE_NEG_INFINITY_DAYS);
121    }
122
123    // PG BC convention: "0001 BC" == astronomical year 0; "N BC" == year -(N-1).
124    let (body, is_bc) = if let Some(stripped) = trimmed.strip_suffix(" BC") {
125        (stripped.trim(), true)
126    } else if let Some(stripped) = trimmed.strip_suffix(" bc") {
127        (stripped.trim(), true)
128    } else {
129        (trimmed, false)
130    };
131
132    let d = JDate::strptime("%Y-%m-%d", body)
133        .map_err(|e| SqlError::InvalidDateLiteral(format!("{body}: {e}")))?;
134    if d.year() == 0 {
135        return Err(SqlError::InvalidDateLiteral(
136            "year 0 is not supported; use '0001-01-01 BC' for 1 BC".into(),
137        ));
138    }
139    let year_adjusted = if is_bc {
140        -(d.year() as i32 - 1)
141    } else {
142        d.year() as i32
143    };
144    let canonical = JDate::new(year_adjusted as i16, d.month(), d.day())
145        .map_err(|e| SqlError::InvalidDateLiteral(format!("{body}: {e}")))?;
146    let span = canonical
147        .since((Unit::Day, epoch_date()))
148        .map_err(|e| SqlError::InvalidDateLiteral(format!("{body}: {e}")))?;
149    let days = span.get_days() as i64;
150    if (i32::MIN as i64..=i32::MAX as i64).contains(&days) {
151        Ok(days as i32)
152    } else {
153        Err(SqlError::InvalidDateLiteral(format!(
154            "{body}: date out of i32 range"
155        )))
156    }
157}
158
159/// Parse an ISO 8601 TIME literal (`HH:MM:SS[.ffffff]`).
160pub fn parse_time(s: &str) -> Result<i64> {
161    let trimmed = s.trim();
162    // Accept 24:00:00 as end-of-day sentinel (PG behavior).
163    if trimmed == "24:00:00" || trimmed == "24:00:00.000000" {
164        return Ok(MICROS_PER_DAY);
165    }
166    let t = JTime::strptime("%H:%M:%S%.f", trimmed)
167        .or_else(|_| JTime::strptime("%H:%M:%S", trimmed))
168        .or_else(|_| JTime::strptime("%H:%M", trimmed))
169        .map_err(|e| SqlError::InvalidTimeLiteral(format!("{trimmed}: {e}")))?;
170    let subsec_micros = (t.subsec_nanosecond() / 1000) as u32;
171    hmsn_to_micros(
172        t.hour() as u8,
173        t.minute() as u8,
174        t.second() as u8,
175        subsec_micros,
176    )
177    .ok_or_else(|| SqlError::InvalidTimeLiteral(format!("{trimmed}: out of range")))
178}
179
180/// Parse an ISO 8601 TIMESTAMP literal (naive `YYYY-MM-DD[T ]HH:MM:SS[.ffffff]` or with offset/zone).
181/// Accepts `Z`, fixed offsets (`+HH:MM`), and IANA zone names (`America/New_York`).
182/// `'infinity'` / `'-infinity'` map to sentinel values.
183pub fn parse_timestamp(s: &str) -> Result<i64> {
184    let trimmed = s.trim();
185    let lower = trimmed.to_ascii_lowercase();
186    if lower == "infinity" || lower == "+infinity" {
187        return Ok(TS_INFINITY_MICROS);
188    }
189    if lower == "-infinity" {
190        return Ok(TS_NEG_INFINITY_MICROS);
191    }
192
193    // Strip trailing " BC" (case-insensitive) same as parse_date.
194    let (body, is_bc) = if let Some(stripped) = trimmed.strip_suffix(" BC") {
195        (stripped.trim_end(), true)
196    } else if let Some(stripped) = trimmed.strip_suffix(" bc") {
197        (stripped.trim_end(), true)
198    } else {
199        (trimmed, false)
200    };
201
202    // Try fully-qualified (Zoned with IANA zone or offset). BC+zone combos are rare; skip if BC.
203    if !is_bc {
204        if let Ok(z) = body.parse::<Zoned>() {
205            reject_year_zero_ts(z.timestamp().as_microsecond())?;
206            return Ok(z.timestamp().as_microsecond());
207        }
208        // Try as bare RFC 3339 / ISO 8601 with offset / Z.
209        if let Ok(ts) = body.parse::<JTimestamp>() {
210            reject_year_zero_ts(ts.as_microsecond())?;
211            return Ok(ts.as_microsecond());
212        }
213    }
214
215    // Try as naive wall-clock: interpret as UTC.
216    // Accept both "2024-01-15 12:30:00" and "2024-01-15T12:30:00" and variations.
217    let parsers = [
218        "%Y-%m-%d %H:%M:%S%.f",
219        "%Y-%m-%dT%H:%M:%S%.f",
220        "%Y-%m-%d %H:%M:%S",
221        "%Y-%m-%dT%H:%M:%S",
222        "%Y-%m-%d %H:%M",
223        "%Y-%m-%dT%H:%M",
224        "%Y-%m-%d",
225    ];
226    for fmt in &parsers {
227        if let Ok(dt) = JDateTime::strptime(fmt, body) {
228            let adjusted = apply_bc_and_check_year_zero(dt, is_bc, body)?;
229            return adjusted
230                .to_zoned(TimeZone::UTC)
231                .map(|z| z.timestamp().as_microsecond())
232                .map_err(|e| SqlError::InvalidTimestampLiteral(format!("{body}: {e}")));
233        }
234    }
235    // Also try "IANA-zone-suffix" parsing: e.g. "2024-01-15 12:00:00 America/New_York".
236    if !is_bc {
237        if let Some(space_idx) = body.rfind(' ') {
238            let (ts_part, zone_part) = body.split_at(space_idx);
239            let zone_name = zone_part.trim();
240            if let Ok(tz) = TimeZone::get(zone_name) {
241                for fmt in &parsers {
242                    if let Ok(dt) = JDateTime::strptime(fmt, ts_part.trim()) {
243                        if dt.year() == 0 {
244                            return Err(SqlError::InvalidTimestampLiteral(
245                                "year 0 is not supported; use 'YYYY-MM-DD HH:MM:SS BC' for 1 BC"
246                                    .into(),
247                            ));
248                        }
249                        return dt
250                            .to_zoned(tz.clone())
251                            .map(|z| z.timestamp().as_microsecond())
252                            .map_err(|e| {
253                                SqlError::InvalidTimestampLiteral(format!("{body}: {e}"))
254                            });
255                    }
256                }
257            }
258        }
259    }
260    Err(SqlError::InvalidTimestampLiteral(format!(
261        "{trimmed}: unrecognized timestamp format"
262    )))
263}
264
265fn reject_year_zero_ts(micros: i64) -> Result<()> {
266    let date_days = ts_to_date_floor(micros);
267    let (y, _, _) = days_to_ymd(date_days);
268    if y == 0 {
269        return Err(SqlError::InvalidTimestampLiteral(
270            "year 0 is not supported; use 'YYYY-MM-DD HH:MM:SS BC' for 1 BC".into(),
271        ));
272    }
273    Ok(())
274}
275
276fn apply_bc_and_check_year_zero(dt: JDateTime, is_bc: bool, body: &str) -> Result<JDateTime> {
277    if dt.year() == 0 {
278        return Err(SqlError::InvalidTimestampLiteral(
279            "year 0 is not supported; use 'YYYY-MM-DD HH:MM:SS BC' for 1 BC".into(),
280        ));
281    }
282    if !is_bc {
283        return Ok(dt);
284    }
285    // N BC → astronomical year -(N-1).
286    let astro_year = -(dt.year() as i32 - 1);
287    let date = JDate::new(astro_year as i16, dt.month(), dt.day())
288        .map_err(|e| SqlError::InvalidTimestampLiteral(format!("{body}: {e}")))?;
289    let time = JTime::new(dt.hour(), dt.minute(), dt.second(), dt.subsec_nanosecond())
290        .map_err(|e| SqlError::InvalidTimestampLiteral(format!("{body}: {e}")))?;
291    Ok(JDateTime::from_parts(date, time))
292}
293
294/// Parse a SQL INTERVAL literal. Accepts PG verbose form (`'1 year 2 months 3 days 04:05:06.789'`),
295/// SQL standard qualified form (`'5' DAY`), and ISO 8601 duration (`'P1Y2M3DT4H5M6S'`).
296pub fn parse_interval(s: &str) -> Result<(i32, i32, i64)> {
297    let trimmed = s.trim();
298    if trimmed.is_empty() {
299        return Err(SqlError::InvalidIntervalLiteral("empty interval".into()));
300    }
301
302    // Try ISO 8601 duration first.
303    if let Some(rest) = trimmed
304        .strip_prefix('P')
305        .or_else(|| trimmed.strip_prefix('-').and_then(|r| r.strip_prefix('P')))
306    {
307        let negate = trimmed.starts_with('-');
308        return parse_iso8601_duration(rest, negate);
309    }
310
311    // PG verbose: "1 year 2 months 3 days 04:05:06.789" (optional @ prefix, `ago` suffix).
312    parse_pg_interval(trimmed)
313}
314
315fn parse_iso8601_duration(s: &str, global_negate: bool) -> Result<(i32, i32, i64)> {
316    // P[nY][nM][nW][nD][T[nH][nM][nS]]
317    let mut months: i64 = 0;
318    let mut days: i64 = 0;
319    let mut micros: i64 = 0;
320    let mut in_time = false;
321    let mut num_buf = String::new();
322    let sign = if global_negate { -1i64 } else { 1 };
323
324    for ch in s.chars() {
325        if ch == 'T' {
326            in_time = true;
327            continue;
328        }
329        if ch.is_ascii_digit() || ch == '.' || ch == '-' {
330            num_buf.push(ch);
331            continue;
332        }
333        if num_buf.is_empty() {
334            return Err(SqlError::InvalidIntervalLiteral(format!(
335                "expected number before '{ch}'"
336            )));
337        }
338        let v: f64 = num_buf
339            .parse()
340            .map_err(|_| SqlError::InvalidIntervalLiteral(format!("invalid number: {num_buf}")))?;
341        num_buf.clear();
342        let v_units = sign * v as i64;
343        let v_frac_micros = ((v.fract() * 1_000_000.0) as i64) * sign;
344        match ch {
345            'Y' if !in_time => months = months.saturating_add(v_units * 12),
346            'M' if !in_time => months = months.saturating_add(v_units),
347            'W' if !in_time => days = days.saturating_add(v_units * 7),
348            'D' if !in_time => days = days.saturating_add(v_units),
349            'H' if in_time => micros = micros.saturating_add(v_units * MICROS_PER_HOUR),
350            'M' if in_time => micros = micros.saturating_add(v_units * MICROS_PER_MIN),
351            'S' if in_time => {
352                micros = micros.saturating_add(v_units * MICROS_PER_SEC + v_frac_micros)
353            }
354            _ => {
355                return Err(SqlError::InvalidIntervalLiteral(format!(
356                    "unknown unit '{ch}' (in_time={in_time})"
357                )))
358            }
359        }
360    }
361    if !num_buf.is_empty() {
362        return Err(SqlError::InvalidIntervalLiteral(format!(
363            "trailing number without unit: {num_buf}"
364        )));
365    }
366    Ok((clamp_i32(months)?, clamp_i32(days)?, micros))
367}
368
369fn parse_pg_interval(s: &str) -> Result<(i32, i32, i64)> {
370    let mut s = s.trim().to_ascii_lowercase();
371    if let Some(rest) = s.strip_prefix('@') {
372        s = rest.trim().to_string();
373    }
374    let ago = s.ends_with(" ago");
375    if ago {
376        s.truncate(s.len() - 4);
377        s = s.trim().to_string();
378    }
379    let sign: i64 = if ago { -1 } else { 1 };
380
381    let mut months: i64 = 0;
382    let mut days: i64 = 0;
383    let mut micros: i64 = 0;
384
385    let tokens: Vec<&str> = s.split_whitespace().collect();
386    let mut i = 0;
387    while i < tokens.len() {
388        let tok = tokens[i];
389        // "HH:MM:SS[.fff]" form.
390        if tok.contains(':') {
391            let (h, m, sc, us) = parse_hms_token(tok)?;
392            let tok_sign = if tok.starts_with('-') { -1 } else { 1 };
393            let tok_micros =
394                (h * MICROS_PER_HOUR + m * MICROS_PER_MIN + sc * MICROS_PER_SEC + us as i64)
395                    * tok_sign
396                    * sign;
397            micros = micros.saturating_add(tok_micros);
398            i += 1;
399            continue;
400        }
401
402        // "N unit" form.
403        let num: f64 = tok.parse().map_err(|_| {
404            SqlError::InvalidIntervalLiteral(format!("expected number, got '{tok}'"))
405        })?;
406        if i + 1 >= tokens.len() {
407            return Err(SqlError::InvalidIntervalLiteral(format!(
408                "missing unit after '{tok}'"
409            )));
410        }
411        let unit = tokens[i + 1].trim_end_matches(',');
412        let v_units = sign * num as i64;
413        let v_frac_micros = ((num.fract() * 1_000_000.0) as i64) * sign;
414        match unit {
415            "year" | "years" | "yr" | "yrs" | "y" => months = months.saturating_add(v_units * 12),
416            "month" | "months" | "mon" | "mons" => months = months.saturating_add(v_units),
417            "week" | "weeks" | "w" => days = days.saturating_add(v_units * 7),
418            "day" | "days" | "d" => days = days.saturating_add(v_units),
419            "hour" | "hours" | "hr" | "hrs" | "h" => {
420                micros = micros.saturating_add(v_units * MICROS_PER_HOUR)
421            }
422            "minute" | "minutes" | "min" | "mins" | "m" => {
423                micros = micros.saturating_add(v_units * MICROS_PER_MIN)
424            }
425            "second" | "seconds" | "sec" | "secs" | "s" => {
426                micros = micros.saturating_add(v_units * MICROS_PER_SEC + v_frac_micros)
427            }
428            "millisecond" | "milliseconds" | "ms" => micros = micros.saturating_add(v_units * 1000),
429            "microsecond" | "microseconds" | "us" => micros = micros.saturating_add(v_units),
430            other => {
431                return Err(SqlError::InvalidIntervalLiteral(format!(
432                    "unknown unit: {other}"
433                )))
434            }
435        }
436        i += 2;
437    }
438    Ok((clamp_i32(months)?, clamp_i32(days)?, micros))
439}
440
441fn parse_hms_token(tok: &str) -> Result<(i64, i64, i64, u32)> {
442    let tok = tok.trim_start_matches('-').trim_start_matches('+');
443    let mut parts = tok.split(':');
444    let h: i64 = parts
445        .next()
446        .ok_or_else(|| SqlError::InvalidIntervalLiteral(format!("bad hms: {tok}")))?
447        .parse()
448        .map_err(|_| SqlError::InvalidIntervalLiteral(format!("bad hour: {tok}")))?;
449    let m: i64 = parts
450        .next()
451        .ok_or_else(|| SqlError::InvalidIntervalLiteral(format!("bad hms: {tok}")))?
452        .parse()
453        .map_err(|_| SqlError::InvalidIntervalLiteral(format!("bad minute: {tok}")))?;
454    let (sc, us) = if let Some(sec_part) = parts.next() {
455        if let Some((s_whole, s_frac)) = sec_part.split_once('.') {
456            let s: i64 = s_whole
457                .parse()
458                .map_err(|_| SqlError::InvalidIntervalLiteral(format!("bad second: {tok}")))?;
459            // Pad / truncate fractional to 6 digits.
460            let mut frac = s_frac.to_string();
461            while frac.len() < 6 {
462                frac.push('0');
463            }
464            frac.truncate(6);
465            let us: u32 = frac
466                .parse()
467                .map_err(|_| SqlError::InvalidIntervalLiteral(format!("bad subsec: {tok}")))?;
468            (s, us)
469        } else {
470            let s: i64 = sec_part
471                .parse()
472                .map_err(|_| SqlError::InvalidIntervalLiteral(format!("bad second: {tok}")))?;
473            (s, 0u32)
474        }
475    } else {
476        (0, 0u32)
477    };
478    Ok((h, m, sc, us))
479}
480
481fn clamp_i32(n: i64) -> Result<i32> {
482    if (i32::MIN as i64..=i32::MAX as i64).contains(&n) {
483        Ok(n as i32)
484    } else {
485        Err(SqlError::InvalidIntervalLiteral(format!(
486            "interval component overflow: {n}"
487        )))
488    }
489}
490
491pub fn format_date(days: i32) -> String {
492    if days == DATE_INFINITY_DAYS {
493        return "infinity".to_string();
494    }
495    if days == DATE_NEG_INFINITY_DAYS {
496        return "-infinity".to_string();
497    }
498    let (y, m, d) = days_to_ymd(days);
499    if y >= 1 {
500        format!("{y:04}-{m:02}-{d:02}")
501    } else {
502        // Astronomical year N ≤ 0 → (1 - N) BC; i.e., year 0 = 1 BC, year -1 = 2 BC.
503        format!("{:04}-{m:02}-{d:02} BC", 1 - y)
504    }
505}
506
507pub fn format_time(micros: i64) -> String {
508    if micros == MICROS_PER_DAY {
509        return "24:00:00".to_string();
510    }
511    let (h, m, s, us) = micros_to_hmsn(micros);
512    if us == 0 {
513        format!("{h:02}:{m:02}:{s:02}")
514    } else {
515        format!("{h:02}:{m:02}:{s:02}.{us:06}")
516    }
517}
518
519pub fn format_timestamp(micros: i64) -> String {
520    if micros == TS_INFINITY_MICROS {
521        return "infinity".to_string();
522    }
523    if micros == TS_NEG_INFINITY_MICROS {
524        return "-infinity".to_string();
525    }
526    let (date_days, time_micros) = ts_split(micros);
527    let date_part = format_date(date_days);
528    let time_part = format_time(time_micros);
529    format!("{date_part} {time_part}")
530}
531
532pub fn format_timestamp_in_zone(micros: i64, zone: &str) -> Result<String> {
533    if micros == TS_INFINITY_MICROS {
534        return Ok("infinity".to_string());
535    }
536    if micros == TS_NEG_INFINITY_MICROS {
537        return Ok("-infinity".to_string());
538    }
539    let tz = resolve_timezone(zone)?;
540    let ts = JTimestamp::from_microsecond(micros)
541        .map_err(|e| SqlError::InvalidTimestampLiteral(format!("{micros}: {e}")))?;
542    let z = ts.to_zoned(tz);
543    let subsec = z.subsec_nanosecond() / 1000;
544    let fmt = if subsec == 0 {
545        "%Y-%m-%d %H:%M:%S%:z"
546    } else {
547        "%Y-%m-%d %H:%M:%S%.6f%:z"
548    };
549    z.strftime(fmt).to_string().pipe(Ok)
550}
551
552/// Accepts IANA names, `Z`, `UTC`, and ISO-8601 fixed offsets; rejects POSIX
553/// `UTC+5` shorthand (sign-inverted in POSIX, ambiguous in practice).
554pub fn resolve_timezone(zone: &str) -> Result<TimeZone> {
555    let trimmed = zone.trim();
556    if let Ok(tz) = TimeZone::get(trimmed) {
557        return Ok(tz);
558    }
559    if let Some(offset) = parse_iso_fixed_offset(trimmed) {
560        return jiff::tz::Offset::from_seconds(offset)
561            .map(TimeZone::fixed)
562            .map_err(|e| SqlError::InvalidTimezone(format!("{zone}: {e}")));
563    }
564    let lower = trimmed.to_ascii_lowercase();
565    if lower.starts_with("utc+")
566        || lower.starts_with("utc-")
567        || lower.starts_with("gmt+")
568        || lower.starts_with("gmt-")
569    {
570        return Err(SqlError::InvalidTimezone(format!(
571            "{zone}: ambiguous POSIX form; use ISO-8601 offset like '+05:00' or a named zone"
572        )));
573    }
574    Err(SqlError::InvalidTimezone(format!(
575        "{zone}: not a recognized IANA name or ISO-8601 offset"
576    )))
577}
578
579/// Parse `Z`, `UTC`, `+HH:MM`, `-HH:MM`, `+HHMM`, `+HH` into signed seconds.
580fn parse_iso_fixed_offset(s: &str) -> Option<i32> {
581    if s.eq_ignore_ascii_case("z") || s.eq_ignore_ascii_case("utc") {
582        return Some(0);
583    }
584    let bytes = s.as_bytes();
585    if bytes.is_empty() {
586        return None;
587    }
588    let sign: i32 = match bytes[0] {
589        b'+' => 1,
590        b'-' => -1,
591        _ => return None,
592    };
593    let rest = &s[1..];
594    let (hh, mm) = if let Some((h, m)) = rest.split_once(':') {
595        (h, m)
596    } else if rest.len() == 4 {
597        (&rest[..2], &rest[2..])
598    } else if rest.len() == 2 {
599        (rest, "00")
600    } else {
601        return None;
602    };
603    let h: i32 = hh.parse().ok()?;
604    let m: i32 = mm.parse().ok()?;
605    if !(0..=23).contains(&h) || !(0..=59).contains(&m) {
606        return None;
607    }
608    Some(sign * (h * 3600 + m * 60))
609}
610
611pub fn format_interval(months: i32, days: i32, micros: i64) -> String {
612    if months == 0 && days == 0 && micros == 0 {
613        return "00:00:00".to_string();
614    }
615    let mut parts = Vec::with_capacity(4);
616    if months != 0 {
617        let years = months / 12;
618        let mon = months % 12;
619        if years != 0 {
620            parts.push(format!(
621                "{} year{}",
622                years,
623                if years.abs() == 1 { "" } else { "s" }
624            ));
625        }
626        if mon != 0 {
627            parts.push(format!(
628                "{} mon{}",
629                mon,
630                if mon.abs() == 1 { "" } else { "s" }
631            ));
632        }
633    }
634    if days != 0 {
635        parts.push(format!(
636            "{} day{}",
637            days,
638            if days.abs() == 1 { "" } else { "s" }
639        ));
640    }
641    if micros != 0 {
642        let sign = if micros < 0 { "-" } else { "" };
643        let abs_us = micros.unsigned_abs() as i64;
644        let (h, m, s, us) = micros_to_hmsn(abs_us);
645        if us == 0 {
646            parts.push(format!("{sign}{h:02}:{m:02}:{s:02}"));
647        } else {
648            parts.push(format!("{sign}{h:02}:{m:02}:{s:02}.{us:06}"));
649        }
650    }
651    parts.join(" ")
652}
653
654/// Extension to allow `x.pipe(Ok)` chaining for readability.
655trait Pipe: Sized {
656    fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
657        f(self)
658    }
659}
660impl<T> Pipe for T {}
661
662pub fn now_micros() -> i64 {
663    JTimestamp::now().as_microsecond()
664}
665
666thread_local! {
667    /// Scoped txn-start timestamp for PG-exact CURRENT_TIMESTAMP (stable per txn).
668    static TXN_CLOCK: std::cell::Cell<Option<i64>> = const { std::cell::Cell::new(None) };
669}
670
671/// Install a txn-start timestamp for the duration of `f`. Restores the previous
672/// value on return (nested-safe).
673pub fn with_txn_clock<R>(ts: Option<i64>, f: impl FnOnce() -> R) -> R {
674    TXN_CLOCK.with(|slot| {
675        let prev = slot.replace(ts);
676        let r = f();
677        slot.set(prev);
678        r
679    })
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)]
1190mod tests {
1191    use super::*;
1192
1193    #[test]
1194    fn ymd_roundtrip_epoch() {
1195        assert_eq!(days_to_ymd(0), (1970, 1, 1));
1196        assert_eq!(ymd_to_days(1970, 1, 1), Some(0));
1197    }
1198
1199    #[test]
1200    fn ymd_roundtrip_leap_day() {
1201        let days = ymd_to_days(2024, 2, 29).unwrap();
1202        assert_eq!(days_to_ymd(days), (2024, 2, 29));
1203    }
1204
1205    #[test]
1206    fn ymd_pre_epoch() {
1207        let days = ymd_to_days(1960, 1, 1).unwrap();
1208        assert!(days < 0);
1209        assert_eq!(days_to_ymd(days), (1960, 1, 1));
1210    }
1211
1212    #[test]
1213    fn hmsn_roundtrip() {
1214        let us = hmsn_to_micros(12, 30, 45, 123456).unwrap();
1215        assert_eq!(micros_to_hmsn(us), (12, 30, 45, 123456));
1216    }
1217
1218    #[test]
1219    fn time_upper_bound_inclusive() {
1220        assert_eq!(hmsn_to_micros(24, 0, 0, 0), Some(MICROS_PER_DAY));
1221        assert_eq!(hmsn_to_micros(24, 0, 0, 1), None);
1222    }
1223
1224    #[test]
1225    fn ts_split_pre_1970() {
1226        // -1 µs is 1969-12-31 23:59:59.999999.
1227        let (d, t) = ts_split(-1);
1228        assert_eq!(d, -1); // day = 1969-12-31
1229        assert_eq!(t, MICROS_PER_DAY - 1);
1230    }
1231
1232    #[test]
1233    fn parse_format_date_roundtrip() {
1234        let d = parse_date("2024-01-15").unwrap();
1235        assert_eq!(format_date(d), "2024-01-15");
1236    }
1237
1238    #[test]
1239    fn parse_date_bc() {
1240        // "0001-01-01 BC" = astronomical year 0 = 1 day before "0001-01-01"
1241        let ad = parse_date("0001-01-01").unwrap();
1242        let bc = parse_date("0001-01-01 BC").unwrap();
1243        assert!(bc < ad);
1244    }
1245
1246    #[test]
1247    fn parse_date_rejects_year_0() {
1248        assert!(parse_date("0000-01-01").is_err());
1249    }
1250
1251    #[test]
1252    fn parse_date_infinity() {
1253        assert_eq!(parse_date("infinity").unwrap(), DATE_INFINITY_DAYS);
1254        assert_eq!(parse_date("-infinity").unwrap(), DATE_NEG_INFINITY_DAYS);
1255    }
1256
1257    #[test]
1258    fn parse_time_with_fractional() {
1259        let t = parse_time("12:30:45.123456").unwrap();
1260        assert_eq!(format_time(t), "12:30:45.123456");
1261    }
1262
1263    #[test]
1264    fn parse_time_24_00() {
1265        assert_eq!(parse_time("24:00:00").unwrap(), MICROS_PER_DAY);
1266    }
1267
1268    #[test]
1269    fn parse_timestamp_iso() {
1270        let t = parse_timestamp("2024-01-15T12:30:45Z").unwrap();
1271        assert_eq!(format_timestamp(t), "2024-01-15 12:30:45");
1272    }
1273
1274    #[test]
1275    fn parse_timestamp_naive() {
1276        let t1 = parse_timestamp("2024-01-15 12:30:45").unwrap();
1277        let t2 = parse_timestamp("2024-01-15T12:30:45").unwrap();
1278        assert_eq!(t1, t2);
1279    }
1280
1281    #[test]
1282    fn parse_timestamp_infinity() {
1283        assert_eq!(parse_timestamp("infinity").unwrap(), TS_INFINITY_MICROS);
1284    }
1285
1286    #[test]
1287    fn parse_timestamp_bc() {
1288        // AD 0001-01-01 at midnight minus 1 day should equal BC 0001-12-31 at midnight.
1289        let ad = parse_timestamp("0001-01-01 00:00:00").unwrap();
1290        let bc = parse_timestamp("0001-12-31 00:00:00 BC").unwrap();
1291        assert_eq!(ad - bc, MICROS_PER_DAY);
1292    }
1293
1294    #[test]
1295    fn parse_timestamp_rejects_year_0() {
1296        assert!(parse_timestamp("0000-06-15 12:00:00").is_err());
1297    }
1298
1299    #[test]
1300    fn parse_interval_pg_verbose() {
1301        let (m, d, us) = parse_interval("1 year 2 months 3 days").unwrap();
1302        assert_eq!((m, d, us), (14, 3, 0));
1303    }
1304
1305    #[test]
1306    fn parse_interval_with_hms() {
1307        let (m, d, us) = parse_interval("3 days 04:05:06.789").unwrap();
1308        assert_eq!(m, 0);
1309        assert_eq!(d, 3);
1310        let expected_us = 4 * MICROS_PER_HOUR + 5 * MICROS_PER_MIN + 6 * MICROS_PER_SEC + 789000;
1311        assert_eq!(us, expected_us);
1312    }
1313
1314    #[test]
1315    fn parse_interval_iso8601() {
1316        let (m, d, us) = parse_interval("P1Y2M3DT4H5M6S").unwrap();
1317        assert_eq!(m, 14);
1318        assert_eq!(d, 3);
1319        assert_eq!(
1320            us,
1321            4 * MICROS_PER_HOUR + 5 * MICROS_PER_MIN + 6 * MICROS_PER_SEC
1322        );
1323    }
1324
1325    #[test]
1326    fn format_interval_zero() {
1327        assert_eq!(format_interval(0, 0, 0), "00:00:00");
1328    }
1329
1330    #[test]
1331    fn format_interval_mixed() {
1332        assert_eq!(
1333            format_interval(
1334                14,
1335                3,
1336                4 * MICROS_PER_HOUR + 5 * MICROS_PER_MIN + 6 * MICROS_PER_SEC
1337            ),
1338            "1 year 2 mons 3 days 04:05:06"
1339        );
1340    }
1341
1342    #[test]
1343    fn add_interval_month_clamp() {
1344        // Jan 31 + 1 month = Feb 29 (leap year 2024).
1345        let jan31 = parse_date("2024-01-31").unwrap();
1346        let ts = add_interval_to_date(jan31, 1, 0, 0).unwrap();
1347        let (d, _t) = ts_split(ts);
1348        let (y, mo, da) = days_to_ymd(d);
1349        assert_eq!((y, mo, da), (2024, 2, 29));
1350    }
1351
1352    #[test]
1353    fn add_interval_month_clamp_non_leap() {
1354        let jan31 = parse_date("2023-01-31").unwrap();
1355        let ts = add_interval_to_date(jan31, 1, 0, 0).unwrap();
1356        let (d, _t) = ts_split(ts);
1357        let (y, mo, da) = days_to_ymd(d);
1358        assert_eq!((y, mo, da), (2023, 2, 28));
1359    }
1360
1361    #[test]
1362    fn interval_normalized_compare() {
1363        // 1 month == 30 days.
1364        let a = (1i32, 0i32, 0i64);
1365        let b = (0i32, 30i32, 0i64);
1366        assert_eq!(pg_normalized_interval_cmp(a, b), std::cmp::Ordering::Equal);
1367    }
1368
1369    #[test]
1370    fn justify_days_basic() {
1371        let (m, d, us) = justify_days(0, 65, 0);
1372        assert_eq!((m, d, us), (2, 5, 0));
1373    }
1374
1375    #[test]
1376    fn justify_hours_basic() {
1377        let (m, d, us) = justify_hours(0, 0, 50 * MICROS_PER_HOUR + 10 * MICROS_PER_MIN);
1378        assert_eq!(
1379            (m, d, us),
1380            (0, 2, 2 * MICROS_PER_HOUR + 10 * MICROS_PER_MIN)
1381        );
1382    }
1383
1384    #[test]
1385    fn time_add_wrap() {
1386        let t = parse_time("23:00:00").unwrap();
1387        let result = add_interval_to_time(t, 0, 0, 2 * MICROS_PER_HOUR).unwrap();
1388        assert_eq!(format_time(result), "01:00:00");
1389    }
1390
1391    #[test]
1392    fn time_add_rejects_days() {
1393        let t = parse_time("12:00:00").unwrap();
1394        assert!(add_interval_to_time(t, 0, 1, 0).is_err());
1395    }
1396
1397    #[test]
1398    fn subtract_timestamps_basic() {
1399        let a = parse_timestamp("2024-01-02 12:00:00").unwrap();
1400        let b = parse_timestamp("2024-01-01 00:00:00").unwrap();
1401        let (days, micros) = subtract_timestamps(a, b);
1402        assert_eq!(days, 1);
1403        assert_eq!(micros, 12 * MICROS_PER_HOUR);
1404    }
1405
1406    #[test]
1407    fn ts_to_date_floor_pre_epoch() {
1408        assert_eq!(ts_to_date_floor(-1), -1);
1409        assert_eq!(ts_to_date_floor(0), 0);
1410        assert_eq!(ts_to_date_floor(MICROS_PER_DAY - 1), 0);
1411        assert_eq!(ts_to_date_floor(MICROS_PER_DAY), 1);
1412    }
1413
1414    #[test]
1415    fn extract_year_from_date() {
1416        let d = parse_date("2024-03-15").unwrap();
1417        assert_eq!(
1418            extract("year", &Value::Date(d)).unwrap(),
1419            Value::Integer(2024)
1420        );
1421    }
1422
1423    #[test]
1424    fn extract_dow_sunday() {
1425        // 2024-01-07 is a Sunday.
1426        let d = parse_date("2024-01-07").unwrap();
1427        assert_eq!(extract("dow", &Value::Date(d)).unwrap(), Value::Integer(0));
1428        assert_eq!(
1429            extract("isodow", &Value::Date(d)).unwrap(),
1430            Value::Integer(7)
1431        );
1432    }
1433
1434    #[test]
1435    fn date_trunc_month() {
1436        let ts = parse_timestamp("2024-03-15 12:30:45").unwrap();
1437        let result = date_trunc("month", &Value::Timestamp(ts)).unwrap();
1438        if let Value::Timestamp(t) = result {
1439            assert_eq!(format_timestamp(t), "2024-03-01 00:00:00");
1440        } else {
1441            panic!("expected Timestamp");
1442        }
1443    }
1444
1445    #[test]
1446    fn date_trunc_week_monday() {
1447        // 2024-01-07 is Sunday; trunc week → 2024-01-01 (Monday).
1448        let d = parse_date("2024-01-07").unwrap();
1449        let Value::Date(trunc) = date_trunc("week", &Value::Date(d)).unwrap() else {
1450            panic!("expected Date");
1451        };
1452        assert_eq!(format_date(trunc), "2024-01-01");
1453    }
1454
1455    #[test]
1456    fn age_basic() {
1457        let a = parse_timestamp("2024-04-10 00:00:00").unwrap();
1458        let b = parse_timestamp("2024-01-01 00:00:00").unwrap();
1459        let (m, d, us) = age(a, b).unwrap();
1460        // 2024-04-10 - 2024-01-01 = 3 months 9 days.
1461        assert_eq!(m, 3);
1462        assert_eq!(d, 9);
1463        assert_eq!(us, 0);
1464    }
1465
1466    #[test]
1467    fn strftime_basic() {
1468        let ts = parse_timestamp("2024-03-15 12:30:45").unwrap();
1469        let s = strftime("%Y-%m-%d", &Value::Timestamp(ts)).unwrap();
1470        assert_eq!(s, "2024-03-15");
1471    }
1472
1473    #[test]
1474    fn strftime_unix_epoch() {
1475        let ts = parse_timestamp("2024-01-01 00:00:00").unwrap();
1476        let s = strftime("%s", &Value::Timestamp(ts)).unwrap();
1477        assert_eq!(s, (ts / MICROS_PER_SEC).to_string());
1478    }
1479
1480    #[test]
1481    fn is_finite_temporal_sentinels() {
1482        assert!(!Value::Date(i32::MAX).is_finite_temporal());
1483        assert!(!Value::Date(i32::MIN).is_finite_temporal());
1484        assert!(Value::Date(0).is_finite_temporal());
1485        assert!(!Value::Timestamp(i64::MAX).is_finite_temporal());
1486        assert!(Value::Timestamp(0).is_finite_temporal());
1487    }
1488
1489    #[test]
1490    fn add_interval_infinity() {
1491        let result = add_interval_to_timestamp(TS_INFINITY_MICROS, 1, 1, 0).unwrap();
1492        assert_eq!(result, TS_INFINITY_MICROS);
1493    }
1494
1495    #[test]
1496    fn format_date_bc() {
1497        // Astronomical year 0 = 1 BC in PG convention.
1498        let bc1 = parse_date("0001-01-01 BC").unwrap();
1499        assert_eq!(format_date(bc1), "0001-01-01 BC");
1500    }
1501}