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
682pub fn set_txn_clock(ts: Option<i64>) {
683    TXN_CLOCK.with(|slot| slot.set(ts));
684}
685
686/// Read the cached txn-start clock if one is installed, else a fresh `now_micros()`.
687/// Used by `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE` / `LOCALTIMESTAMP`.
688pub fn txn_or_clock_micros() -> i64 {
689    TXN_CLOCK.with(|slot| slot.get()).unwrap_or_else(now_micros)
690}
691
692pub fn today_days() -> i32 {
693    ts_to_date_floor(now_micros())
694}
695
696pub fn current_time_micros() -> i64 {
697    ts_split(now_micros()).1
698}
699
700pub fn add_interval_to_timestamp(ts: i64, months: i32, days: i32, micros: i64) -> Result<i64> {
701    if ts == TS_INFINITY_MICROS || ts == TS_NEG_INFINITY_MICROS {
702        return Ok(ts);
703    }
704    let jts =
705        JTimestamp::from_microsecond(ts).map_err(|e| SqlError::InvalidValue(format!("ts: {e}")))?;
706    // PG order: months, then days, then micros.
707    let span = Span::new()
708        .try_months(months as i64)
709        .map_err(|e| SqlError::InvalidValue(format!("months overflow: {e}")))?
710        .try_days(days as i64)
711        .map_err(|e| SqlError::InvalidValue(format!("days overflow: {e}")))?
712        .try_microseconds(micros)
713        .map_err(|e| SqlError::InvalidValue(format!("micros overflow: {e}")))?;
714    let result = jts
715        .to_zoned(TimeZone::UTC)
716        .checked_add(span)
717        .map_err(|_| SqlError::IntegerOverflow)?;
718    Ok(result.timestamp().as_microsecond())
719}
720
721/// PG rule: DATE + INTERVAL always yields TIMESTAMP.
722pub fn add_interval_to_date(days: i32, months: i32, i_days: i32, micros: i64) -> Result<i64> {
723    if is_infinity_date(days) {
724        return Ok(if days == DATE_INFINITY_DAYS {
725            TS_INFINITY_MICROS
726        } else {
727            TS_NEG_INFINITY_MICROS
728        });
729    }
730    let ts = date_to_ts(days);
731    add_interval_to_timestamp(ts, months, i_days, micros)
732}
733
734pub fn add_days_to_date(days: i32, n: i64) -> Result<i32> {
735    if is_infinity_date(days) {
736        return Ok(days);
737    }
738    let new_days = (days as i64)
739        .checked_add(n)
740        .ok_or(SqlError::IntegerOverflow)?;
741    if new_days >= i32::MIN as i64 && new_days <= i32::MAX as i64 {
742        Ok(new_days as i32)
743    } else {
744        Err(SqlError::IntegerOverflow)
745    }
746}
747
748pub fn add_interval_to_time(t: i64, months: i32, days: i32, micros: i64) -> Result<i64> {
749    if months != 0 || days != 0 {
750        return Err(SqlError::InvalidValue(
751            "cannot add month/day interval to TIME".into(),
752        ));
753    }
754    // PG: TIME + interval wraps mod 24h.
755    let combined = t.checked_add(micros).unwrap_or(t);
756    Ok(combined.rem_euclid(MICROS_PER_DAY))
757}
758
759/// PG `timestamp - timestamp`: returns `(days, remainder_micros)` with months = 0.
760pub fn subtract_timestamps(a: i64, b: i64) -> (i32, i64) {
761    let diff = a.saturating_sub(b);
762    let days = (diff / MICROS_PER_DAY) as i32;
763    let micros = diff % MICROS_PER_DAY;
764    (days, micros)
765}
766
767/// AGE(a, b) — symbolic diff preserving months/years. Uses jiff's Span rounding to Year unit.
768pub fn age(ts_a: i64, ts_b: i64) -> Result<(i32, i32, i64)> {
769    let a = JTimestamp::from_microsecond(ts_a)
770        .map_err(|e| SqlError::InvalidValue(format!("ts_a: {e}")))?
771        .to_zoned(TimeZone::UTC);
772    let b = JTimestamp::from_microsecond(ts_b)
773        .map_err(|e| SqlError::InvalidValue(format!("ts_b: {e}")))?
774        .to_zoned(TimeZone::UTC);
775    let span = a
776        .since((Unit::Year, &b))
777        .map_err(|e| SqlError::InvalidValue(format!("age: {e}")))?;
778    span_to_triple(&span)
779}
780
781fn span_to_triple(span: &Span) -> Result<(i32, i32, i64)> {
782    let months = i64::from(span.get_years()) * 12 + i64::from(span.get_months());
783    let days = i64::from(span.get_weeks()) * 7 + i64::from(span.get_days());
784    let micros = i64::from(span.get_hours()) * MICROS_PER_HOUR
785        + span.get_minutes() * MICROS_PER_MIN
786        + span.get_seconds() * MICROS_PER_SEC
787        + span.get_milliseconds() * 1000
788        + span.get_microseconds()
789        + span.get_nanoseconds() / 1000;
790    Ok((clamp_i32(months)?, clamp_i32(days)?, micros))
791}
792
793pub fn justify_days(months: i32, days: i32, micros: i64) -> (i32, i32, i64) {
794    // Convert every 30 days into 1 month.
795    let extra_months = days / 30;
796    let rem_days = days % 30;
797    let new_months = months.saturating_add(extra_months);
798    (new_months, rem_days, micros)
799}
800
801pub fn justify_hours(months: i32, days: i32, micros: i64) -> (i32, i32, i64) {
802    // Convert every 24 hours into 1 day.
803    let extra_days = (micros / MICROS_PER_DAY) as i32;
804    let rem_micros = micros % MICROS_PER_DAY;
805    let new_days = days.saturating_add(extra_days);
806    (months, new_days, rem_micros)
807}
808
809pub fn justify_interval(months: i32, days: i32, micros: i64) -> (i32, i32, i64) {
810    let (m1, d1, us1) = justify_hours(months, days, micros);
811    let (m2, d2, us2) = justify_days(m1, d1, us1);
812    (m2, d2, us2)
813}
814
815/// PG-normalized total µs for comparison purposes (30-day month, 24-hour day).
816pub fn interval_to_total_micros(months: i32, days: i32, micros: i64) -> i128 {
817    (months as i128) * 30 * (MICROS_PER_DAY as i128)
818        + (days as i128) * (MICROS_PER_DAY as i128)
819        + micros as i128
820}
821
822pub fn extract(field: &str, v: &Value) -> Result<Value> {
823    let f = field.trim();
824    match v {
825        Value::Null => Ok(Value::Null),
826        Value::Date(d) => extract_from_date(f, *d),
827        Value::Time(t) => extract_from_time(f, *t),
828        Value::Timestamp(t) => extract_from_timestamp(f, *t),
829        Value::Interval {
830            months,
831            days,
832            micros,
833        } => extract_from_interval(f, *months, *days, *micros),
834        _ => Err(SqlError::TypeMismatch {
835            expected: "temporal type".into(),
836            got: v.data_type().to_string(),
837        }),
838    }
839}
840
841fn extract_from_date(field: &str, days: i32) -> Result<Value> {
842    if field.eq_ignore_ascii_case("epoch") {
843        return Ok(Value::Integer((days as i64) * 86400));
844    }
845    let (y, m, d) = days_to_ymd(days);
846    if field.eq_ignore_ascii_case("year") {
847        return Ok(Value::Integer(y as i64));
848    }
849    if field.eq_ignore_ascii_case("month") {
850        return Ok(Value::Integer(m as i64));
851    }
852    if field.eq_ignore_ascii_case("day") {
853        return Ok(Value::Integer(d as i64));
854    }
855    if field.eq_ignore_ascii_case("hour")
856        || field.eq_ignore_ascii_case("minute")
857        || field.eq_ignore_ascii_case("second")
858        || field.eq_ignore_ascii_case("microseconds")
859        || field.eq_ignore_ascii_case("milliseconds")
860    {
861        return Ok(Value::Integer(0));
862    }
863    // Fall-through: use a canonical lowercase form for the remaining rare fields.
864    let f = field.to_ascii_lowercase();
865    match f.as_str() {
866        "dow" => {
867            let jd = JDate::new(y as i16, m as i8, d as i8)
868                .map_err(|e| SqlError::InvalidValue(format!("{e}")))?;
869            // Jiff weekday: Monday=1..Sunday=7. PG dow: Sunday=0..Saturday=6.
870            let w = jd.weekday().to_monday_one_offset() as i64;
871            let dow = if w == 7 { 0 } else { w }; // Sunday: 7 → 0
872            Ok(Value::Integer(dow))
873        }
874        "isodow" => {
875            let jd = JDate::new(y as i16, m as i8, d as i8)
876                .map_err(|e| SqlError::InvalidValue(format!("{e}")))?;
877            Ok(Value::Integer(jd.weekday().to_monday_one_offset() as i64))
878        }
879        "doy" => {
880            let jd = JDate::new(y as i16, m as i8, d as i8)
881                .map_err(|e| SqlError::InvalidValue(format!("{e}")))?;
882            Ok(Value::Integer(jd.day_of_year() as i64))
883        }
884        "quarter" => Ok(Value::Integer(((m - 1) / 3 + 1) as i64)),
885        "decade" => Ok(Value::Integer((y / 10) as i64)),
886        "century" => Ok(Value::Integer(if y > 0 {
887            ((y - 1) / 100 + 1) as i64
888        } else {
889            (y / 100 - 1) as i64
890        })),
891        "millennium" => Ok(Value::Integer(if y > 0 {
892            ((y - 1) / 1000 + 1) as i64
893        } else {
894            (y / 1000 - 1) as i64
895        })),
896        "julian" => Ok(Value::Integer(days as i64 + 2_440_588)),
897        "week" | "isoyear" => {
898            let jd = JDate::new(y as i16, m as i8, d as i8)
899                .map_err(|e| SqlError::InvalidValue(format!("{e}")))?;
900            let iso = jd.iso_week_date();
901            if field == "week" {
902                Ok(Value::Integer(iso.week() as i64))
903            } else {
904                Ok(Value::Integer(iso.year() as i64))
905            }
906        }
907        _ => Err(SqlError::InvalidExtractField(format!("{field} from DATE"))),
908    }
909}
910
911fn extract_from_time(field: &str, micros: i64) -> Result<Value> {
912    let (h, m, s, us) = micros_to_hmsn(micros);
913    match field {
914        "hour" => Ok(Value::Integer(h as i64)),
915        "minute" => Ok(Value::Integer(m as i64)),
916        "second" => {
917            if us == 0 {
918                Ok(Value::Integer(s as i64))
919            } else {
920                Ok(Value::Real(s as f64 + (us as f64) / 1_000_000.0))
921            }
922        }
923        "microseconds" => Ok(Value::Integer((s as i64) * 1_000_000 + us as i64)),
924        "milliseconds" => Ok(Value::Real(s as f64 * 1000.0 + (us as f64) / 1000.0)),
925        "epoch" => Ok(Value::Integer(micros / MICROS_PER_SEC)),
926        _ => Err(SqlError::InvalidExtractField(format!("{field} from TIME"))),
927    }
928}
929
930fn extract_from_timestamp(field: &str, ts: i64) -> Result<Value> {
931    if field.eq_ignore_ascii_case("hour") {
932        return Ok(Value::Integer(
933            ts.rem_euclid(MICROS_PER_DAY) / MICROS_PER_HOUR,
934        ));
935    }
936    if field.eq_ignore_ascii_case("minute") {
937        return Ok(Value::Integer(
938            ts.rem_euclid(MICROS_PER_HOUR) / MICROS_PER_MIN,
939        ));
940    }
941    if field.eq_ignore_ascii_case("epoch") {
942        return Ok(Value::Integer(ts / MICROS_PER_SEC));
943    }
944    let (date_days, time_micros) = ts_split(ts);
945    // Date-level fields.
946    let date_fields = [
947        "year",
948        "month",
949        "day",
950        "dow",
951        "isodow",
952        "doy",
953        "quarter",
954        "decade",
955        "century",
956        "millennium",
957        "julian",
958        "week",
959        "isoyear",
960    ];
961    if date_fields.iter().any(|&f| field.eq_ignore_ascii_case(f)) {
962        return extract_from_date(field, date_days);
963    }
964    // Time-of-day fields (second, microseconds, milliseconds).
965    let time_fields = ["second", "microseconds", "milliseconds"];
966    if time_fields.iter().any(|&f| field.eq_ignore_ascii_case(f)) {
967        return extract_from_time(field, time_micros);
968    }
969    Err(SqlError::InvalidExtractField(format!(
970        "{field} from TIMESTAMP"
971    )))
972}
973
974fn extract_from_interval(field: &str, months: i32, days: i32, micros: i64) -> Result<Value> {
975    match field {
976        "year" => Ok(Value::Integer((months / 12) as i64)),
977        "month" => Ok(Value::Integer((months % 12) as i64)),
978        "day" => Ok(Value::Integer(days as i64)),
979        "hour" => Ok(Value::Integer(micros / MICROS_PER_HOUR)),
980        "minute" => Ok(Value::Integer((micros % MICROS_PER_HOUR) / MICROS_PER_MIN)),
981        "second" => {
982            let rem = micros % MICROS_PER_MIN;
983            let sec_part = rem / MICROS_PER_SEC;
984            let us_part = rem % MICROS_PER_SEC;
985            if us_part == 0 {
986                Ok(Value::Integer(sec_part))
987            } else {
988                Ok(Value::Real(sec_part as f64 + us_part as f64 / 1_000_000.0))
989            }
990        }
991        "microseconds" => Ok(Value::Integer(micros % MICROS_PER_MIN)),
992        "epoch" => {
993            let total = interval_to_total_micros(months, days, micros);
994            Ok(Value::Real(total as f64 / 1_000_000.0))
995        }
996        _ => Err(SqlError::InvalidExtractField(format!(
997            "{field} from INTERVAL"
998        ))),
999    }
1000}
1001
1002pub fn date_trunc(unit: &str, v: &Value) -> Result<Value> {
1003    let u = unit.trim().to_ascii_lowercase();
1004    match v {
1005        Value::Null => Ok(Value::Null),
1006        Value::Date(d) => date_trunc_date(&u, *d).map(Value::Date),
1007        Value::Timestamp(t) => date_trunc_timestamp(&u, *t).map(Value::Timestamp),
1008        Value::Time(t) => date_trunc_time(&u, *t).map(Value::Time),
1009        Value::Interval {
1010            months,
1011            days,
1012            micros,
1013        } => date_trunc_interval(&u, *months, *days, *micros).map(|(m, d, us)| Value::Interval {
1014            months: m,
1015            days: d,
1016            micros: us,
1017        }),
1018        _ => Err(SqlError::TypeMismatch {
1019            expected: "temporal type".into(),
1020            got: v.data_type().to_string(),
1021        }),
1022    }
1023}
1024
1025fn date_trunc_date(unit: &str, days: i32) -> Result<i32> {
1026    if is_infinity_date(days) {
1027        return Ok(days);
1028    }
1029    let (y, m, d) = days_to_ymd(days);
1030    match unit {
1031        "microseconds" | "milliseconds" | "second" | "minute" | "hour" | "day" => Ok(days),
1032        "week" => {
1033            // Monday-based ISO 8601.
1034            let jd = JDate::new(y as i16, m as i8, d as i8)
1035                .map_err(|e| SqlError::InvalidValue(format!("{e}")))?;
1036            let dow = jd.weekday().to_monday_one_offset() as i32; // 1=Mon..7=Sun
1037            add_days_to_date(days, -(dow - 1) as i64)
1038        }
1039        "month" => {
1040            ymd_to_days(y, m, 1).ok_or_else(|| SqlError::InvalidValue("date_trunc month".into()))
1041        }
1042        "quarter" => {
1043            let qm = ((m - 1) / 3) * 3 + 1;
1044            ymd_to_days(y, qm, 1).ok_or_else(|| SqlError::InvalidValue("date_trunc quarter".into()))
1045        }
1046        "year" => {
1047            ymd_to_days(y, 1, 1).ok_or_else(|| SqlError::InvalidValue("date_trunc year".into()))
1048        }
1049        "decade" => ymd_to_days(y - (y % 10), 1, 1)
1050            .ok_or_else(|| SqlError::InvalidValue("date_trunc decade".into())),
1051        "century" => {
1052            let cy = if y > 0 {
1053                ((y - 1) / 100) * 100 + 1
1054            } else {
1055                (y / 100) * 100 - 99
1056            };
1057            ymd_to_days(cy, 1, 1).ok_or_else(|| SqlError::InvalidValue("date_trunc century".into()))
1058        }
1059        "millennium" => {
1060            let my = if y > 0 {
1061                ((y - 1) / 1000) * 1000 + 1
1062            } else {
1063                (y / 1000) * 1000 - 999
1064            };
1065            ymd_to_days(my, 1, 1)
1066                .ok_or_else(|| SqlError::InvalidValue("date_trunc millennium".into()))
1067        }
1068        _ => Err(SqlError::InvalidDateTruncUnit(unit.into())),
1069    }
1070}
1071
1072fn date_trunc_timestamp(unit: &str, ts: i64) -> Result<i64> {
1073    if is_infinity_ts(ts) {
1074        return Ok(ts);
1075    }
1076    let (date_days, time_micros) = ts_split(ts);
1077    // time_micros is in 0..MICROS_PER_DAY (ts_split uses div_euclid), so `% unit_size` works.
1078    match unit {
1079        "microseconds" => Ok(ts),
1080        "milliseconds" => Ok(ts_combine(date_days, time_micros - time_micros % 1000)),
1081        "second" => Ok(ts_combine(
1082            date_days,
1083            time_micros - time_micros % MICROS_PER_SEC,
1084        )),
1085        "minute" => Ok(ts_combine(
1086            date_days,
1087            time_micros - time_micros % MICROS_PER_MIN,
1088        )),
1089        "hour" => Ok(ts_combine(
1090            date_days,
1091            time_micros - time_micros % MICROS_PER_HOUR,
1092        )),
1093        "day" => Ok(ts_combine(date_days, 0)),
1094        _ => {
1095            // Weekly+ units delegate to date-level truncation (time zeroed).
1096            let trunc_days = date_trunc_date(unit, date_days)?;
1097            Ok(ts_combine(trunc_days, 0))
1098        }
1099    }
1100}
1101
1102fn date_trunc_time(unit: &str, micros: i64) -> Result<i64> {
1103    match unit {
1104        "microseconds" => Ok(micros),
1105        "milliseconds" => Ok(micros - (micros % 1000)),
1106        "second" => Ok(micros - (micros % MICROS_PER_SEC)),
1107        "minute" => Ok(micros - (micros % MICROS_PER_MIN)),
1108        "hour" => Ok(micros - (micros % MICROS_PER_HOUR)),
1109        _ => Err(SqlError::InvalidDateTruncUnit(format!(
1110            "{unit} is invalid for TIME"
1111        ))),
1112    }
1113}
1114
1115fn date_trunc_interval(unit: &str, months: i32, days: i32, micros: i64) -> Result<(i32, i32, i64)> {
1116    match unit {
1117        "microseconds" => Ok((months, days, micros)),
1118        "milliseconds" => Ok((months, days, micros - (micros % 1000))),
1119        "second" => Ok((months, days, micros - (micros % MICROS_PER_SEC))),
1120        "minute" => Ok((months, days, micros - (micros % MICROS_PER_MIN))),
1121        "hour" => Ok((months, days, micros - (micros % MICROS_PER_HOUR))),
1122        "day" => Ok((months, days, 0)),
1123        "month" => Ok((months, 0, 0)),
1124        "year" => Ok(((months / 12) * 12, 0, 0)),
1125        "quarter" => Ok(((months / 3) * 3, 0, 0)),
1126        "decade" => Ok(((months / 120) * 120, 0, 0)),
1127        "century" => Ok(((months / 1200) * 1200, 0, 0)),
1128        "millennium" => Ok(((months / 12000) * 12000, 0, 0)),
1129        _ => Err(SqlError::InvalidDateTruncUnit(unit.into())),
1130    }
1131}
1132
1133pub fn strftime(fmt: &str, v: &Value) -> Result<String> {
1134    let ts_micros = match v {
1135        Value::Null => return Ok(String::new()),
1136        Value::Timestamp(t) => *t,
1137        Value::Date(d) => date_to_ts(*d),
1138        Value::Time(t) => *t, // time-only: use epoch date as anchor
1139        _ => {
1140            return Err(SqlError::TypeMismatch {
1141                expected: "temporal type".into(),
1142                got: v.data_type().to_string(),
1143            })
1144        }
1145    };
1146    let z = JTimestamp::from_microsecond(ts_micros)
1147        .map_err(|e| SqlError::InvalidValue(format!("ts: {e}")))?
1148        .to_zoned(TimeZone::UTC);
1149    // Rewrite %J, %f, %s — not supported by jiff — before formatting.
1150    let mut prepared = String::with_capacity(fmt.len());
1151    let mut chars = fmt.chars().peekable();
1152    while let Some(c) = chars.next() {
1153        if c == '%' {
1154            match chars.peek() {
1155                Some('J') => {
1156                    chars.next();
1157                    // Julian Day 2440587.5 = 1970-01-01 00:00:00 UTC (Julian days start at noon).
1158                    let julian = ts_to_date_floor(ts_micros) as f64
1159                        + 2_440_587.5
1160                        + (ts_split(ts_micros).1 as f64) / (MICROS_PER_DAY as f64);
1161                    prepared.push_str(&format!("{julian}"));
1162                }
1163                Some('f') => {
1164                    chars.next();
1165                    let subsec = ts_split(ts_micros).1 % MICROS_PER_SEC;
1166                    prepared.push_str(&format!("{:06}", subsec));
1167                }
1168                Some('s') => {
1169                    chars.next();
1170                    prepared.push_str(&format!("{}", ts_micros / MICROS_PER_SEC));
1171                }
1172                Some(&next) => {
1173                    prepared.push('%');
1174                    prepared.push(next);
1175                    chars.next();
1176                }
1177                None => prepared.push('%'),
1178            }
1179        } else {
1180            prepared.push(c);
1181        }
1182    }
1183    Ok(z.strftime(&prepared).to_string())
1184}
1185
1186/// Session-agnostic util used by eval.rs for SQL INTERVAL comparison normalization.
1187pub fn pg_normalized_interval_cmp(a: (i32, i32, i64), b: (i32, i32, i64)) -> std::cmp::Ordering {
1188    let at = interval_to_total_micros(a.0, a.1, a.2);
1189    let bt = interval_to_total_micros(b.0, b.1, b.2);
1190    at.cmp(&bt)
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195    use super::*;
1196
1197    #[test]
1198    fn ymd_roundtrip_epoch() {
1199        assert_eq!(days_to_ymd(0), (1970, 1, 1));
1200        assert_eq!(ymd_to_days(1970, 1, 1), Some(0));
1201    }
1202
1203    #[test]
1204    fn ymd_roundtrip_leap_day() {
1205        let days = ymd_to_days(2024, 2, 29).unwrap();
1206        assert_eq!(days_to_ymd(days), (2024, 2, 29));
1207    }
1208
1209    #[test]
1210    fn ymd_pre_epoch() {
1211        let days = ymd_to_days(1960, 1, 1).unwrap();
1212        assert!(days < 0);
1213        assert_eq!(days_to_ymd(days), (1960, 1, 1));
1214    }
1215
1216    #[test]
1217    fn hmsn_roundtrip() {
1218        let us = hmsn_to_micros(12, 30, 45, 123456).unwrap();
1219        assert_eq!(micros_to_hmsn(us), (12, 30, 45, 123456));
1220    }
1221
1222    #[test]
1223    fn time_upper_bound_inclusive() {
1224        assert_eq!(hmsn_to_micros(24, 0, 0, 0), Some(MICROS_PER_DAY));
1225        assert_eq!(hmsn_to_micros(24, 0, 0, 1), None);
1226    }
1227
1228    #[test]
1229    fn ts_split_pre_1970() {
1230        // -1 µs is 1969-12-31 23:59:59.999999.
1231        let (d, t) = ts_split(-1);
1232        assert_eq!(d, -1); // day = 1969-12-31
1233        assert_eq!(t, MICROS_PER_DAY - 1);
1234    }
1235
1236    #[test]
1237    fn parse_format_date_roundtrip() {
1238        let d = parse_date("2024-01-15").unwrap();
1239        assert_eq!(format_date(d), "2024-01-15");
1240    }
1241
1242    #[test]
1243    fn parse_date_bc() {
1244        // "0001-01-01 BC" = astronomical year 0 = 1 day before "0001-01-01"
1245        let ad = parse_date("0001-01-01").unwrap();
1246        let bc = parse_date("0001-01-01 BC").unwrap();
1247        assert!(bc < ad);
1248    }
1249
1250    #[test]
1251    fn parse_date_rejects_year_0() {
1252        assert!(parse_date("0000-01-01").is_err());
1253    }
1254
1255    #[test]
1256    fn parse_date_infinity() {
1257        assert_eq!(parse_date("infinity").unwrap(), DATE_INFINITY_DAYS);
1258        assert_eq!(parse_date("-infinity").unwrap(), DATE_NEG_INFINITY_DAYS);
1259    }
1260
1261    #[test]
1262    fn parse_time_with_fractional() {
1263        let t = parse_time("12:30:45.123456").unwrap();
1264        assert_eq!(format_time(t), "12:30:45.123456");
1265    }
1266
1267    #[test]
1268    fn parse_time_24_00() {
1269        assert_eq!(parse_time("24:00:00").unwrap(), MICROS_PER_DAY);
1270    }
1271
1272    #[test]
1273    fn parse_timestamp_iso() {
1274        let t = parse_timestamp("2024-01-15T12:30:45Z").unwrap();
1275        assert_eq!(format_timestamp(t), "2024-01-15 12:30:45");
1276    }
1277
1278    #[test]
1279    fn parse_timestamp_naive() {
1280        let t1 = parse_timestamp("2024-01-15 12:30:45").unwrap();
1281        let t2 = parse_timestamp("2024-01-15T12:30:45").unwrap();
1282        assert_eq!(t1, t2);
1283    }
1284
1285    #[test]
1286    fn parse_timestamp_infinity() {
1287        assert_eq!(parse_timestamp("infinity").unwrap(), TS_INFINITY_MICROS);
1288    }
1289
1290    #[test]
1291    fn parse_timestamp_bc() {
1292        // AD 0001-01-01 at midnight minus 1 day should equal BC 0001-12-31 at midnight.
1293        let ad = parse_timestamp("0001-01-01 00:00:00").unwrap();
1294        let bc = parse_timestamp("0001-12-31 00:00:00 BC").unwrap();
1295        assert_eq!(ad - bc, MICROS_PER_DAY);
1296    }
1297
1298    #[test]
1299    fn parse_timestamp_rejects_year_0() {
1300        assert!(parse_timestamp("0000-06-15 12:00:00").is_err());
1301    }
1302
1303    #[test]
1304    fn parse_interval_pg_verbose() {
1305        let (m, d, us) = parse_interval("1 year 2 months 3 days").unwrap();
1306        assert_eq!((m, d, us), (14, 3, 0));
1307    }
1308
1309    #[test]
1310    fn parse_interval_with_hms() {
1311        let (m, d, us) = parse_interval("3 days 04:05:06.789").unwrap();
1312        assert_eq!(m, 0);
1313        assert_eq!(d, 3);
1314        let expected_us = 4 * MICROS_PER_HOUR + 5 * MICROS_PER_MIN + 6 * MICROS_PER_SEC + 789000;
1315        assert_eq!(us, expected_us);
1316    }
1317
1318    #[test]
1319    fn parse_interval_iso8601() {
1320        let (m, d, us) = parse_interval("P1Y2M3DT4H5M6S").unwrap();
1321        assert_eq!(m, 14);
1322        assert_eq!(d, 3);
1323        assert_eq!(
1324            us,
1325            4 * MICROS_PER_HOUR + 5 * MICROS_PER_MIN + 6 * MICROS_PER_SEC
1326        );
1327    }
1328
1329    #[test]
1330    fn format_interval_zero() {
1331        assert_eq!(format_interval(0, 0, 0), "00:00:00");
1332    }
1333
1334    #[test]
1335    fn format_interval_mixed() {
1336        assert_eq!(
1337            format_interval(
1338                14,
1339                3,
1340                4 * MICROS_PER_HOUR + 5 * MICROS_PER_MIN + 6 * MICROS_PER_SEC
1341            ),
1342            "1 year 2 mons 3 days 04:05:06"
1343        );
1344    }
1345
1346    #[test]
1347    fn add_interval_month_clamp() {
1348        // Jan 31 + 1 month = Feb 29 (leap year 2024).
1349        let jan31 = parse_date("2024-01-31").unwrap();
1350        let ts = add_interval_to_date(jan31, 1, 0, 0).unwrap();
1351        let (d, _t) = ts_split(ts);
1352        let (y, mo, da) = days_to_ymd(d);
1353        assert_eq!((y, mo, da), (2024, 2, 29));
1354    }
1355
1356    #[test]
1357    fn add_interval_month_clamp_non_leap() {
1358        let jan31 = parse_date("2023-01-31").unwrap();
1359        let ts = add_interval_to_date(jan31, 1, 0, 0).unwrap();
1360        let (d, _t) = ts_split(ts);
1361        let (y, mo, da) = days_to_ymd(d);
1362        assert_eq!((y, mo, da), (2023, 2, 28));
1363    }
1364
1365    #[test]
1366    fn interval_normalized_compare() {
1367        // 1 month == 30 days.
1368        let a = (1i32, 0i32, 0i64);
1369        let b = (0i32, 30i32, 0i64);
1370        assert_eq!(pg_normalized_interval_cmp(a, b), std::cmp::Ordering::Equal);
1371    }
1372
1373    #[test]
1374    fn justify_days_basic() {
1375        let (m, d, us) = justify_days(0, 65, 0);
1376        assert_eq!((m, d, us), (2, 5, 0));
1377    }
1378
1379    #[test]
1380    fn justify_hours_basic() {
1381        let (m, d, us) = justify_hours(0, 0, 50 * MICROS_PER_HOUR + 10 * MICROS_PER_MIN);
1382        assert_eq!(
1383            (m, d, us),
1384            (0, 2, 2 * MICROS_PER_HOUR + 10 * MICROS_PER_MIN)
1385        );
1386    }
1387
1388    #[test]
1389    fn time_add_wrap() {
1390        let t = parse_time("23:00:00").unwrap();
1391        let result = add_interval_to_time(t, 0, 0, 2 * MICROS_PER_HOUR).unwrap();
1392        assert_eq!(format_time(result), "01:00:00");
1393    }
1394
1395    #[test]
1396    fn time_add_rejects_days() {
1397        let t = parse_time("12:00:00").unwrap();
1398        assert!(add_interval_to_time(t, 0, 1, 0).is_err());
1399    }
1400
1401    #[test]
1402    fn subtract_timestamps_basic() {
1403        let a = parse_timestamp("2024-01-02 12:00:00").unwrap();
1404        let b = parse_timestamp("2024-01-01 00:00:00").unwrap();
1405        let (days, micros) = subtract_timestamps(a, b);
1406        assert_eq!(days, 1);
1407        assert_eq!(micros, 12 * MICROS_PER_HOUR);
1408    }
1409
1410    #[test]
1411    fn ts_to_date_floor_pre_epoch() {
1412        assert_eq!(ts_to_date_floor(-1), -1);
1413        assert_eq!(ts_to_date_floor(0), 0);
1414        assert_eq!(ts_to_date_floor(MICROS_PER_DAY - 1), 0);
1415        assert_eq!(ts_to_date_floor(MICROS_PER_DAY), 1);
1416    }
1417
1418    #[test]
1419    fn extract_year_from_date() {
1420        let d = parse_date("2024-03-15").unwrap();
1421        assert_eq!(
1422            extract("year", &Value::Date(d)).unwrap(),
1423            Value::Integer(2024)
1424        );
1425    }
1426
1427    #[test]
1428    fn extract_dow_sunday() {
1429        // 2024-01-07 is a Sunday.
1430        let d = parse_date("2024-01-07").unwrap();
1431        assert_eq!(extract("dow", &Value::Date(d)).unwrap(), Value::Integer(0));
1432        assert_eq!(
1433            extract("isodow", &Value::Date(d)).unwrap(),
1434            Value::Integer(7)
1435        );
1436    }
1437
1438    #[test]
1439    fn date_trunc_month() {
1440        let ts = parse_timestamp("2024-03-15 12:30:45").unwrap();
1441        let result = date_trunc("month", &Value::Timestamp(ts)).unwrap();
1442        if let Value::Timestamp(t) = result {
1443            assert_eq!(format_timestamp(t), "2024-03-01 00:00:00");
1444        } else {
1445            panic!("expected Timestamp");
1446        }
1447    }
1448
1449    #[test]
1450    fn date_trunc_week_monday() {
1451        // 2024-01-07 is Sunday; trunc week → 2024-01-01 (Monday).
1452        let d = parse_date("2024-01-07").unwrap();
1453        let Value::Date(trunc) = date_trunc("week", &Value::Date(d)).unwrap() else {
1454            panic!("expected Date");
1455        };
1456        assert_eq!(format_date(trunc), "2024-01-01");
1457    }
1458
1459    #[test]
1460    fn age_basic() {
1461        let a = parse_timestamp("2024-04-10 00:00:00").unwrap();
1462        let b = parse_timestamp("2024-01-01 00:00:00").unwrap();
1463        let (m, d, us) = age(a, b).unwrap();
1464        // 2024-04-10 - 2024-01-01 = 3 months 9 days.
1465        assert_eq!(m, 3);
1466        assert_eq!(d, 9);
1467        assert_eq!(us, 0);
1468    }
1469
1470    #[test]
1471    fn strftime_basic() {
1472        let ts = parse_timestamp("2024-03-15 12:30:45").unwrap();
1473        let s = strftime("%Y-%m-%d", &Value::Timestamp(ts)).unwrap();
1474        assert_eq!(s, "2024-03-15");
1475    }
1476
1477    #[test]
1478    fn strftime_unix_epoch() {
1479        let ts = parse_timestamp("2024-01-01 00:00:00").unwrap();
1480        let s = strftime("%s", &Value::Timestamp(ts)).unwrap();
1481        assert_eq!(s, (ts / MICROS_PER_SEC).to_string());
1482    }
1483
1484    #[test]
1485    fn is_finite_temporal_sentinels() {
1486        assert!(!Value::Date(i32::MAX).is_finite_temporal());
1487        assert!(!Value::Date(i32::MIN).is_finite_temporal());
1488        assert!(Value::Date(0).is_finite_temporal());
1489        assert!(!Value::Timestamp(i64::MAX).is_finite_temporal());
1490        assert!(Value::Timestamp(0).is_finite_temporal());
1491    }
1492
1493    #[test]
1494    fn add_interval_infinity() {
1495        let result = add_interval_to_timestamp(TS_INFINITY_MICROS, 1, 1, 0).unwrap();
1496        assert_eq!(result, TS_INFINITY_MICROS);
1497    }
1498
1499    #[test]
1500    fn format_date_bc() {
1501        // Astronomical year 0 = 1 BC in PG convention.
1502        let bc1 = parse_date("0001-01-01 BC").unwrap();
1503        assert_eq!(format_date(bc1), "0001-01-01 BC");
1504    }
1505}