Skip to main content

quack_rs/
datetime.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. <https://github.com/tomtom215/>
3// My way of giving something small back to the open source community
4// and encouraging more Rust development!
5
6//! Calendar conversions for `DuckDB`'s temporal types.
7//!
8//! `VectorReader`/`VectorWriter` move `DATE`, `TIME` and `TIMESTAMP` as the raw
9//! integers `DuckDB` stores: days since 1970-01-01, microseconds since midnight,
10//! microseconds since the epoch. Turning those into year/month/day means
11//! implementing the proleptic Gregorian calendar — including `DuckDB`'s
12//! infinity sentinels — which is exactly the kind of thing an extension should
13//! not be reimplementing.
14//!
15//! `DuckDB` already exposes the conversions (`duckdb_from_date`,
16//! `duckdb_to_date`, `duckdb_from_time`, `duckdb_from_timestamp`, …) and they
17//! sit in the **stable** prefix of the C extension API, so they work on every
18//! release from v1.2.0 onwards and need no feature flag. This module wraps them
19//! in plain Rust structs.
20//!
21//! Using `DuckDB`'s own routines also means the results agree with `DuckDB`'s
22//! SQL semantics exactly, rather than approximately.
23//!
24//! # Example
25//!
26//! ```rust,no_run
27//! use quack_rs::datetime;
28//!
29//! // Inside a callback, given a DATE read as days-since-epoch:
30//! # let days = 0_i32;
31//! let date = unsafe { datetime::date_from_days(days) };
32//! assert_eq!((date.year, date.month, date.day), (1970, 1, 1));
33//!
34//! // …and back again.
35//! assert_eq!(unsafe { datetime::date_to_days(date) }, days);
36//! ```
37//!
38//! # Infinity
39//!
40//! `DuckDB` reserves two values of `DATE` and of `TIMESTAMP` for `infinity` and
41//! `-infinity`. Decomposing one of those into a calendar date is meaningless, so
42//! check with [`is_finite_date`] / [`is_finite_timestamp`] first, or compare
43//! against the constants below.
44//!
45//! Note the exact values: negative infinity is `-i32::MAX` / `-i64::MAX`, **not**
46//! `i32::MIN` / `i64::MIN`. `i32::MIN` is an ordinary (if absurd) finite date.
47
48use libduckdb_sys::{
49    duckdb_date, duckdb_date_struct, duckdb_decimal, duckdb_decimal_to_double,
50    duckdb_double_to_decimal, duckdb_double_to_hugeint, duckdb_double_to_uhugeint,
51    duckdb_from_date, duckdb_from_time, duckdb_from_time_tz, duckdb_from_timestamp, duckdb_hugeint,
52    duckdb_hugeint_to_double, duckdb_is_finite_date, duckdb_is_finite_timestamp,
53    duckdb_is_finite_timestamp_ms, duckdb_is_finite_timestamp_ns, duckdb_is_finite_timestamp_s,
54    duckdb_time, duckdb_time_struct, duckdb_time_tz, duckdb_timestamp, duckdb_timestamp_ms,
55    duckdb_timestamp_ns, duckdb_timestamp_s, duckdb_timestamp_struct, duckdb_to_date,
56    duckdb_to_time, duckdb_to_timestamp, duckdb_uhugeint, duckdb_uhugeint_to_double,
57};
58
59/// A calendar date, as `DuckDB` decomposes a `DATE`.
60///
61/// `month` is 1–12 and `day` is 1–31; `year` may be negative (BCE).
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
63pub struct Date {
64    /// Proleptic Gregorian year. Negative values are BCE.
65    pub year: i32,
66    /// Month of year, 1–12.
67    pub month: i8,
68    /// Day of month, 1–31.
69    pub day: i8,
70}
71
72/// A wall-clock time, as `DuckDB` decomposes a `TIME`.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
74pub struct Time {
75    /// Hour of day, 0–23.
76    pub hour: i8,
77    /// Minute of hour, 0–59.
78    pub min: i8,
79    /// Second of minute, 0–59.
80    pub sec: i8,
81    /// Microseconds within the second, 0–999999.
82    pub micros: i32,
83}
84
85/// A `TIME WITH TIME ZONE`, decomposed into wall-clock time plus UTC offset.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
87pub struct TimeTz {
88    /// The wall-clock time.
89    pub time: Time,
90    /// Offset from UTC in seconds.
91    pub offset_seconds: i32,
92}
93
94/// A `TIMESTAMP`, decomposed into date and time parts.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
96pub struct Timestamp {
97    /// The calendar date.
98    pub date: Date,
99    /// The wall-clock time.
100    pub time: Time,
101}
102
103impl From<duckdb_date_struct> for Date {
104    fn from(value: duckdb_date_struct) -> Self {
105        Self {
106            year: value.year,
107            month: value.month,
108            day: value.day,
109        }
110    }
111}
112
113impl From<Date> for duckdb_date_struct {
114    fn from(value: Date) -> Self {
115        Self {
116            year: value.year,
117            month: value.month,
118            day: value.day,
119        }
120    }
121}
122
123impl From<duckdb_time_struct> for Time {
124    fn from(value: duckdb_time_struct) -> Self {
125        Self {
126            hour: value.hour,
127            min: value.min,
128            sec: value.sec,
129            micros: value.micros,
130        }
131    }
132}
133
134impl From<Time> for duckdb_time_struct {
135    fn from(value: Time) -> Self {
136        Self {
137            hour: value.hour,
138            min: value.min,
139            sec: value.sec,
140            micros: value.micros,
141        }
142    }
143}
144
145/// The `DATE` value `DuckDB` uses for `infinity`, in days since 1970-01-01.
146///
147/// Matches `duckdb::date_t::infinity()`.
148pub const DATE_INFINITY_DAYS: i32 = i32::MAX;
149
150/// The `DATE` value `DuckDB` uses for `-infinity`, in days since 1970-01-01.
151///
152/// Matches `duckdb::date_t::ninfinity()`, which is `-i32::MAX` — one greater
153/// than `i32::MIN`, so `i32::MIN` itself is a finite date.
154pub const DATE_NEGATIVE_INFINITY_DAYS: i32 = -i32::MAX;
155
156/// The `TIMESTAMP` value `DuckDB` uses for `infinity`, in microseconds since the
157/// epoch.
158///
159/// Matches `duckdb::timestamp_t::infinity()`.
160pub const TIMESTAMP_INFINITY_MICROS: i64 = i64::MAX;
161
162/// The `TIMESTAMP` value `DuckDB` uses for `-infinity`, in microseconds since
163/// the epoch.
164///
165/// Matches `duckdb::timestamp_t::ninfinity()`, which is `-i64::MAX`.
166pub const TIMESTAMP_NEGATIVE_INFINITY_MICROS: i64 = -i64::MAX;
167
168// ─── DATE ────────────────────────────────────────────────────────────────────
169
170/// Decomposes a `DATE` (days since 1970-01-01) into a calendar date.
171///
172/// Check [`is_finite_date`] first: `DuckDB` reserves extreme values for
173/// `infinity` / `-infinity`, which have no calendar representation.
174///
175/// # Safety
176///
177/// The `DuckDB` C API dispatch table must be initialised — it always is inside a
178/// callback or a registration closure.
179#[must_use]
180pub unsafe fn date_from_days(days: i32) -> Date {
181    // SAFETY: forwarded from this function's own contract.
182    unsafe { duckdb_from_date(duckdb_date { days }) }.into()
183}
184
185/// Composes a calendar date into a `DATE` (days since 1970-01-01).
186///
187/// # Safety
188///
189/// See [`date_from_days`].
190#[must_use]
191pub unsafe fn date_to_days(date: Date) -> i32 {
192    // SAFETY: forwarded from this function's own contract.
193    unsafe { duckdb_to_date(date.into()) }.days
194}
195
196/// Returns `false` for `DuckDB`'s `infinity` / `-infinity` `DATE` sentinels.
197///
198/// # Safety
199///
200/// See [`date_from_days`].
201#[must_use]
202pub unsafe fn is_finite_date(days: i32) -> bool {
203    // SAFETY: forwarded from this function's own contract.
204    unsafe { duckdb_is_finite_date(duckdb_date { days }) }
205}
206
207// ─── TIME ────────────────────────────────────────────────────────────────────
208
209/// Decomposes a `TIME` (microseconds since midnight) into a wall-clock time.
210///
211/// # Safety
212///
213/// See [`date_from_days`].
214#[must_use]
215pub unsafe fn time_from_micros(micros: i64) -> Time {
216    // SAFETY: forwarded from this function's own contract.
217    unsafe { duckdb_from_time(duckdb_time { micros }) }.into()
218}
219
220/// Composes a wall-clock time into a `TIME` (microseconds since midnight).
221///
222/// # Safety
223///
224/// See [`date_from_days`].
225#[must_use]
226pub unsafe fn time_to_micros(time: Time) -> i64 {
227    // SAFETY: forwarded from this function's own contract.
228    unsafe { duckdb_to_time(time.into()) }.micros
229}
230
231/// Packs a wall-clock time and UTC offset into `DuckDB`'s `TIME WITH TIME ZONE`
232/// bit representation.
233///
234/// `offset_seconds` is the offset from UTC in seconds.
235///
236/// # Safety
237///
238/// See [`date_from_days`].
239#[must_use]
240pub unsafe fn time_tz_bits(micros_since_midnight: i64, offset_seconds: i32) -> u64 {
241    // SAFETY: forwarded from this function's own contract.
242    unsafe { libduckdb_sys::duckdb_create_time_tz(micros_since_midnight, offset_seconds) }.bits
243}
244
245/// Unpacks `DuckDB`'s `TIME WITH TIME ZONE` bit representation.
246///
247/// # Safety
248///
249/// See [`date_from_days`].
250#[must_use]
251pub unsafe fn time_tz_from_bits(bits: u64) -> TimeTz {
252    // SAFETY: forwarded from this function's own contract.
253    let raw = unsafe { duckdb_from_time_tz(duckdb_time_tz { bits }) };
254    TimeTz {
255        time: raw.time.into(),
256        offset_seconds: raw.offset,
257    }
258}
259
260// ─── TIMESTAMP ───────────────────────────────────────────────────────────────
261
262/// Decomposes a `TIMESTAMP` (microseconds since the epoch) into date and time.
263///
264/// Check [`is_finite_timestamp`] first.
265///
266/// # Safety
267///
268/// See [`date_from_days`].
269#[must_use]
270pub unsafe fn timestamp_from_micros(micros: i64) -> Timestamp {
271    // SAFETY: forwarded from this function's own contract.
272    let raw: duckdb_timestamp_struct =
273        unsafe { duckdb_from_timestamp(duckdb_timestamp { micros }) };
274    Timestamp {
275        date: raw.date.into(),
276        time: raw.time.into(),
277    }
278}
279
280/// Composes date and time into a `TIMESTAMP` (microseconds since the epoch).
281///
282/// # Safety
283///
284/// See [`date_from_days`].
285#[must_use]
286pub unsafe fn timestamp_to_micros(timestamp: Timestamp) -> i64 {
287    let raw = duckdb_timestamp_struct {
288        date: timestamp.date.into(),
289        time: timestamp.time.into(),
290    };
291    // SAFETY: forwarded from this function's own contract.
292    unsafe { duckdb_to_timestamp(raw) }.micros
293}
294
295/// Returns `false` for `DuckDB`'s `infinity` / `-infinity` `TIMESTAMP`
296/// sentinels.
297///
298/// # Safety
299///
300/// See [`date_from_days`].
301#[must_use]
302pub unsafe fn is_finite_timestamp(micros: i64) -> bool {
303    // SAFETY: forwarded from this function's own contract.
304    unsafe { duckdb_is_finite_timestamp(duckdb_timestamp { micros }) }
305}
306
307/// `TIMESTAMP_S` variant of [`is_finite_timestamp`].
308///
309/// # Safety
310///
311/// See [`date_from_days`].
312#[must_use]
313pub unsafe fn is_finite_timestamp_s(seconds: i64) -> bool {
314    // SAFETY: forwarded from this function's own contract.
315    unsafe { duckdb_is_finite_timestamp_s(duckdb_timestamp_s { seconds }) }
316}
317
318/// `TIMESTAMP_MS` variant of [`is_finite_timestamp`].
319///
320/// # Safety
321///
322/// See [`date_from_days`].
323#[must_use]
324pub unsafe fn is_finite_timestamp_ms(millis: i64) -> bool {
325    // SAFETY: forwarded from this function's own contract.
326    unsafe { duckdb_is_finite_timestamp_ms(duckdb_timestamp_ms { millis }) }
327}
328
329/// `TIMESTAMP_NS` variant of [`is_finite_timestamp`].
330///
331/// # Safety
332///
333/// See [`date_from_days`].
334#[must_use]
335pub unsafe fn is_finite_timestamp_ns(nanos: i64) -> bool {
336    // SAFETY: forwarded from this function's own contract.
337    unsafe { duckdb_is_finite_timestamp_ns(duckdb_timestamp_ns { nanos }) }
338}
339
340// ─── Wide integers and DECIMAL ───────────────────────────────────────────────
341
342/// Converts a `HUGEINT` to `f64` the way `DuckDB` does.
343///
344/// # Safety
345///
346/// See [`date_from_days`].
347#[must_use]
348pub unsafe fn hugeint_to_f64(value: i128) -> f64 {
349    let raw = duckdb_hugeint {
350        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
351        lower: value as u64,
352        #[allow(clippy::cast_possible_truncation)]
353        upper: (value >> 64) as i64,
354    };
355    // SAFETY: forwarded from this function's own contract.
356    unsafe { duckdb_hugeint_to_double(raw) }
357}
358
359/// Converts an `f64` to `HUGEINT` the way `DuckDB` does.
360///
361/// # Safety
362///
363/// See [`date_from_days`].
364#[must_use]
365pub unsafe fn f64_to_hugeint(value: f64) -> i128 {
366    // SAFETY: forwarded from this function's own contract.
367    let raw = unsafe { duckdb_double_to_hugeint(value) };
368    (i128::from(raw.upper) << 64) | i128::from(raw.lower)
369}
370
371/// Converts a `UHUGEINT` to `f64` the way `DuckDB` does.
372///
373/// # Safety
374///
375/// See [`date_from_days`].
376#[must_use]
377pub unsafe fn uhugeint_to_f64(value: u128) -> f64 {
378    let raw = duckdb_uhugeint {
379        #[allow(clippy::cast_possible_truncation)]
380        lower: value as u64,
381        #[allow(clippy::cast_possible_truncation)]
382        upper: (value >> 64) as u64,
383    };
384    // SAFETY: forwarded from this function's own contract.
385    unsafe { duckdb_uhugeint_to_double(raw) }
386}
387
388/// Converts an `f64` to `UHUGEINT` the way `DuckDB` does.
389///
390/// # Safety
391///
392/// See [`date_from_days`].
393#[must_use]
394pub unsafe fn f64_to_uhugeint(value: f64) -> u128 {
395    // SAFETY: forwarded from this function's own contract.
396    let raw = unsafe { duckdb_double_to_uhugeint(value) };
397    (u128::from(raw.upper) << 64) | u128::from(raw.lower)
398}
399
400/// A `DECIMAL` value: an unscaled `i128` plus its declared width and scale.
401#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
402pub struct Decimal {
403    /// Total number of significant digits.
404    pub width: u8,
405    /// Number of digits after the decimal point.
406    pub scale: u8,
407    /// The unscaled value: the represented number is `value / 10^scale`.
408    pub value: i128,
409}
410
411/// Converts an `f64` into a `DECIMAL` of the given width and scale.
412///
413/// # Safety
414///
415/// See [`date_from_days`].
416#[must_use]
417pub unsafe fn f64_to_decimal(value: f64, width: u8, scale: u8) -> Decimal {
418    // SAFETY: forwarded from this function's own contract.
419    let raw = unsafe { duckdb_double_to_decimal(value, width, scale) };
420    Decimal {
421        width: raw.width,
422        scale: raw.scale,
423        value: (i128::from(raw.value.upper) << 64) | i128::from(raw.value.lower),
424    }
425}
426
427/// Converts a `DECIMAL` to `f64` the way `DuckDB` does.
428///
429/// # Safety
430///
431/// See [`date_from_days`].
432#[must_use]
433pub unsafe fn decimal_to_f64(decimal: Decimal) -> f64 {
434    let raw = duckdb_decimal {
435        width: decimal.width,
436        scale: decimal.scale,
437        value: duckdb_hugeint {
438            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
439            lower: decimal.value as u64,
440            #[allow(clippy::cast_possible_truncation)]
441            upper: (decimal.value >> 64) as i64,
442        },
443    };
444    // SAFETY: forwarded from this function's own contract.
445    unsafe { duckdb_decimal_to_double(raw) }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn date_struct_round_trips_through_ffi_types() {
454        let date = Date {
455            year: 2026,
456            month: 8,
457            day: 18,
458        };
459        let raw: duckdb_date_struct = date.into();
460        assert_eq!(raw.year, 2026);
461        assert_eq!(raw.month, 8);
462        assert_eq!(raw.day, 18);
463        assert_eq!(Date::from(raw), date);
464    }
465
466    #[test]
467    fn time_struct_round_trips_through_ffi_types() {
468        let time = Time {
469            hour: 23,
470            min: 59,
471            sec: 58,
472            micros: 123_456,
473        };
474        let raw: duckdb_time_struct = time.into();
475        assert_eq!(Time::from(raw), time);
476    }
477
478    #[test]
479    fn decimal_is_ordered_and_hashable() {
480        use std::collections::HashSet;
481        let a = Decimal {
482            width: 18,
483            scale: 3,
484            value: 1_500,
485        };
486        let b = Decimal {
487            width: 18,
488            scale: 3,
489            value: 2_500,
490        };
491        assert!(a < b);
492        let set: HashSet<Decimal> = [a, b, a].into_iter().collect();
493        assert_eq!(set.len(), 2);
494    }
495}
496
497/// Conversions checked against a live `DuckDB`.
498#[cfg(all(test, feature = "_duckdb-testing"))]
499mod live_tests {
500    use super::*;
501    use crate::testing::InMemoryDb;
502
503    #[test]
504    fn epoch_day_zero_is_1970_01_01() {
505        let _db = InMemoryDb::open().expect("open in-memory DuckDB");
506        // SAFETY: InMemoryDb::open() initialised the dispatch table.
507        let date = unsafe { date_from_days(0) };
508        assert_eq!(
509            date,
510            Date {
511                year: 1970,
512                month: 1,
513                day: 1
514            }
515        );
516    }
517
518    #[test]
519    fn date_round_trips_across_leap_years_and_bce() {
520        let _db = InMemoryDb::open().expect("open in-memory DuckDB");
521        for days in [
522            -1_000_000_i32,
523            -719_162, // 0001-01-01
524            -1,
525            0,
526            1,
527            59,     // 1970-03-01
528            10_957, // 2000-01-01
529            11_017, // 2000-03-01, just past a leap day
530            20_685, // 2026-08-18
531            1_000_000,
532        ] {
533            // SAFETY: InMemoryDb::open() initialised the dispatch table.
534            let date = unsafe { date_from_days(days) };
535            assert_eq!(unsafe { date_to_days(date) }, days, "round trip for {days}");
536        }
537    }
538
539    #[test]
540    fn duckdb_agrees_with_our_conversion() {
541        // Cross-check against DuckDB's SQL layer rather than trusting the C API
542        // wrapper in isolation.
543        let db = InMemoryDb::open().expect("open in-memory DuckDB");
544        for days in [0_i32, 20_685, -719_162] {
545            // `INTERVAL {n} DAY` will not parse a negative literal, so add the
546            // interval as an expression instead.
547            let sql =
548                format!("SELECT strftime(DATE '1970-01-01' + INTERVAL ({days}) DAY, '%Y-%m-%d')");
549            let expected: String = db.query_one(&sql).expect("query");
550            // SAFETY: InMemoryDb::open() initialised the dispatch table.
551            let date = unsafe { date_from_days(days) };
552            let actual = format!("{:04}-{:02}-{:02}", date.year, date.month, date.day);
553            assert_eq!(actual, expected, "for {days} days since the epoch");
554        }
555    }
556
557    #[test]
558    fn infinity_sentinels_match_the_documented_constants() {
559        let _db = InMemoryDb::open().expect("open in-memory DuckDB");
560        // SAFETY: InMemoryDb::open() initialised the dispatch table.
561        unsafe {
562            assert!(is_finite_date(0));
563            assert!(!is_finite_date(DATE_INFINITY_DAYS));
564            assert!(!is_finite_date(DATE_NEGATIVE_INFINITY_DAYS));
565            // -infinity is -i32::MAX, so i32::MIN is one step beyond it and is a
566            // finite (if nonsensical) date. Getting this backwards would make a
567            // caller treat a real date as infinity.
568            assert!(is_finite_date(i32::MIN));
569
570            assert!(is_finite_timestamp(0));
571            assert!(!is_finite_timestamp(TIMESTAMP_INFINITY_MICROS));
572            assert!(!is_finite_timestamp(TIMESTAMP_NEGATIVE_INFINITY_MICROS));
573            assert!(is_finite_timestamp(i64::MIN));
574
575            assert!(is_finite_timestamp_s(0));
576            assert!(is_finite_timestamp_ms(0));
577            assert!(is_finite_timestamp_ns(0));
578            assert!(!is_finite_timestamp_s(TIMESTAMP_INFINITY_MICROS));
579            assert!(!is_finite_timestamp_ms(TIMESTAMP_INFINITY_MICROS));
580            assert!(!is_finite_timestamp_ns(TIMESTAMP_INFINITY_MICROS));
581        }
582    }
583
584    #[test]
585    fn duckdb_sql_agrees_that_the_sentinels_are_infinite() {
586        let db = InMemoryDb::open().expect("open in-memory DuckDB");
587        let rendered: String = db
588            .query_one("SELECT ('infinity'::DATE)::VARCHAR")
589            .expect("query");
590        assert_eq!(rendered, "infinity");
591        // SAFETY: InMemoryDb::open() initialised the dispatch table.
592        assert!(!unsafe { is_finite_date(DATE_INFINITY_DAYS) });
593    }
594
595    #[test]
596    fn time_round_trips_including_microsecond_precision() {
597        let _db = InMemoryDb::open().expect("open in-memory DuckDB");
598        for micros in [0_i64, 1, 999_999, 1_000_000, 86_399_999_999] {
599            // SAFETY: InMemoryDb::open() initialised the dispatch table.
600            let time = unsafe { time_from_micros(micros) };
601            assert_eq!(unsafe { time_to_micros(time) }, micros, "for {micros} us");
602        }
603        // SAFETY: dispatch table initialised above.
604        let end_of_day = unsafe { time_from_micros(86_399_999_999) };
605        assert_eq!(
606            end_of_day,
607            Time {
608                hour: 23,
609                min: 59,
610                sec: 59,
611                micros: 999_999
612            }
613        );
614    }
615
616    #[test]
617    fn timestamp_round_trips() {
618        let _db = InMemoryDb::open().expect("open in-memory DuckDB");
619        for micros in [0_i64, 1, -1, 1_700_000_000_000_000, -1_700_000_000_000_000] {
620            // SAFETY: InMemoryDb::open() initialised the dispatch table.
621            let ts = unsafe { timestamp_from_micros(micros) };
622            assert_eq!(
623                unsafe { timestamp_to_micros(ts) },
624                micros,
625                "for {micros} us"
626            );
627        }
628    }
629
630    #[test]
631    fn time_tz_round_trips_with_offset() {
632        let _db = InMemoryDb::open().expect("open in-memory DuckDB");
633        // SAFETY: InMemoryDb::open() initialised the dispatch table.
634        unsafe {
635            let bits = time_tz_bits(12 * 3_600 * 1_000_000, -5 * 3_600);
636            let decoded = time_tz_from_bits(bits);
637            assert_eq!(decoded.time.hour, 12);
638            assert_eq!(decoded.offset_seconds, -5 * 3_600);
639        }
640    }
641
642    #[test]
643    fn hugeint_conversions_match_duckdb() {
644        let db = InMemoryDb::open().expect("open in-memory DuckDB");
645        // SAFETY: InMemoryDb::open() initialised the dispatch table.
646        unsafe {
647            assert!((hugeint_to_f64(0) - 0.0).abs() < f64::EPSILON);
648            assert!((hugeint_to_f64(1) - 1.0).abs() < f64::EPSILON);
649            assert!((hugeint_to_f64(-1) + 1.0).abs() < f64::EPSILON);
650            assert_eq!(f64_to_hugeint(42.0), 42);
651            assert_eq!(f64_to_hugeint(-42.0), -42);
652            assert_eq!(f64_to_uhugeint(42.0), 42);
653            assert!((uhugeint_to_f64(u128::from(u64::MAX)) - 1.844_674_407_370_955e19).abs() < 1e6);
654        }
655        // Cross-check the sign handling of the split representation against SQL.
656        let expected: f64 = db
657            .query_one("SELECT (-170141183460469231731687303715884105728)::HUGEINT::DOUBLE")
658            .expect("query");
659        // SAFETY: dispatch table initialised above.
660        let actual = unsafe { hugeint_to_f64(i128::MIN) };
661        assert!(
662            (actual - expected).abs() / expected.abs() < 1e-12,
663            "{actual} != {expected}"
664        );
665    }
666
667    #[test]
668    fn decimal_conversions_preserve_width_and_scale() {
669        let _db = InMemoryDb::open().expect("open in-memory DuckDB");
670        // SAFETY: InMemoryDb::open() initialised the dispatch table.
671        unsafe {
672            let decimal = f64_to_decimal(12.345, 18, 3);
673            assert_eq!(decimal.width, 18);
674            assert_eq!(decimal.scale, 3);
675            assert_eq!(decimal.value, 12_345);
676            assert!((decimal_to_f64(decimal) - 12.345).abs() < 1e-9);
677
678            let negative = f64_to_decimal(-12.345, 18, 3);
679            assert_eq!(negative.value, -12_345);
680            assert!((decimal_to_f64(negative) + 12.345).abs() < 1e-9);
681        }
682    }
683}