Skip to main content

mz_repr/adt/
timestamp.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9//
10// Portions of this file are derived from the PostgreSQL project. The original
11// source code was retrieved on June 1, 2023 from:
12//
13//     https://github.com/postgres/postgres/blob/REL_15_3/src/backend/utils/adt/timestamp.c
14//
15// The original source code is subject to the terms of the PostgreSQL license, a
16// copy of which can be found in the LICENSE file at the root of this
17// repository.
18
19//! Methods for checked timestamp operations.
20
21use std::error::Error;
22use std::fmt::{self, Display};
23use std::ops::Sub;
24use std::sync::LazyLock;
25
26use ::chrono::{
27    DateTime, Datelike, Days, Duration, FixedOffset, Months, NaiveDate, NaiveDateTime, NaiveTime,
28    Utc,
29};
30use chrono::Timelike;
31use mz_ore::cast::{self, CastFrom};
32use mz_persist_types::columnar::FixedSizeCodec;
33use mz_proto::chrono::ProtoNaiveDateTime;
34use mz_proto::{RustType, TryFromProtoError};
35#[cfg(any(test, feature = "proptest"))]
36use proptest::arbitrary::Arbitrary;
37#[cfg(any(test, feature = "proptest"))]
38use proptest::strategy::{BoxedStrategy, Strategy};
39use serde::{Deserialize, Serialize, Serializer};
40use thiserror::Error;
41
42use crate::Datum;
43use crate::adt::datetime::DateTimePart;
44use crate::adt::interval::Interval;
45use crate::adt::numeric::DecimalLike;
46#[cfg(any(test, feature = "proptest"))]
47use crate::scalar::{arb_naive_date_time, arb_utc_date_time};
48
49include!(concat!(env!("OUT_DIR"), "/mz_repr.adt.timestamp.rs"));
50
51const MONTHS_PER_YEAR: i64 = cast::u16_to_i64(Interval::MONTH_PER_YEAR);
52const HOURS_PER_DAY: i64 = cast::u16_to_i64(Interval::HOUR_PER_DAY);
53const MINUTES_PER_HOUR: i64 = cast::u16_to_i64(Interval::MINUTE_PER_HOUR);
54const SECONDS_PER_MINUTE: i64 = cast::u16_to_i64(Interval::SECOND_PER_MINUTE);
55
56const NANOSECONDS_PER_HOUR: i64 = NANOSECONDS_PER_MINUTE * MINUTES_PER_HOUR;
57const NANOSECONDS_PER_MINUTE: i64 = NANOSECONDS_PER_SECOND * SECONDS_PER_MINUTE;
58const NANOSECONDS_PER_SECOND: i64 = 10i64.pow(9);
59
60pub const MAX_PRECISION: u8 = 6;
61
62/// The `max_precision` of a [`SqlScalarType::Timestamp`] or
63/// [`SqlScalarType::TimestampTz`].
64///
65/// This newtype wrapper ensures that the length is within the valid range.
66///
67/// [`SqlScalarType::Timestamp`]: crate::SqlScalarType::Timestamp
68/// [`SqlScalarType::TimestampTz`]: crate::SqlScalarType::TimestampTz
69#[derive(
70    Debug,
71    Clone,
72    Copy,
73    Eq,
74    PartialEq,
75    Ord,
76    PartialOrd,
77    Hash,
78    Serialize,
79    Deserialize
80)]
81pub struct TimestampPrecision(pub(crate) u8);
82
83impl TimestampPrecision {
84    /// Consumes the newtype wrapper, returning the inner `u8`.
85    pub fn into_u8(self) -> u8 {
86        self.0
87    }
88}
89
90#[cfg(any(test, feature = "proptest"))]
91impl Arbitrary for TimestampPrecision {
92    type Parameters = ();
93    type Strategy = BoxedStrategy<TimestampPrecision>;
94
95    fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
96        (0..=MAX_PRECISION).prop_map(TimestampPrecision).boxed()
97    }
98}
99
100impl TryFrom<i64> for TimestampPrecision {
101    type Error = InvalidTimestampPrecisionError;
102
103    fn try_from(max_precision: i64) -> Result<Self, Self::Error> {
104        match u8::try_from(max_precision) {
105            Ok(max_precision) if max_precision <= MAX_PRECISION => {
106                Ok(TimestampPrecision(max_precision))
107            }
108            _ => Err(InvalidTimestampPrecisionError),
109        }
110    }
111}
112
113impl RustType<ProtoTimestampPrecision> for TimestampPrecision {
114    fn into_proto(&self) -> ProtoTimestampPrecision {
115        ProtoTimestampPrecision {
116            value: self.0.into_proto(),
117        }
118    }
119
120    // NOTE: `from_proto` is a trust boundary for durable and protocol state, so it
121    // enforces the same domain as `TryFrom<i64>`. Consumers rely on the invariant:
122    // a precision above `MAX_PRECISION` panics `round_to_precision`.
123    fn from_proto(proto: ProtoTimestampPrecision) -> Result<Self, TryFromProtoError> {
124        TimestampPrecision::try_from(i64::from(proto.value)).map_err(|e| {
125            TryFromProtoError::InvalidFieldError(format!(
126                "ProtoTimestampPrecision::value {}: {e}",
127                proto.value
128            ))
129        })
130    }
131}
132
133/// The error returned when constructing a [`VarCharMaxLength`] from an invalid
134/// value.
135///
136/// [`VarCharMaxLength`]: crate::adt::varchar::VarCharMaxLength
137#[derive(Debug, Clone)]
138pub struct InvalidTimestampPrecisionError;
139
140impl fmt::Display for InvalidTimestampPrecisionError {
141    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
142        write!(
143            f,
144            "precision for type timestamp or timestamptz must be between 0 and {}",
145            MAX_PRECISION
146        )
147    }
148}
149
150impl Error for InvalidTimestampPrecisionError {}
151
152/// Common set of methods for time component.
153pub trait TimeLike: chrono::Timelike {
154    fn extract_epoch<T>(&self) -> T
155    where
156        T: DecimalLike,
157    {
158        T::from(self.hour() * 60 * 60 + self.minute() * 60) + self.extract_second::<T>()
159    }
160
161    fn extract_second<T>(&self) -> T
162    where
163        T: DecimalLike,
164    {
165        let s = T::from(self.second());
166        let ns = T::from(self.nanosecond()) / T::from(1e9);
167        s + ns
168    }
169
170    fn extract_millisecond<T>(&self) -> T
171    where
172        T: DecimalLike,
173    {
174        let s = T::from(self.second() * 1_000);
175        let ns = T::from(self.nanosecond()) / T::from(1e6);
176        s + ns
177    }
178
179    fn extract_microsecond<T>(&self) -> T
180    where
181        T: DecimalLike,
182    {
183        let s = T::from(self.second() * 1_000_000);
184        let ns = T::from(self.nanosecond()) / T::from(1e3);
185        s + ns
186    }
187}
188
189impl<T> TimeLike for T where T: chrono::Timelike {}
190
191/// Common set of methods for date component.
192pub trait DateLike: chrono::Datelike {
193    fn extract_epoch(&self) -> i64 {
194        let naive_date = NaiveDate::from_ymd_opt(self.year(), self.month(), self.day())
195            .unwrap()
196            .and_hms_opt(0, 0, 0)
197            .unwrap();
198        naive_date.and_utc().timestamp()
199    }
200
201    /// The year in SQL's numbering, where 1 BC is year -1 and there is no
202    /// year 0. Chrono's `year` uses astronomical numbering, where 1 BC is
203    /// year 0.
204    fn extract_year(&self) -> i32 {
205        let year = self.year();
206        if year <= 0 { year - 1 } else { year }
207    }
208
209    fn millennium(&self) -> i32 {
210        (self.year() + if self.year() > 0 { 999 } else { -1_000 }) / 1_000
211    }
212
213    fn century(&self) -> i32 {
214        (self.year() + if self.year() > 0 { 99 } else { -100 }) / 100
215    }
216
217    fn decade(&self) -> i32 {
218        self.year().div_euclid(10)
219    }
220
221    /// Extract the iso week of the year
222    ///
223    /// Note that because isoweeks are defined in terms of January 4th, Jan 1 is only in week
224    /// 1 about half of the time
225    fn iso_week_number(&self) -> u32 {
226        self.iso_week().week()
227    }
228
229    fn day_of_week(&self) -> u32 {
230        self.weekday().num_days_from_sunday()
231    }
232
233    fn iso_day_of_week(&self) -> u32 {
234        self.weekday().number_from_monday()
235    }
236}
237
238impl<T> DateLike for T where T: chrono::Datelike {}
239
240/// A timestamp with both a date and a time component, but not necessarily a
241/// timezone component.
242pub trait TimestampLike:
243    Clone
244    + PartialOrd
245    + std::ops::Add<Duration, Output = Self>
246    + std::ops::Sub<Duration, Output = Self>
247    + std::ops::Sub<Output = Duration>
248    + for<'a> TryInto<Datum<'a>, Error = TimestampError>
249    + for<'a> TryFrom<Datum<'a>, Error = ()>
250    + TimeLike
251    + DateLike
252{
253    fn new(date: NaiveDate, time: NaiveTime) -> Self;
254
255    /// Returns the weekday as a `usize` between 0 and 6, where 0 represents
256    /// Sunday and 6 represents Saturday.
257    fn weekday0(&self) -> usize {
258        usize::cast_from(self.weekday().num_days_from_sunday())
259    }
260
261    /// Like [`chrono::Datelike::year_ce`], but works on the ISO week system.
262    fn iso_year_ce(&self) -> u32 {
263        let year = self.iso_week().year();
264        if year < 1 {
265            u32::try_from(1 - year).expect("known to be positive")
266        } else {
267            u32::try_from(year).expect("known to be positive")
268        }
269    }
270
271    fn timestamp(&self) -> i64;
272
273    fn timestamp_subsec_micros(&self) -> u32;
274
275    fn extract_epoch<T>(&self) -> T
276    where
277        T: DecimalLike,
278    {
279        T::lossy_from(self.timestamp()) + T::from(self.timestamp_subsec_micros()) / T::from(1e6)
280    }
281
282    fn truncate_microseconds(&self) -> Self {
283        // Use `with_nanosecond` rather than `from_hms_micro_opt`: the latter only
284        // accepts a leap-second sub-second (>= 1s) when `sec == 59`, so a value
285        // carrying chrono's leap representation at any other second (reachable
286        // from a parsed `:60` literal) would be `None` and panic. `with_nanosecond`
287        // accepts the whole [0, 2s) range, preserving the value.
288        let time = NaiveTime::from_hms_opt(self.hour(), self.minute(), self.second())
289            .and_then(|t| t.with_nanosecond((self.nanosecond() / 1_000) * 1_000))
290            .expect("hour/minute/second/nanosecond came from a valid time");
291
292        Self::new(self.date(), time)
293    }
294
295    fn truncate_milliseconds(&self) -> Self {
296        let time = NaiveTime::from_hms_opt(self.hour(), self.minute(), self.second())
297            .and_then(|t| t.with_nanosecond((self.nanosecond() / 1_000_000) * 1_000_000))
298            .expect("hour/minute/second/nanosecond came from a valid time");
299
300        Self::new(self.date(), time)
301    }
302
303    fn truncate_second(&self) -> Self {
304        let time = NaiveTime::from_hms_opt(self.hour(), self.minute(), self.second()).unwrap();
305
306        Self::new(self.date(), time)
307    }
308
309    fn truncate_minute(&self) -> Self {
310        Self::new(
311            self.date(),
312            NaiveTime::from_hms_opt(self.hour(), self.minute(), 0).unwrap(),
313        )
314    }
315
316    fn truncate_hour(&self) -> Self {
317        Self::new(
318            self.date(),
319            NaiveTime::from_hms_opt(self.hour(), 0, 0).unwrap(),
320        )
321    }
322
323    fn truncate_day(&self) -> Self {
324        Self::new(self.date(), NaiveTime::from_hms_opt(0, 0, 0).unwrap())
325    }
326
327    fn truncate_week(&self) -> Result<Self, TimestampError> {
328        let num_days_from_monday = i64::from(self.date().weekday().num_days_from_monday());
329        let new_date = NaiveDate::from_ymd_opt(self.year(), self.month(), self.day())
330            .unwrap()
331            .checked_sub_signed(
332                Duration::try_days(num_days_from_monday).ok_or(TimestampError::OutOfRange)?,
333            )
334            .ok_or(TimestampError::OutOfRange)?;
335        Ok(Self::new(
336            new_date,
337            NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
338        ))
339    }
340
341    fn truncate_month(&self) -> Self {
342        Self::new(
343            NaiveDate::from_ymd_opt(self.year(), self.month(), 1).unwrap(),
344            NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
345        )
346    }
347
348    fn truncate_quarter(&self) -> Self {
349        let month = self.month();
350        let quarter = if month <= 3 {
351            1
352        } else if month <= 6 {
353            4
354        } else if month <= 9 {
355            7
356        } else {
357            10
358        };
359
360        Self::new(
361            NaiveDate::from_ymd_opt(self.year(), quarter, 1).unwrap(),
362            NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
363        )
364    }
365
366    fn truncate_year(&self) -> Self {
367        Self::new(
368            NaiveDate::from_ymd_opt(self.year(), 1, 1).unwrap(),
369            NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
370        )
371    }
372    fn truncate_decade(&self) -> Self {
373        Self::new(
374            NaiveDate::from_ymd_opt(self.year() - self.year().rem_euclid(10), 1, 1).unwrap(),
375            NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
376        )
377    }
378    fn truncate_century(&self) -> Self {
379        // Expects the first year of the century, meaning 2001 instead of 2000.
380        Self::new(
381            NaiveDate::from_ymd_opt(
382                if self.year() > 0 {
383                    self.year() - (self.year() - 1) % 100
384                } else {
385                    self.year() - self.year() % 100 - 99
386                },
387                1,
388                1,
389            )
390            .unwrap(),
391            NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
392        )
393    }
394    fn truncate_millennium(&self) -> Self {
395        // Expects the first year of the millennium, meaning 2001 instead of 2000.
396        Self::new(
397            NaiveDate::from_ymd_opt(
398                if self.year() > 0 {
399                    self.year() - (self.year() - 1) % 1000
400                } else {
401                    self.year() - self.year() % 1000 - 999
402                },
403                1,
404                1,
405            )
406            .unwrap(),
407            NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
408        )
409    }
410
411    /// Return the date component of the timestamp
412    fn date(&self) -> NaiveDate;
413
414    /// Return the date and time of the timestamp
415    fn date_time(&self) -> NaiveDateTime;
416
417    /// Return the date and time of the timestamp
418    fn from_date_time(dt: NaiveDateTime) -> Self;
419
420    /// Returns a string representing the timezone's offset from UTC.
421    fn timezone_offset(&self) -> &'static str;
422
423    /// Returns a string representing the hour portion of the timezone's offset
424    /// from UTC.
425    fn timezone_hours(&self) -> &'static str;
426
427    /// Returns a string representing the minute portion of the timezone's
428    /// offset from UTC.
429    fn timezone_minutes(&self) -> &'static str;
430
431    /// Returns the abbreviated name of the timezone with the specified
432    /// capitalization.
433    fn timezone_name(&self, caps: bool) -> &'static str;
434
435    /// Adds given Duration to the current date and time.
436    fn checked_add_signed(self, rhs: Duration) -> Option<Self>;
437
438    /// Subtracts given Duration from the current date and time.
439    fn checked_sub_signed(self, rhs: Duration) -> Option<Self>;
440}
441
442impl TryFrom<Datum<'_>> for NaiveDateTime {
443    type Error = ();
444
445    #[inline]
446    fn try_from(from: Datum<'_>) -> Result<Self, Self::Error> {
447        match from {
448            Datum::Timestamp(dt) => Ok(dt.t),
449            _ => Err(()),
450        }
451    }
452}
453
454impl TryFrom<Datum<'_>> for DateTime<Utc> {
455    type Error = ();
456
457    #[inline]
458    fn try_from(from: Datum<'_>) -> Result<Self, Self::Error> {
459        match from {
460            Datum::TimestampTz(dt_tz) => Ok(dt_tz.t),
461            _ => Err(()),
462        }
463    }
464}
465
466impl TimestampLike for chrono::NaiveDateTime {
467    fn new(date: NaiveDate, time: NaiveTime) -> Self {
468        NaiveDateTime::new(date, time)
469    }
470
471    fn date(&self) -> NaiveDate {
472        self.date()
473    }
474
475    fn date_time(&self) -> NaiveDateTime {
476        self.clone()
477    }
478
479    fn from_date_time(dt: NaiveDateTime) -> NaiveDateTime {
480        dt
481    }
482
483    fn timestamp(&self) -> i64 {
484        self.and_utc().timestamp()
485    }
486
487    fn timestamp_subsec_micros(&self) -> u32 {
488        self.and_utc().timestamp_subsec_micros()
489    }
490
491    fn timezone_offset(&self) -> &'static str {
492        "+00"
493    }
494
495    fn timezone_hours(&self) -> &'static str {
496        "+00"
497    }
498
499    fn timezone_minutes(&self) -> &'static str {
500        "00"
501    }
502
503    fn timezone_name(&self, _caps: bool) -> &'static str {
504        ""
505    }
506
507    fn checked_add_signed(self, rhs: Duration) -> Option<Self> {
508        self.checked_add_signed(rhs)
509    }
510
511    fn checked_sub_signed(self, rhs: Duration) -> Option<Self> {
512        self.checked_sub_signed(rhs)
513    }
514}
515
516impl TimestampLike for chrono::DateTime<chrono::Utc> {
517    fn new(date: NaiveDate, time: NaiveTime) -> Self {
518        Self::from_date_time(NaiveDateTime::new(date, time))
519    }
520
521    fn date(&self) -> NaiveDate {
522        self.naive_utc().date()
523    }
524
525    fn date_time(&self) -> NaiveDateTime {
526        self.naive_utc()
527    }
528
529    fn from_date_time(dt: NaiveDateTime) -> Self {
530        DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)
531    }
532
533    fn timestamp(&self) -> i64 {
534        self.timestamp()
535    }
536
537    fn timestamp_subsec_micros(&self) -> u32 {
538        self.timestamp_subsec_micros()
539    }
540
541    fn timezone_offset(&self) -> &'static str {
542        "+00"
543    }
544
545    fn timezone_hours(&self) -> &'static str {
546        "+00"
547    }
548
549    fn timezone_minutes(&self) -> &'static str {
550        "00"
551    }
552
553    fn timezone_name(&self, caps: bool) -> &'static str {
554        if caps { "UTC" } else { "utc" }
555    }
556
557    fn checked_add_signed(self, rhs: Duration) -> Option<Self> {
558        self.checked_add_signed(rhs)
559    }
560
561    fn checked_sub_signed(self, rhs: Duration) -> Option<Self> {
562        self.checked_sub_signed(rhs)
563    }
564}
565
566#[derive(Debug, Error)]
567pub enum TimestampError {
568    #[error("timestamp out of range")]
569    OutOfRange,
570}
571
572#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
573pub struct CheckedTimestamp<T> {
574    t: T,
575}
576
577impl<T: Serialize> Serialize for CheckedTimestamp<T> {
578    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
579    where
580        S: Serializer,
581    {
582        self.t.serialize(serializer)
583    }
584}
585
586// We support intersection of the limits of Postgres, Avro, and chrono dates:
587// the set of dates that are representable in all used formats.
588//
589// - Postgres supports 4713 BC to 294276 AD (any time on those days inclusive).
590// - Avro supports i64 milliseconds since the Unix epoch: -292275055-05-16
591// 16:47:04.192 to 292278994-08-17 07:12:55.807.
592// - Avro also supports i64 microseconds since the Unix epoch: -290308-12-21
593//   19:59:05.224192 to 294247-01-10 04:00:54.775807.
594// - chrono's NaiveDate supports January 1, 262144 BCE to December 31, 262142
595//   CE.
596//
597// Thus on the low end we have 4713-12-31 BC from Postgres, and on the high end
598// 262142-12-31 from chrono.
599
600pub static LOW_DATE: LazyLock<NaiveDate> =
601    LazyLock::new(|| NaiveDate::from_ymd_opt(-4713, 12, 31).unwrap());
602pub static HIGH_DATE: LazyLock<NaiveDate> =
603    LazyLock::new(|| NaiveDate::from_ymd_opt(262142, 12, 31).unwrap());
604
605impl<T: TimestampLike> CheckedTimestamp<T> {
606    pub fn from_timestamplike(t: T) -> Result<Self, TimestampError> {
607        let d = t.date();
608        if d < *LOW_DATE {
609            return Err(TimestampError::OutOfRange);
610        }
611        if d > *HIGH_DATE {
612            return Err(TimestampError::OutOfRange);
613        }
614        Ok(Self { t })
615    }
616
617    pub fn checked_add_signed(self, rhs: Duration) -> Option<T> {
618        self.t.checked_add_signed(rhs)
619    }
620
621    pub fn checked_sub_signed(self, rhs: Duration) -> Option<T> {
622        self.t.checked_sub_signed(rhs)
623    }
624
625    /// Returns the difference between `self` and the provided [`CheckedTimestamp`] as a number of
626    /// "unit"s.
627    ///
628    /// Note: used for `DATEDIFF(...)`, which isn't a Postgres function, but is in a number of
629    /// other databases.
630    pub fn diff_as(&self, other: &Self, unit: DateTimePart) -> Result<i64, TimestampError> {
631        const QUARTERS_PER_YEAR: i64 = 4;
632        const DAYS_PER_WEEK: i64 = 7;
633
634        fn diff_inner<U>(
635            a: &CheckedTimestamp<U>,
636            b: &CheckedTimestamp<U>,
637            unit: DateTimePart,
638        ) -> Option<i64>
639        where
640            U: TimestampLike,
641        {
642            match unit {
643                DateTimePart::Millennium => {
644                    i64::cast_from(a.millennium()).checked_sub(i64::cast_from(b.millennium()))
645                }
646                DateTimePart::Century => {
647                    i64::cast_from(a.century()).checked_sub(i64::cast_from(b.century()))
648                }
649                DateTimePart::Decade => {
650                    i64::cast_from(a.decade()).checked_sub(i64::cast_from(b.decade()))
651                }
652                DateTimePart::Year => {
653                    i64::cast_from(a.year()).checked_sub(i64::cast_from(b.year()))
654                }
655                DateTimePart::Quarter => {
656                    let years = i64::cast_from(a.year()).checked_sub(i64::cast_from(b.year()))?;
657                    let quarters = years.checked_mul(QUARTERS_PER_YEAR)?;
658                    let diff = i64::cast_from(a.quarter()) - i64::cast_from(b.quarter());
659                    quarters.checked_add(diff)
660                }
661                DateTimePart::Month => {
662                    let years = i64::cast_from(a.year()).checked_sub(i64::cast_from(b.year()))?;
663                    let months = years.checked_mul(MONTHS_PER_YEAR)?;
664                    let diff = i64::cast_from(a.month()).checked_sub(i64::cast_from(b.month()))?;
665                    months.checked_add(diff)
666                }
667                DateTimePart::Week => {
668                    let diff = a.clone() - b.clone();
669                    diff.num_days().checked_div(DAYS_PER_WEEK)
670                }
671                DateTimePart::Day => {
672                    let diff = a.clone() - b.clone();
673                    Some(diff.num_days())
674                }
675                DateTimePart::Hour => {
676                    let diff = a.clone() - b.clone();
677                    Some(diff.num_hours())
678                }
679                DateTimePart::Minute => {
680                    let diff = a.clone() - b.clone();
681                    Some(diff.num_minutes())
682                }
683                DateTimePart::Second => {
684                    let diff = a.clone() - b.clone();
685                    Some(diff.num_seconds())
686                }
687                DateTimePart::Milliseconds => {
688                    let diff = a.clone() - b.clone();
689                    Some(diff.num_milliseconds())
690                }
691                DateTimePart::Microseconds => {
692                    let diff = a.clone() - b.clone();
693                    diff.num_microseconds()
694                }
695            }
696        }
697
698        diff_inner(self, other, unit).ok_or(TimestampError::OutOfRange)
699    }
700
701    /// Implementation was roughly ported from Postgres's `timestamp.c`.
702    ///
703    /// <https://github.com/postgres/postgres/blob/REL_15_3/src/backend/utils/adt/timestamp.c#L3631>
704    pub fn age(&self, other: &Self) -> Result<Interval, TimestampError> {
705        /// Returns the number of days in the month for which the [`CheckedTimestamp`] is in.
706        fn num_days_in_month<T: TimestampLike>(dt: &CheckedTimestamp<T>) -> Option<i64> {
707            // Creates a new Date in the same month and year as our original timestamp. Adds one
708            // month then subtracts one day, to get the last day of our original month.
709            let last_day = NaiveDate::from_ymd_opt(dt.year(), dt.month(), 1)?
710                .checked_add_months(Months::new(1))?
711                .checked_sub_days(Days::new(1))?
712                .day();
713
714            Some(CastFrom::cast_from(last_day))
715        }
716
717        /// All of the `checked_*` functions return `Option<T>`, so we do all of the math in this
718        /// inner function so we can use the `?` operator, maping to a `TimestampError` at the end.
719        fn age_inner<U: TimestampLike>(
720            a: &CheckedTimestamp<U>,
721            b: &CheckedTimestamp<U>,
722        ) -> Option<Interval> {
723            let mut nanos =
724                i64::cast_from(a.nanosecond()).checked_sub(i64::cast_from(b.nanosecond()))?;
725            let mut seconds = i64::cast_from(a.second()).checked_sub(i64::cast_from(b.second()))?;
726            let mut minutes = i64::cast_from(a.minute()).checked_sub(i64::cast_from(b.minute()))?;
727            let mut hours = i64::cast_from(a.hour()).checked_sub(i64::cast_from(b.hour()))?;
728            let mut days = i64::cast_from(a.day()).checked_sub(i64::cast_from(b.day()))?;
729            let mut months = i64::cast_from(a.month()).checked_sub(i64::cast_from(b.month()))?;
730            let mut years = i64::cast_from(a.year()).checked_sub(i64::cast_from(b.year()))?;
731
732            // Flip sign if necessary.
733            if a < b {
734                nanos = nanos.checked_neg()?;
735                seconds = seconds.checked_neg()?;
736                minutes = minutes.checked_neg()?;
737                hours = hours.checked_neg()?;
738                days = days.checked_neg()?;
739                months = months.checked_neg()?;
740                years = years.checked_neg()?;
741            }
742
743            // Carry negative fields into the next higher field.
744            while nanos < 0 {
745                nanos = nanos.checked_add(NANOSECONDS_PER_SECOND)?;
746                seconds = seconds.checked_sub(1)?;
747            }
748            while seconds < 0 {
749                seconds = seconds.checked_add(SECONDS_PER_MINUTE)?;
750                minutes = minutes.checked_sub(1)?;
751            }
752            while minutes < 0 {
753                minutes = minutes.checked_add(MINUTES_PER_HOUR)?;
754                hours = hours.checked_sub(1)?;
755            }
756            while hours < 0 {
757                hours = hours.checked_add(HOURS_PER_DAY)?;
758                days = days.checked_sub(1)?
759            }
760            while days < 0 {
761                if a < b {
762                    days = num_days_in_month(a).and_then(|x| days.checked_add(x))?;
763                } else {
764                    days = num_days_in_month(b).and_then(|x| days.checked_add(x))?;
765                }
766                months = months.checked_sub(1)?;
767            }
768            while months < 0 {
769                months = months.checked_add(MONTHS_PER_YEAR)?;
770                years = years.checked_sub(1)?;
771            }
772
773            // Revert the sign back, if we flipped it originally.
774            if a < b {
775                nanos = nanos.checked_neg()?;
776                seconds = seconds.checked_neg()?;
777                minutes = minutes.checked_neg()?;
778                hours = hours.checked_neg()?;
779                days = days.checked_neg()?;
780                months = months.checked_neg()?;
781                years = years.checked_neg()?;
782            }
783
784            let months = i32::try_from(years * MONTHS_PER_YEAR + months).ok()?;
785            let days = i32::try_from(days).ok()?;
786            let micros = Duration::nanoseconds(
787                nanos
788                    .checked_add(seconds.checked_mul(NANOSECONDS_PER_SECOND)?)?
789                    .checked_add(minutes.checked_mul(NANOSECONDS_PER_MINUTE)?)?
790                    .checked_add(hours.checked_mul(NANOSECONDS_PER_HOUR)?)?,
791            )
792            .num_microseconds()?;
793
794            Some(Interval {
795                months,
796                days,
797                micros,
798            })
799        }
800
801        // If at any point we overflow, map to a TimestampError.
802        age_inner(self, other).ok_or(TimestampError::OutOfRange)
803    }
804
805    /// Rounds the timestamp to the specified number of digits of precision.
806    ///
807    /// Returns [`TimestampError::OutOfRange`] when rounding up would leave
808    /// chrono's representable range, which the last microsecond of
809    /// [`HIGH_DATE`] does for every precision.
810    pub fn round_to_precision(
811        &self,
812        precision: Option<TimestampPrecision>,
813    ) -> Result<CheckedTimestamp<T>, TimestampError> {
814        let precision = precision.map(|p| p.into_u8()).unwrap_or(MAX_PRECISION);
815        // maximum precision is micros
816        let power = MAX_PRECISION
817            .checked_sub(precision)
818            .expect("precision fits in micros");
819        let round_to_micros = 10_i64.pow(power.into());
820
821        let mut original = self.date_time();
822        let nanoseconds = original.and_utc().timestamp_subsec_nanos();
823        // truncating to microseconds does not round it up
824        // i.e. 123456789 will be truncated to 123456
825        original = original.truncate_microseconds();
826        // depending upon the 7th digit here, we'll be
827        // adding 1 millisecond
828        // so eventually 123456789 will be rounded to 123457
829        let seventh_digit = (nanoseconds % 1_000) / 100;
830        assert!(seventh_digit < 10);
831        if seventh_digit >= 5 {
832            // Checked, not `+`: on the last microsecond of `HIGH_DATE` this
833            // nudge leaves chrono's range, and chrono's `Add` panics there.
834            original = original
835                .checked_add_signed(Duration::microseconds(1))
836                .ok_or(TimestampError::OutOfRange)?;
837        }
838        // this is copied from [`chrono::round::duration_round`]
839        // but using microseconds instead of nanoseconds precision
840        let stamp = original.and_utc().timestamp_micros();
841        let dt = {
842            let delta_down = stamp % round_to_micros;
843            if delta_down == 0 {
844                original
845            } else {
846                let (delta_up, delta_down) = if delta_down < 0 {
847                    (delta_down.abs(), round_to_micros - delta_down.abs())
848                } else {
849                    (round_to_micros - delta_down, delta_down)
850                };
851                // Both directions are checked for the same reason as the
852                // seventh-digit nudge above.
853                if delta_up <= delta_down {
854                    original.checked_add_signed(Duration::microseconds(delta_up))
855                } else {
856                    original.checked_sub_signed(Duration::microseconds(delta_down))
857                }
858                .ok_or(TimestampError::OutOfRange)?
859            }
860        };
861
862        let t = T::from_date_time(dt);
863        Self::from_timestamplike(t)
864    }
865}
866
867impl TryFrom<NaiveDateTime> for CheckedTimestamp<NaiveDateTime> {
868    type Error = TimestampError;
869
870    fn try_from(value: NaiveDateTime) -> Result<Self, Self::Error> {
871        Self::from_timestamplike(value)
872    }
873}
874
875impl TryFrom<DateTime<Utc>> for CheckedTimestamp<DateTime<Utc>> {
876    type Error = TimestampError;
877
878    fn try_from(value: DateTime<Utc>) -> Result<Self, Self::Error> {
879        Self::from_timestamplike(value)
880    }
881}
882
883impl<T: TimestampLike> std::ops::Deref for CheckedTimestamp<T> {
884    type Target = T;
885
886    #[inline]
887    fn deref(&self) -> &T {
888        &self.t
889    }
890}
891
892impl From<CheckedTimestamp<NaiveDateTime>> for NaiveDateTime {
893    fn from(val: CheckedTimestamp<NaiveDateTime>) -> Self {
894        val.t
895    }
896}
897
898impl From<CheckedTimestamp<DateTime<Utc>>> for DateTime<Utc> {
899    fn from(val: CheckedTimestamp<DateTime<Utc>>) -> Self {
900        val.t
901    }
902}
903
904impl CheckedTimestamp<NaiveDateTime> {
905    pub fn to_naive(&self) -> NaiveDateTime {
906        self.t
907    }
908}
909
910impl CheckedTimestamp<DateTime<Utc>> {
911    pub fn to_naive(&self) -> NaiveDateTime {
912        self.t.date_naive().and_time(self.t.time())
913    }
914}
915
916impl Display for CheckedTimestamp<NaiveDateTime> {
917    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
918        self.t.fmt(f)
919    }
920}
921
922impl Display for CheckedTimestamp<DateTime<Utc>> {
923    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
924        self.t.fmt(f)
925    }
926}
927
928impl RustType<ProtoNaiveDateTime> for CheckedTimestamp<NaiveDateTime> {
929    fn into_proto(&self) -> ProtoNaiveDateTime {
930        self.t.into_proto()
931    }
932
933    fn from_proto(proto: ProtoNaiveDateTime) -> Result<Self, TryFromProtoError> {
934        // Go through `from_timestamplike` so out-of-range values are
935        // rejected here. Pushing them into a `Row` succeeds, but
936        // `read_datum` would panic when reconstructing the timestamp.
937        CheckedTimestamp::from_timestamplike(NaiveDateTime::from_proto(proto)?)
938            .map_err(|err| TryFromProtoError::InvalidFieldError(err.to_string()))
939    }
940}
941
942impl RustType<ProtoNaiveDateTime> for CheckedTimestamp<DateTime<Utc>> {
943    fn into_proto(&self) -> ProtoNaiveDateTime {
944        self.t.into_proto()
945    }
946
947    fn from_proto(proto: ProtoNaiveDateTime) -> Result<Self, TryFromProtoError> {
948        CheckedTimestamp::from_timestamplike(DateTime::<Utc>::from_proto(proto)?)
949            .map_err(|err| TryFromProtoError::InvalidFieldError(err.to_string()))
950    }
951}
952
953impl<T: Sub<Output = Duration>> Sub<CheckedTimestamp<T>> for CheckedTimestamp<T> {
954    type Output = Duration;
955
956    #[inline]
957    fn sub(self, rhs: CheckedTimestamp<T>) -> Duration {
958        self.t - rhs.t
959    }
960}
961
962impl<T: Sub<Duration, Output = T>> Sub<Duration> for CheckedTimestamp<T> {
963    type Output = T;
964
965    #[inline]
966    fn sub(self, rhs: Duration) -> T {
967        self.t - rhs
968    }
969}
970
971#[cfg(any(test, feature = "proptest"))]
972impl Arbitrary for CheckedTimestamp<NaiveDateTime> {
973    type Parameters = ();
974    type Strategy = BoxedStrategy<CheckedTimestamp<NaiveDateTime>>;
975
976    fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
977        arb_naive_date_time()
978            .prop_map(|dt| CheckedTimestamp::try_from(dt).unwrap())
979            .boxed()
980    }
981}
982
983#[cfg(any(test, feature = "proptest"))]
984impl Arbitrary for CheckedTimestamp<DateTime<Utc>> {
985    type Parameters = ();
986    type Strategy = BoxedStrategy<CheckedTimestamp<DateTime<Utc>>>;
987
988    fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
989        arb_utc_date_time()
990            .prop_map(|dt| CheckedTimestamp::try_from(dt).unwrap())
991            .boxed()
992    }
993}
994
995/// An encoded packed variant of [`NaiveDateTime`].
996///
997/// We uphold the invariant that [`PackedNaiveDateTime`] sorts the same as
998/// [`NaiveDateTime`].
999#[derive(Copy, Clone, Debug, PartialOrd, Ord, PartialEq, Eq, Hash)]
1000pub struct PackedNaiveDateTime([u8; Self::SIZE]);
1001
1002// `as` conversions are okay here because we're doing bit level logic to make
1003// sure the sort order of the packed binary is correct. This is implementation
1004// is proptest-ed below.
1005#[allow(clippy::as_conversions)]
1006impl FixedSizeCodec<NaiveDateTime> for PackedNaiveDateTime {
1007    const SIZE: usize = 16;
1008
1009    fn as_bytes(&self) -> &[u8] {
1010        &self.0
1011    }
1012
1013    fn from_bytes(slice: &[u8]) -> Result<Self, String> {
1014        let buf: [u8; Self::SIZE] = slice.try_into().map_err(|_| {
1015            format!(
1016                "size for PackedNaiveDateTime is {} bytes, got {}",
1017                Self::SIZE,
1018                slice.len()
1019            )
1020        })?;
1021        Ok(PackedNaiveDateTime(buf))
1022    }
1023
1024    #[inline]
1025    fn from_value(value: NaiveDateTime) -> Self {
1026        let mut buf = [0u8; 16];
1027
1028        // Note: We XOR the values to get correct sorting of negative values.
1029
1030        let year = (value.year() as u32) ^ (0x8000_0000u32);
1031        let ordinal = value.ordinal();
1032        let secs = value.num_seconds_from_midnight();
1033        let nano = value.nanosecond();
1034
1035        buf[..4].copy_from_slice(&year.to_be_bytes());
1036        buf[4..8].copy_from_slice(&ordinal.to_be_bytes());
1037        buf[8..12].copy_from_slice(&secs.to_be_bytes());
1038        buf[12..].copy_from_slice(&nano.to_be_bytes());
1039
1040        PackedNaiveDateTime(buf)
1041    }
1042
1043    #[inline]
1044    fn into_value(self) -> NaiveDateTime {
1045        let mut year = [0u8; 4];
1046        year.copy_from_slice(&self.0[..4]);
1047        let year = u32::from_be_bytes(year) ^ 0x8000_0000u32;
1048
1049        let mut ordinal = [0u8; 4];
1050        ordinal.copy_from_slice(&self.0[4..8]);
1051        let ordinal = u32::from_be_bytes(ordinal);
1052
1053        let mut secs = [0u8; 4];
1054        secs.copy_from_slice(&self.0[8..12]);
1055        let secs = u32::from_be_bytes(secs);
1056
1057        let mut nano = [0u8; 4];
1058        nano.copy_from_slice(&self.0[12..]);
1059        let nano = u32::from_be_bytes(nano);
1060
1061        let date = NaiveDate::from_yo_opt(year as i32, ordinal)
1062            .expect("NaiveDate roundtrips with PackedNaiveDateTime");
1063        let time = NaiveTime::from_num_seconds_from_midnight_opt(secs, nano)
1064            .expect("NaiveTime roundtrips with PackedNaiveDateTime");
1065
1066        NaiveDateTime::new(date, time)
1067    }
1068}
1069
1070/// Adds a fixed offset to `lhs`, returning `None` on overflow.
1071///
1072/// chrono's own `NaiveDateTime + FixedOffset` panics on overflow, so this is the
1073/// only safe way to shift a timestamp that may sit at the edge of chrono's
1074/// range. Beyond the overflow check it also normalizes a leap second that the
1075/// shift moves off `:59`, see the note in the body.
1076pub fn checked_add_with_leapsecond(
1077    lhs: &NaiveDateTime,
1078    rhs: &FixedOffset,
1079) -> Option<NaiveDateTime> {
1080    checked_offset_with_leapsecond(lhs, rhs.local_minus_utc())
1081}
1082
1083/// Subtracts a fixed offset from `lhs`, returning `None` on overflow.
1084///
1085/// The mirror of [`checked_add_with_leapsecond`], with the same guarantees.
1086pub fn checked_sub_with_leapsecond(
1087    lhs: &NaiveDateTime,
1088    rhs: &FixedOffset,
1089) -> Option<NaiveDateTime> {
1090    checked_offset_with_leapsecond(lhs, rhs.local_minus_utc().checked_neg()?)
1091}
1092
1093/// Shifts `lhs` by `seconds`, keeping a leap second representable.
1094fn checked_offset_with_leapsecond(lhs: &NaiveDateTime, seconds: i32) -> Option<NaiveDateTime> {
1095    // The fractional part is set aside so that the shift operates on whole
1096    // seconds only, then recovered below.
1097    let nanos = lhs.nanosecond();
1098    let whole = lhs.with_nanosecond(0).expect("0 is a valid nanosecond");
1099    let dt = whole.checked_add_signed(Duration::try_seconds(i64::from(seconds))?)?;
1100    // chrono represents a leap second as `nanos >= 1_000_000_000`, but only on a
1101    // second-of-minute of 59. If the shift moved us off `:59`, we can't keep the
1102    // leap-second representation: the resulting `NaiveTime` would be
1103    // unconstructable via `from_num_seconds_from_midnight_opt` and would panic
1104    // when round-tripped through `Row` encoding. In that case, fold the leap
1105    // second into the next regular second, which is also what PostgreSQL does
1106    // with a `:60` literal.
1107    if nanos >= 1_000_000_000 && dt.second() != 59 {
1108        dt.checked_add_signed(Duration::nanoseconds(i64::from(nanos)))
1109    } else {
1110        Some(
1111            dt.with_nanosecond(nanos)
1112                .expect("nanos came from a NaiveTime"),
1113        )
1114    }
1115}
1116
1117#[cfg(test)]
1118mod test {
1119    use super::*;
1120    use itertools::Itertools;
1121    use mz_ore::{assert_err, assert_ok};
1122    use proptest::prelude::*;
1123
1124    #[mz_ore::test]
1125    fn test_max_age() {
1126        let low = CheckedTimestamp::try_from(
1127            LOW_DATE.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()),
1128        )
1129        .unwrap();
1130        let high = CheckedTimestamp::try_from(
1131            HIGH_DATE.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()),
1132        )
1133        .unwrap();
1134
1135        let years = HIGH_DATE.year() - LOW_DATE.year();
1136        let months = years * 12;
1137
1138        // Test high - low.
1139        let result = high.age(&low).unwrap();
1140        assert_eq!(result, Interval::new(months, 0, 0));
1141
1142        // Test low - high.
1143        let result = low.age(&high).unwrap();
1144        assert_eq!(result, Interval::new(-months, 0, 0));
1145    }
1146
1147    fn assert_round_to_precision(
1148        dt: CheckedTimestamp<NaiveDateTime>,
1149        precision: u8,
1150        expected: i64,
1151    ) {
1152        let updated = dt
1153            .round_to_precision(Some(TimestampPrecision(precision)))
1154            .unwrap();
1155        assert_eq!(expected, updated.and_utc().timestamp_micros());
1156    }
1157
1158    #[mz_ore::test]
1159    fn test_round_to_precision() {
1160        let date = CheckedTimestamp::try_from(
1161            NaiveDate::from_ymd_opt(1970, 1, 1)
1162                .unwrap()
1163                .and_hms_nano_opt(0, 0, 0, 123456789)
1164                .unwrap(),
1165        )
1166        .unwrap();
1167        assert_round_to_precision(date, 0, 0);
1168        assert_round_to_precision(date, 1, 100000);
1169        assert_round_to_precision(date, 2, 120000);
1170        assert_round_to_precision(date, 3, 123000);
1171        assert_round_to_precision(date, 4, 123500);
1172        assert_round_to_precision(date, 5, 123460);
1173        assert_round_to_precision(date, 6, 123457);
1174
1175        let low =
1176            CheckedTimestamp::try_from(LOW_DATE.and_hms_nano_opt(0, 0, 0, 123456789).unwrap())
1177                .unwrap();
1178        assert_round_to_precision(low, 0, -210863606400000000);
1179        assert_round_to_precision(low, 1, -210863606399900000);
1180        assert_round_to_precision(low, 2, -210863606399880000);
1181        assert_round_to_precision(low, 3, -210863606399877000);
1182        assert_round_to_precision(low, 4, -210863606399876500);
1183        assert_round_to_precision(low, 5, -210863606399876540);
1184        assert_round_to_precision(low, 6, -210863606399876543);
1185
1186        let high =
1187            CheckedTimestamp::try_from(HIGH_DATE.and_hms_nano_opt(0, 0, 0, 123456789).unwrap())
1188                .unwrap();
1189        assert_round_to_precision(high, 0, 8210266790400000000);
1190        assert_round_to_precision(high, 1, 8210266790400100000);
1191        assert_round_to_precision(high, 2, 8210266790400120000);
1192        assert_round_to_precision(high, 3, 8210266790400123000);
1193        assert_round_to_precision(high, 4, 8210266790400123500);
1194        assert_round_to_precision(high, 5, 8210266790400123460);
1195        assert_round_to_precision(high, 6, 8210266790400123457);
1196    }
1197
1198    #[mz_ore::test]
1199    fn test_round_to_precision_leap_second_off_minute() {
1200        // Regression: parsing a `:60` literal can leave chrono's leap-second
1201        // representation (sub-second >= 1s) on a second other than `:59` (after
1202        // time-zone math). Rounding such a value, as the string->timestamp cast
1203        // does, must not panic. `truncate_microseconds`/`truncate_milliseconds`
1204        // previously rebuilt the time with `from_hms_{micro,milli}_opt`, whose
1205        // leap sub-second range is only valid at `:59`, and unwrapped the `None`.
1206        let leap = NaiveDate::from_ymd_opt(3, 3, 17)
1207            .unwrap()
1208            .and_hms_opt(12, 30, 56)
1209            .unwrap()
1210            .with_nanosecond(1_000_000_000)
1211            .unwrap();
1212        let ts = CheckedTimestamp::try_from(leap).unwrap();
1213        for precision in [None, Some(0), Some(3), Some(6)] {
1214            ts.round_to_precision(precision.map(TimestampPrecision))
1215                .unwrap();
1216        }
1217    }
1218
1219    #[mz_ore::test]
1220    fn test_checked_offset_with_leapsecond_branches() {
1221        // Leap-second values reach these shifts from persisted data and from
1222        // the frozen storage source casts, so both branches need coverage
1223        // built directly from the leap representation. A SQL `:60` literal
1224        // rolls over at parse and cannot reach them.
1225        let leap = NaiveDate::from_ymd_opt(2024, 6, 30)
1226            .unwrap()
1227            .and_hms_nano_opt(23, 59, 59, 1_500_000_000)
1228            .unwrap();
1229        let second = FixedOffset::east_opt(1).unwrap();
1230        let minute = FixedOffset::east_opt(60).unwrap();
1231
1232        // A shift off `:59` cannot keep the leap representation, so the leap
1233        // nanos fold into the following regular second.
1234        assert_eq!(
1235            checked_add_with_leapsecond(&leap, &second).unwrap(),
1236            NaiveDate::from_ymd_opt(2024, 7, 1)
1237                .unwrap()
1238                .and_hms_nano_opt(0, 0, 1, 500_000_000)
1239                .unwrap()
1240        );
1241        assert_eq!(
1242            checked_sub_with_leapsecond(&leap, &second).unwrap(),
1243            NaiveDate::from_ymd_opt(2024, 6, 30)
1244                .unwrap()
1245                .and_hms_nano_opt(23, 59, 59, 500_000_000)
1246                .unwrap()
1247        );
1248
1249        // A shift that lands on `:59` keeps the leap representation.
1250        assert_eq!(
1251            checked_add_with_leapsecond(&leap, &minute).unwrap(),
1252            NaiveDate::from_ymd_opt(2024, 7, 1)
1253                .unwrap()
1254                .and_hms_nano_opt(0, 0, 59, 1_500_000_000)
1255                .unwrap()
1256        );
1257    }
1258
1259    #[mz_ore::test]
1260    fn test_round_to_precision_high_date_overflow() {
1261        // `HIGH_DATE` is exactly `NaiveDate::MAX`, so rounding *up* from the last
1262        // fraction of that day leaves chrono's range. Both the seventh-digit
1263        // nudge and the rounding branch used unchecked `+`, which panics in
1264        // chrono rather than returning an error.
1265        //
1266        // A precision below 6 reaches this with far fewer fractional digits than
1267        // the microsecond default: at precision 0 a single `.5` rounds up a whole
1268        // second. Every precision is covered because each has its own quantum.
1269        for (nanos, precision) in [
1270            (999_999_500, None),
1271            (500_000_000, Some(0)),
1272            (950_000_000, Some(1)),
1273            (995_000_000, Some(2)),
1274            (999_500_000, Some(3)),
1275            (999_950_000, Some(4)),
1276            (999_995_000, Some(5)),
1277            (999_999_500, Some(6)),
1278        ] {
1279            let ts =
1280                CheckedTimestamp::try_from(HIGH_DATE.and_hms_nano_opt(23, 59, 59, nanos).unwrap())
1281                    .unwrap();
1282            assert!(
1283                matches!(
1284                    ts.round_to_precision(precision.map(TimestampPrecision)),
1285                    Err(TimestampError::OutOfRange)
1286                ),
1287                "rounding {nanos}ns to {precision:?} should report out of range"
1288            );
1289        }
1290
1291        // The mirror case: rounding *down* stays in range, so it must still
1292        // succeed rather than being caught by an over-broad check.
1293        let ts =
1294            CheckedTimestamp::try_from(HIGH_DATE.and_hms_nano_opt(23, 59, 59, 400_000).unwrap())
1295                .unwrap();
1296        assert_ok!(ts.round_to_precision(Some(TimestampPrecision(3))));
1297    }
1298
1299    #[mz_ore::test]
1300    fn test_precision_edge_cases() {
1301        #[allow(clippy::disallowed_methods)] // not using enhanced panic handler in tests
1302        let result = std::panic::catch_unwind(|| {
1303            let date = CheckedTimestamp::try_from(
1304                DateTime::from_timestamp_micros(123456).unwrap().naive_utc(),
1305            )
1306            .unwrap();
1307            let _ = date.round_to_precision(Some(TimestampPrecision(7)));
1308        });
1309        assert_err!(result);
1310
1311        let date = CheckedTimestamp::try_from(
1312            DateTime::from_timestamp_micros(123456).unwrap().naive_utc(),
1313        )
1314        .unwrap();
1315        let date = date.round_to_precision(None).unwrap();
1316        assert_eq!(123456, date.and_utc().timestamp_micros());
1317    }
1318
1319    #[mz_ore::test]
1320    fn test_equality_with_same_precision() {
1321        let date1 =
1322            CheckedTimestamp::try_from(DateTime::from_timestamp(0, 123456).unwrap()).unwrap();
1323        let date1 = date1
1324            .round_to_precision(Some(TimestampPrecision(0)))
1325            .unwrap();
1326
1327        let date2 =
1328            CheckedTimestamp::try_from(DateTime::from_timestamp(0, 123456789).unwrap()).unwrap();
1329        let date2 = date2
1330            .round_to_precision(Some(TimestampPrecision(0)))
1331            .unwrap();
1332        assert_eq!(date1, date2);
1333    }
1334
1335    #[mz_ore::test]
1336    fn test_equality_with_different_precisions() {
1337        let date1 =
1338            CheckedTimestamp::try_from(DateTime::from_timestamp(0, 123500000).unwrap()).unwrap();
1339        let date1 = date1
1340            .round_to_precision(Some(TimestampPrecision(5)))
1341            .unwrap();
1342
1343        let date2 =
1344            CheckedTimestamp::try_from(DateTime::from_timestamp(0, 123456789).unwrap()).unwrap();
1345        let date2 = date2
1346            .round_to_precision(Some(TimestampPrecision(4)))
1347            .unwrap();
1348        assert_eq!(date1, date2);
1349    }
1350
1351    proptest! {
1352        #[mz_ore::test]
1353        #[cfg_attr(miri, ignore)] // slow
1354        fn test_age_naive(a: CheckedTimestamp<NaiveDateTime>, b: CheckedTimestamp<NaiveDateTime>) {
1355            let result = a.age(&b);
1356            prop_assert!(result.is_ok());
1357        }
1358
1359        #[mz_ore::test]
1360        #[cfg_attr(miri, ignore)] // slow
1361        fn test_age_utc(a: CheckedTimestamp<DateTime<Utc>>, b: CheckedTimestamp<DateTime<Utc>>) {
1362            let result = a.age(&b);
1363            prop_assert!(result.is_ok());
1364        }
1365    }
1366
1367    #[mz_ore::test]
1368    fn proptest_packed_naive_date_time_roundtrips() {
1369        proptest!(|(timestamp in arb_naive_date_time())| {
1370            let packed = PackedNaiveDateTime::from_value(timestamp);
1371            let rnd = packed.into_value();
1372            prop_assert_eq!(timestamp, rnd);
1373        });
1374    }
1375
1376    #[mz_ore::test]
1377    #[cfg_attr(miri, ignore)] // too slow
1378    fn proptest_packed_naive_date_time_sort_order() {
1379        let strat = proptest::collection::vec(arb_naive_date_time(), 0..128);
1380        proptest!(|(mut times in strat)| {
1381            let mut packed: Vec<_> = times
1382                .iter()
1383                .copied()
1384                .map(PackedNaiveDateTime::from_value)
1385                .collect();
1386
1387            times.sort();
1388            packed.sort();
1389
1390            for (time, packed) in times.into_iter().zip_eq(packed.into_iter()) {
1391                let rnd = packed.into_value();
1392                prop_assert_eq!(time, rnd);
1393            }
1394        });
1395    }
1396}