Skip to main content

quack_rs/
interval.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//! `DuckDB` `INTERVAL` type conversion utilities.
7//!
8//! A `DuckDB` `INTERVAL` is a 16-byte struct with three fields:
9//! ```text
10//! { months: i32, days: i32, micros: i64 }
11//! ```
12//!
13//! Converting to a uniform unit (microseconds) requires careful arithmetic to
14//! avoid integer overflow. This module provides checked and saturating conversions.
15//!
16//! # Pitfall P8: Undocumented INTERVAL layout
17//!
18//! The `duckdb_string_t` and `INTERVAL` struct layouts are not documented in the
19//! Rust bindings (`libduckdb-sys`). They must be inferred from `DuckDB`'s C headers.
20//! This module encodes that knowledge so extension authors never need to look it up.
21//!
22//! # Example
23//!
24//! ```rust
25//! use quack_rs::interval::{interval_to_micros, DuckInterval};
26//!
27//! let iv = DuckInterval { months: 1, days: 0, micros: 0 };
28//! // 1 month ≈ 30 days = 2_592_000_000_000 microseconds
29//! assert_eq!(interval_to_micros(iv), Some(2_592_000_000_000_i64));
30//! ```
31
32/// Microseconds per day, used for interval conversion.
33pub const MICROS_PER_DAY: i64 = 86_400 * 1_000_000;
34
35/// Microseconds per month (approximated as 30 days, matching `DuckDB`'s behaviour).
36pub const MICROS_PER_MONTH: i64 = 30 * MICROS_PER_DAY;
37
38/// A `DuckDB` `INTERVAL` value, matching the C struct layout exactly.
39///
40/// # Memory layout
41///
42/// ```text
43/// offset 0:  months (i32)  — number of calendar months
44/// offset 4:  days   (i32)  — number of calendar days
45/// offset 8:  micros (i64)  — microseconds component
46/// total:     16 bytes
47/// ```
48///
49/// # Safety
50///
51/// This struct must remain `#[repr(C)]` with the exact field order above,
52/// matching `DuckDB`'s `duckdb_interval` C struct.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54#[repr(C)]
55pub struct DuckInterval {
56    /// Calendar months component.
57    pub months: i32,
58    /// Calendar days component.
59    pub days: i32,
60    /// Sub-day microseconds component.
61    pub micros: i64,
62}
63
64impl DuckInterval {
65    /// Returns a zero-valued interval (0 months, 0 days, 0 microseconds).
66    #[inline]
67    #[must_use]
68    pub const fn zero() -> Self {
69        Self {
70            months: 0,
71            days: 0,
72            micros: 0,
73        }
74    }
75
76    /// Converts this interval to total microseconds with overflow checking.
77    ///
78    /// Returns `None` if the result would overflow `i64`.
79    ///
80    /// Month conversion uses 30 days/month, matching `DuckDB`'s approximation.
81    ///
82    /// # Example
83    ///
84    /// ```rust
85    /// use quack_rs::interval::DuckInterval;
86    ///
87    /// let iv = DuckInterval { months: 0, days: 1, micros: 500_000 };
88    /// assert_eq!(iv.to_micros(), Some(86_400_500_000_i64));
89    /// ```
90    #[inline]
91    #[must_use]
92    pub fn to_micros(self) -> Option<i64> {
93        interval_to_micros(self)
94    }
95
96    /// Converts this interval to total microseconds, saturating on overflow.
97    ///
98    /// # Example
99    ///
100    /// ```rust
101    /// use quack_rs::interval::DuckInterval;
102    ///
103    /// let iv = DuckInterval { months: i32::MAX, days: i32::MAX, micros: i64::MAX };
104    /// assert_eq!(iv.to_micros_saturating(), i64::MAX);
105    /// ```
106    #[inline]
107    #[must_use]
108    pub fn to_micros_saturating(self) -> i64 {
109        interval_to_micros_saturating(self)
110    }
111}
112
113impl Default for DuckInterval {
114    #[inline]
115    fn default() -> Self {
116        Self::zero()
117    }
118}
119
120/// Converts a [`DuckInterval`] to total microseconds with overflow checking.
121///
122/// Uses the approximation: **1 month = 30 days**, which is what `DuckDB` uses
123/// internally when comparing or arithmetically combining intervals.
124///
125/// # Returns
126///
127/// `None` if any intermediate multiplication or addition overflows `i64`.
128///
129/// # Example
130///
131/// ```rust
132/// use quack_rs::interval::{interval_to_micros, DuckInterval};
133///
134/// // 2 hours 30 minutes = 9_000_000_000 microseconds
135/// let iv = DuckInterval { months: 0, days: 0, micros: 9_000_000_000 };
136/// assert_eq!(interval_to_micros(iv), Some(9_000_000_000_i64));
137///
138/// // 1 day
139/// let iv = DuckInterval { months: 0, days: 1, micros: 0 };
140/// assert_eq!(interval_to_micros(iv), Some(86_400_000_000_i64));
141///
142/// // Overflow returns None
143/// let iv = DuckInterval { months: i32::MAX, days: i32::MAX, micros: i64::MAX };
144/// assert_eq!(interval_to_micros(iv), None);
145/// ```
146#[inline]
147pub fn interval_to_micros(iv: DuckInterval) -> Option<i64> {
148    let months_us = i64::from(iv.months).checked_mul(MICROS_PER_MONTH)?;
149    let days_us = i64::from(iv.days).checked_mul(MICROS_PER_DAY)?;
150    months_us.checked_add(days_us)?.checked_add(iv.micros)
151}
152
153/// Converts a [`DuckInterval`] to total microseconds, saturating on overflow.
154///
155/// Uses the approximation: **1 month = 30 days**.
156///
157/// # Example
158///
159/// ```rust
160/// use quack_rs::interval::{interval_to_micros_saturating, DuckInterval};
161///
162/// let iv = DuckInterval { months: 0, days: 0, micros: 1_000_000 };
163/// assert_eq!(interval_to_micros_saturating(iv), 1_000_000_i64);
164/// ```
165#[inline]
166pub fn interval_to_micros_saturating(iv: DuckInterval) -> i64 {
167    interval_to_micros(iv).unwrap_or_else(|| {
168        // Determine sign of the true (overflowed) result using i128 arithmetic.
169        // This correctly handles mixed-sign cases where some components are
170        // positive and others are negative.
171        let months_us = i128::from(iv.months) * i128::from(MICROS_PER_MONTH);
172        let days_us = i128::from(iv.days) * i128::from(MICROS_PER_DAY);
173        let total = months_us + days_us + i128::from(iv.micros);
174        if total >= 0 {
175            i64::MAX
176        } else {
177            i64::MIN
178        }
179    })
180}
181
182/// Reads a [`DuckInterval`] from a raw `DuckDB` vector data pointer at a given row index.
183///
184/// # Safety
185///
186/// - `data` must be a valid pointer to a `DuckDB` vector's data buffer containing
187///   `INTERVAL` values (16 bytes each).
188/// - `idx` must be within bounds of the vector.
189///
190/// # Pitfall P8
191///
192/// The `INTERVAL` struct is 16 bytes: `{ months: i32, days: i32, micros: i64 }`.
193/// This layout matches `duckdb_interval` in `DuckDB`'s C headers.
194///
195/// # Example
196///
197/// ```rust
198/// use quack_rs::interval::{read_interval_at, DuckInterval};
199///
200/// let ivs = [DuckInterval { months: 2, days: 15, micros: 1_000 }];
201/// let data = ivs.as_ptr() as *const u8;
202/// let read = unsafe { read_interval_at(data, 0) };
203/// assert_eq!(read.months, 2);
204/// assert_eq!(read.days, 15);
205/// assert_eq!(read.micros, 1_000);
206/// ```
207#[inline]
208pub const unsafe fn read_interval_at(data: *const u8, idx: usize) -> DuckInterval {
209    // SAFETY: Each INTERVAL is exactly 16 bytes (repr(C) struct with i32, i32, i64).
210    // The caller guarantees `data` points to valid INTERVAL data and `idx` is in bounds.
211    let ptr = unsafe { data.add(idx * 16) };
212    let months = unsafe { core::ptr::read_unaligned(ptr.cast::<i32>()) };
213    let days = unsafe { core::ptr::read_unaligned(ptr.add(4).cast::<i32>()) };
214    let micros = unsafe { core::ptr::read_unaligned(ptr.add(8).cast::<i64>()) };
215    DuckInterval {
216        months,
217        days,
218        micros,
219    }
220}
221
222/// Asserts the size and alignment of [`DuckInterval`] match `DuckDB`'s C struct.
223const _: () = {
224    assert!(
225        core::mem::size_of::<DuckInterval>() == 16,
226        "DuckInterval must be exactly 16 bytes"
227    );
228    assert!(
229        core::mem::align_of::<DuckInterval>() >= 4,
230        "DuckInterval must have at least 4-byte alignment"
231    );
232};
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn size_of_duck_interval() {
240        assert_eq!(core::mem::size_of::<DuckInterval>(), 16);
241    }
242
243    #[test]
244    fn zero_interval() {
245        let iv = DuckInterval::zero();
246        assert_eq!(interval_to_micros(iv), Some(0));
247    }
248
249    #[test]
250    fn default_interval() {
251        let iv = DuckInterval::default();
252        assert_eq!(iv, DuckInterval::zero());
253    }
254
255    #[test]
256    fn one_day() {
257        let iv = DuckInterval {
258            months: 0,
259            days: 1,
260            micros: 0,
261        };
262        assert_eq!(interval_to_micros(iv), Some(MICROS_PER_DAY));
263    }
264
265    #[test]
266    fn one_month() {
267        let iv = DuckInterval {
268            months: 1,
269            days: 0,
270            micros: 0,
271        };
272        assert_eq!(interval_to_micros(iv), Some(MICROS_PER_MONTH));
273    }
274
275    #[test]
276    fn combined_interval() {
277        let iv = DuckInterval {
278            months: 0,
279            days: 1,
280            micros: 500_000,
281        };
282        let expected = MICROS_PER_DAY + 500_000;
283        assert_eq!(interval_to_micros(iv), Some(expected));
284    }
285
286    #[test]
287    fn negative_interval() {
288        let iv = DuckInterval {
289            months: -1,
290            days: 0,
291            micros: 0,
292        };
293        assert_eq!(interval_to_micros(iv), Some(-MICROS_PER_MONTH));
294    }
295
296    #[test]
297    fn overflow_returns_none() {
298        let iv = DuckInterval {
299            months: i32::MAX,
300            days: i32::MAX,
301            micros: i64::MAX,
302        };
303        assert_eq!(interval_to_micros(iv), None);
304    }
305
306    #[test]
307    fn saturating_on_overflow() {
308        let iv = DuckInterval {
309            months: i32::MAX,
310            days: i32::MAX,
311            micros: i64::MAX,
312        };
313        assert_eq!(interval_to_micros_saturating(iv), i64::MAX);
314    }
315
316    #[test]
317    fn saturating_no_overflow() {
318        let iv = DuckInterval {
319            months: 0,
320            days: 0,
321            micros: 42,
322        };
323        assert_eq!(interval_to_micros_saturating(iv), 42);
324    }
325
326    #[test]
327    fn to_micros_method() {
328        let iv = DuckInterval {
329            months: 0,
330            days: 0,
331            micros: 12345,
332        };
333        assert_eq!(iv.to_micros(), Some(12345));
334    }
335
336    #[test]
337    fn to_micros_saturating_method() {
338        let iv = DuckInterval {
339            months: 0,
340            days: 0,
341            micros: 12345,
342        };
343        assert_eq!(iv.to_micros_saturating(), 12345);
344    }
345
346    #[test]
347    fn read_interval_at_basic() {
348        let data = [
349            DuckInterval {
350                months: 2,
351                days: 15,
352                micros: 999_000,
353            },
354            DuckInterval {
355                months: -1,
356                days: 3,
357                micros: 0,
358            },
359        ];
360        // SAFETY: data is a valid array of DuckInterval, idx 0 and 1 are in bounds.
361        let iv0 = unsafe { read_interval_at(data.as_ptr().cast::<u8>(), 0) };
362        assert_eq!(iv0.months, 2);
363        assert_eq!(iv0.days, 15);
364        assert_eq!(iv0.micros, 999_000);
365
366        let iv1 = unsafe { read_interval_at(data.as_ptr().cast::<u8>(), 1) };
367        assert_eq!(iv1.months, -1);
368        assert_eq!(iv1.days, 3);
369        assert_eq!(iv1.micros, 0);
370    }
371
372    #[test]
373    fn exactly_max_i64_micros_no_overflow() {
374        // If all overflow is in micros only (months=0, days=0), no overflow
375        let iv = DuckInterval {
376            months: 0,
377            days: 0,
378            micros: i64::MAX,
379        };
380        assert_eq!(interval_to_micros(iv), Some(i64::MAX));
381    }
382
383    #[test]
384    fn months_calculation() {
385        // 12 months = 12 * 30 days * 86400 * 1_000_000 us
386        let iv = DuckInterval {
387            months: 12,
388            days: 0,
389            micros: 0,
390        };
391        let expected = 12_i64 * MICROS_PER_MONTH;
392        assert_eq!(interval_to_micros(iv), Some(expected));
393    }
394
395    // Mixed-sign overflow saturation tests (CRIT-5 regression tests)
396
397    #[test]
398    fn saturating_positive_overflow_with_negative_days() {
399        // months = i32::MAX overflows to massive positive; days = -1 is tiny negative.
400        // True result is still massively positive → should saturate to i64::MAX.
401        let iv = DuckInterval {
402            months: i32::MAX,
403            days: -1,
404            micros: 0,
405        };
406        assert_eq!(interval_to_micros(iv), None); // confirm it overflows
407        assert_eq!(interval_to_micros_saturating(iv), i64::MAX);
408    }
409
410    #[test]
411    fn saturating_negative_overflow_with_positive_days() {
412        // months = i32::MIN overflows to massive negative; days = 1 is tiny positive.
413        // True result is still massively negative → should saturate to i64::MIN.
414        let iv = DuckInterval {
415            months: i32::MIN,
416            days: 1,
417            micros: 0,
418        };
419        assert_eq!(interval_to_micros(iv), None);
420        assert_eq!(interval_to_micros_saturating(iv), i64::MIN);
421    }
422
423    #[test]
424    fn saturating_positive_overflow_negative_micros() {
425        // Months alone overflow positive; negative micros doesn't change sign.
426        let iv = DuckInterval {
427            months: i32::MAX,
428            days: 0,
429            micros: -1_000_000,
430        };
431        assert_eq!(interval_to_micros(iv), None);
432        assert_eq!(interval_to_micros_saturating(iv), i64::MAX);
433    }
434
435    #[test]
436    fn saturating_negative_overflow_all_negative() {
437        let iv = DuckInterval {
438            months: i32::MIN,
439            days: i32::MIN,
440            micros: i64::MIN,
441        };
442        assert_eq!(interval_to_micros(iv), None);
443        assert_eq!(interval_to_micros_saturating(iv), i64::MIN);
444    }
445
446    mod proptest_interval {
447        use super::*;
448        use proptest::prelude::*;
449
450        proptest! {
451            #[test]
452            fn micros_only_never_overflows_within_i64(micros: i64) {
453                let iv = DuckInterval { months: 0, days: 0, micros };
454                // micros-only interval always succeeds (no multiplication needed)
455                assert_eq!(interval_to_micros(iv), Some(micros));
456            }
457
458            #[test]
459            fn saturating_never_panics(months: i32, days: i32, micros: i64) {
460                let iv = DuckInterval { months, days, micros };
461                // Must not panic for any input
462                let _ = interval_to_micros_saturating(iv);
463            }
464
465            #[test]
466            fn saturating_direction_matches_i128(months: i32, days: i32, micros: i64) {
467                let iv = DuckInterval { months, days, micros };
468                let sat = interval_to_micros_saturating(iv);
469                if interval_to_micros(iv).is_none() {
470                    // Verify saturation direction using i128 ground truth
471                    let total = i128::from(months) * i128::from(MICROS_PER_MONTH)
472                        + i128::from(days) * i128::from(MICROS_PER_DAY)
473                        + i128::from(micros);
474                    if total >= 0 {
475                        prop_assert_eq!(sat, i64::MAX);
476                    } else {
477                        prop_assert_eq!(sat, i64::MIN);
478                    }
479                }
480            }
481
482            #[test]
483            fn checked_and_saturating_agree_when_no_overflow(months in -100_i32..=100_i32, days in -100_i32..=100_i32, micros in -1_000_000_i64..=1_000_000_i64) {
484                let iv = DuckInterval { months, days, micros };
485                if let Some(checked) = interval_to_micros(iv) {
486                    assert_eq!(interval_to_micros_saturating(iv), checked);
487                }
488            }
489        }
490    }
491}