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}