Skip to main content

quack_rs/vector/
reader.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//! Safe typed reading from `DuckDB` data vectors.
7//!
8//! [`VectorReader`] provides safe access to the typed data in a `DuckDB` vector
9//! without requiring direct raw pointer manipulation.
10//!
11//! # Pitfalls solved
12//!
13//! - **L5**: Booleans are read as `u8 != 0`, never as `bool`, because `DuckDB`'s
14//!   C API does not guarantee the Rust `bool` invariant (must be 0 or 1).
15//!
16//! # Example
17//!
18//! ```rust,no_run
19//! use quack_rs::vector::VectorReader;
20//! use libduckdb_sys::{duckdb_data_chunk, duckdb_data_chunk_get_vector,
21//!                     duckdb_data_chunk_get_size};
22//!
23//! // Inside a DuckDB aggregate `update` callback:
24//! // let reader = unsafe { VectorReader::new(chunk, 0) };
25//! // for row in 0..reader.row_count() {
26//! //     if reader.is_valid(row) {
27//! //         let val = unsafe { reader.read_i64(row) };
28//! //     }
29//! // }
30//! ```
31
32use libduckdb_sys::{
33    duckdb_data_chunk, duckdb_data_chunk_get_size, duckdb_data_chunk_get_vector,
34    duckdb_validity_row_is_valid, duckdb_vector, duckdb_vector_get_data,
35    duckdb_vector_get_validity, idx_t,
36};
37
38/// A typed reader for a single column in a `DuckDB` data chunk.
39///
40/// `VectorReader` wraps a pointer to a `DuckDB` vector's data buffer and
41/// provides ergonomic, type-checked access methods for common `DuckDB` types.
42///
43/// # Lifetimes
44///
45/// The reader borrows from the data chunk. Do not call `duckdb_destroy_data_chunk`
46/// while a `VectorReader` that references it is live.
47#[derive(Debug)]
48pub struct VectorReader {
49    data: *const u8,
50    validity: *mut u64,
51    row_count: usize,
52}
53
54impl VectorReader {
55    /// Creates a new `VectorReader` for the given column in a data chunk.
56    ///
57    /// # Safety
58    ///
59    /// - `chunk` must be a valid `duckdb_data_chunk` for the duration of this reader's lifetime.
60    /// - `col_idx` must be a valid column index in the chunk.
61    pub unsafe fn new(chunk: duckdb_data_chunk, col_idx: usize) -> Self {
62        // SAFETY: Caller guarantees chunk is valid.
63        let row_count = usize::try_from(unsafe { duckdb_data_chunk_get_size(chunk) }).unwrap_or(0);
64        // SAFETY: col_idx is valid per caller's contract.
65        let vector = unsafe { duckdb_data_chunk_get_vector(chunk, col_idx as idx_t) };
66        // SAFETY: vector is non-null for valid column indices.
67        let data = unsafe { duckdb_vector_get_data(vector) }.cast::<u8>();
68        // SAFETY: may be null if all values are valid (no NULLs); checked in is_valid.
69        let validity = unsafe { duckdb_vector_get_validity(vector) };
70        Self {
71            data,
72            validity,
73            row_count,
74        }
75    }
76
77    /// Creates a `VectorReader` directly from a raw `duckdb_vector` handle.
78    ///
79    /// Use this when you already have a child vector (e.g., from
80    /// [`StructVector::get_child`][crate::vector::complex::StructVector::get_child] or
81    /// [`ListVector::get_child`][crate::vector::complex::ListVector::get_child]).
82    ///
83    /// # Safety
84    ///
85    /// - `vector` must be a valid `duckdb_vector` for the duration of this reader's lifetime.
86    /// - `row_count` must equal the number of valid rows in the vector.
87    pub unsafe fn from_vector(vector: duckdb_vector, row_count: usize) -> Self {
88        // SAFETY: vector is valid per caller's contract.
89        let data = unsafe { duckdb_vector_get_data(vector) }.cast::<u8>();
90        let validity = unsafe { duckdb_vector_get_validity(vector) };
91        Self {
92            data,
93            validity,
94            row_count,
95        }
96    }
97
98    /// Returns the number of rows in this vector.
99    #[mutants::skip]
100    #[must_use]
101    #[inline]
102    pub const fn row_count(&self) -> usize {
103        self.row_count
104    }
105
106    /// Returns `true` if the value at row `idx` is not NULL.
107    ///
108    /// # Safety
109    ///
110    /// `idx` must be less than `self.row_count()`.
111    #[inline]
112    pub unsafe fn is_valid(&self, idx: usize) -> bool {
113        if self.validity.is_null() {
114            return true;
115        }
116        // SAFETY: validity is non-null and idx is in bounds per caller's contract.
117        unsafe { duckdb_validity_row_is_valid(self.validity, idx as idx_t) }
118    }
119
120    /// Reads an `i8` (TINYINT) value at row `idx`.
121    ///
122    /// # Safety
123    ///
124    /// - `idx` must be less than `self.row_count()`.
125    /// - The column must contain `TINYINT` data.
126    /// - The value at `idx` must not be NULL (check with [`is_valid`][Self::is_valid]).
127    #[inline]
128    pub const unsafe fn read_i8(&self, idx: usize) -> i8 {
129        // SAFETY: data points to valid TINYINT array, idx is in bounds.
130        unsafe { core::ptr::read_unaligned(self.data.add(idx).cast::<i8>()) }
131    }
132
133    /// Reads an `i16` (SMALLINT) value at row `idx`.
134    ///
135    /// # Safety
136    ///
137    /// - `idx` must be less than `self.row_count()`.
138    /// - The column must contain `SMALLINT` data.
139    #[inline]
140    pub const unsafe fn read_i16(&self, idx: usize) -> i16 {
141        // SAFETY: 2-byte read from valid SMALLINT vector.
142        unsafe { core::ptr::read_unaligned(self.data.add(idx * 2).cast::<i16>()) }
143    }
144
145    /// Reads an `i32` (INTEGER) value at row `idx`.
146    ///
147    /// # Safety
148    ///
149    /// See [`read_i8`][Self::read_i8].
150    #[inline]
151    pub const unsafe fn read_i32(&self, idx: usize) -> i32 {
152        // SAFETY: 4-byte read from valid INTEGER vector.
153        unsafe { core::ptr::read_unaligned(self.data.add(idx * 4).cast::<i32>()) }
154    }
155
156    /// Reads an `i64` (BIGINT / TIMESTAMP) value at row `idx`.
157    ///
158    /// # Safety
159    ///
160    /// See [`read_i8`][Self::read_i8].
161    #[inline]
162    pub const unsafe fn read_i64(&self, idx: usize) -> i64 {
163        // SAFETY: 8-byte read from valid BIGINT/TIMESTAMP vector.
164        unsafe { core::ptr::read_unaligned(self.data.add(idx * 8).cast::<i64>()) }
165    }
166
167    /// Reads a `u8` (UTINYINT) value at row `idx`.
168    ///
169    /// # Safety
170    ///
171    /// See [`read_i8`][Self::read_i8].
172    #[inline]
173    pub const unsafe fn read_u8(&self, idx: usize) -> u8 {
174        // SAFETY: 1-byte read from valid UTINYINT vector.
175        unsafe { *self.data.add(idx) }
176    }
177
178    /// Reads a `u16` (USMALLINT) value at row `idx`.
179    ///
180    /// # Safety
181    ///
182    /// See [`read_i8`][Self::read_i8].
183    #[inline]
184    pub const unsafe fn read_u16(&self, idx: usize) -> u16 {
185        // SAFETY: 2-byte read from valid USMALLINT vector.
186        unsafe { core::ptr::read_unaligned(self.data.add(idx * 2).cast::<u16>()) }
187    }
188
189    /// Reads a `u32` (UINTEGER) value at row `idx`.
190    ///
191    /// # Safety
192    ///
193    /// See [`read_i8`][Self::read_i8].
194    #[inline]
195    pub const unsafe fn read_u32(&self, idx: usize) -> u32 {
196        // SAFETY: 4-byte read from valid UINTEGER vector.
197        unsafe { core::ptr::read_unaligned(self.data.add(idx * 4).cast::<u32>()) }
198    }
199
200    /// Reads a `u64` (UBIGINT) value at row `idx`.
201    ///
202    /// # Safety
203    ///
204    /// See [`read_i8`][Self::read_i8].
205    #[inline]
206    pub const unsafe fn read_u64(&self, idx: usize) -> u64 {
207        // SAFETY: 8-byte read from valid UBIGINT vector.
208        unsafe { core::ptr::read_unaligned(self.data.add(idx * 8).cast::<u64>()) }
209    }
210
211    /// Reads an `f32` (FLOAT) value at row `idx`.
212    ///
213    /// # Safety
214    ///
215    /// See [`read_i8`][Self::read_i8].
216    #[inline]
217    pub const unsafe fn read_f32(&self, idx: usize) -> f32 {
218        // SAFETY: 4-byte read from valid FLOAT vector.
219        unsafe { core::ptr::read_unaligned(self.data.add(idx * 4).cast::<f32>()) }
220    }
221
222    /// Reads an `f64` (DOUBLE) value at row `idx`.
223    ///
224    /// # Safety
225    ///
226    /// See [`read_i8`][Self::read_i8].
227    #[inline]
228    pub const unsafe fn read_f64(&self, idx: usize) -> f64 {
229        // SAFETY: 8-byte read from valid DOUBLE vector.
230        unsafe { core::ptr::read_unaligned(self.data.add(idx * 8).cast::<f64>()) }
231    }
232
233    /// Reads a `bool` (BOOLEAN) value at row `idx`.
234    ///
235    /// # Pitfall L5: Defensive boolean reading
236    ///
237    /// This method reads the underlying byte as `u8` and compares with `!= 0`,
238    /// rather than casting directly to `bool`. `DuckDB`'s C API does not guarantee
239    /// the Rust `bool` invariant (must be exactly 0 or 1), so a direct cast could
240    /// cause undefined behaviour.
241    ///
242    /// # Safety
243    ///
244    /// - `idx` must be less than `self.row_count()`.
245    /// - The column must contain `BOOLEAN` data.
246    #[inline]
247    pub const unsafe fn read_bool(&self, idx: usize) -> bool {
248        // SAFETY: BOOLEAN data is stored as 1 byte per value.
249        // We read as u8 (not bool) to avoid UB if DuckDB sets non-0/1 values.
250        // This is Pitfall L5: always read boolean as u8 then compare != 0.
251        unsafe { *self.data.add(idx) != 0 }
252    }
253
254    /// Reads an `i128` (HUGEINT) value at row `idx`.
255    ///
256    /// `DuckDB` stores HUGEINT as `{ lower: u64, upper: i64 }` in little-endian
257    /// layout, totaling 16 bytes per value.
258    ///
259    /// # Safety
260    ///
261    /// - `idx` must be less than `self.row_count()`.
262    /// - The column must contain `HUGEINT` data.
263    /// - The value at `idx` must not be NULL (check with [`is_valid`][Self::is_valid]).
264    #[inline]
265    pub const unsafe fn read_i128(&self, idx: usize) -> i128 {
266        // SAFETY: HUGEINT is stored as { lower: u64, upper: i64 } = 16 bytes.
267        // DuckDB lays this out in little-endian order: lower at offset 0, upper at offset 8.
268        let base = unsafe { self.data.add(idx * 16) };
269        let lower = unsafe { core::ptr::read_unaligned(base.cast::<u64>()) };
270        let upper = unsafe { core::ptr::read_unaligned(base.add(8).cast::<i64>()) };
271        // Widening casts: u64→i128 and i64→i128 are always lossless.
272        #[allow(clippy::cast_lossless)]
273        let result = (upper as i128) << 64 | (lower as i128);
274        result
275    }
276
277    /// Reads a `u128` (UHUGEINT) value at row `idx`.
278    ///
279    /// `DuckDB` stores UHUGEINT as `{ lower: u64, upper: u64 }` in little-endian
280    /// layout, totalling 16 bytes per value.
281    ///
282    /// # Safety
283    ///
284    /// - `idx` must be less than `self.row_count()`.
285    /// - The column must contain `UHUGEINT` data.
286    #[inline]
287    pub const unsafe fn read_u128(&self, idx: usize) -> u128 {
288        // SAFETY: UHUGEINT = { lower: u64, upper: u64 } = 16 bytes.
289        let base = unsafe { self.data.add(idx * 16) };
290        let lower = unsafe { core::ptr::read_unaligned(base.cast::<u64>()) };
291        let upper = unsafe { core::ptr::read_unaligned(base.add(8).cast::<u64>()) };
292        ((upper as u128) << 64) | (lower as u128)
293    }
294
295    /// Reads a `TIMESTAMP WITH TIME ZONE` value at row `idx`, as microseconds
296    /// since the Unix epoch in UTC.
297    ///
298    /// # Safety
299    ///
300    /// - `idx` must be less than `self.row_count()`.
301    /// - The column must contain `TIMESTAMPTZ` data.
302    #[inline]
303    pub const unsafe fn read_timestamp_tz(&self, idx: usize) -> i64 {
304        // SAFETY: TIMESTAMPTZ shares TIMESTAMP's i64 storage.
305        unsafe { self.read_i64(idx) }
306    }
307
308    /// Reads a `TIMESTAMP_S` value at row `idx`, as seconds since the epoch.
309    ///
310    /// # Safety
311    ///
312    /// - `idx` must be less than `self.row_count()`.
313    /// - The column must contain `TIMESTAMP_S` data.
314    #[inline]
315    pub const unsafe fn read_timestamp_s(&self, idx: usize) -> i64 {
316        // SAFETY: TIMESTAMP_S is stored as i64 seconds.
317        unsafe { self.read_i64(idx) }
318    }
319
320    /// Reads a `TIMESTAMP_MS` value at row `idx`, as milliseconds since the
321    /// epoch.
322    ///
323    /// # Safety
324    ///
325    /// - `idx` must be less than `self.row_count()`.
326    /// - The column must contain `TIMESTAMP_MS` data.
327    #[inline]
328    pub const unsafe fn read_timestamp_ms(&self, idx: usize) -> i64 {
329        // SAFETY: TIMESTAMP_MS is stored as i64 milliseconds.
330        unsafe { self.read_i64(idx) }
331    }
332
333    /// Reads a `TIMESTAMP_NS` value at row `idx`, as nanoseconds since the
334    /// epoch.
335    ///
336    /// # Safety
337    ///
338    /// - `idx` must be less than `self.row_count()`.
339    /// - The column must contain `TIMESTAMP_NS` data.
340    #[inline]
341    pub const unsafe fn read_timestamp_ns(&self, idx: usize) -> i64 {
342        // SAFETY: TIMESTAMP_NS is stored as i64 nanoseconds.
343        unsafe { self.read_i64(idx) }
344    }
345
346    /// Reads a `TIME WITH TIME ZONE` value at row `idx` as `DuckDB`'s packed
347    /// 64-bit representation.
348    ///
349    /// Decode it with
350    /// [`datetime::time_tz_from_bits`][crate::datetime::time_tz_from_bits].
351    ///
352    /// # Safety
353    ///
354    /// - `idx` must be less than `self.row_count()`.
355    /// - The column must contain `TIMETZ` data.
356    #[inline]
357    pub const unsafe fn read_time_tz(&self, idx: usize) -> u64 {
358        // SAFETY: TIMETZ is stored as a 64-bit packed value.
359        unsafe { self.read_u64(idx) }
360    }
361
362    /// Reads a `DECIMAL` value at row `idx` as its unscaled integer.
363    ///
364    /// `DuckDB` stores a `DECIMAL` in the narrowest integer that fits its
365    /// declared width — `i16` up to 4 digits, `i32` up to 9, `i64` up to 18, and
366    /// `i128` up to 38 — so `width` must be the column's declared width. Get it
367    /// from [`LogicalType::decimal_width`][crate::types::LogicalType::decimal_width].
368    ///
369    /// The represented number is `result / 10^scale`.
370    ///
371    /// # Safety
372    ///
373    /// - `idx` must be less than `self.row_count()`.
374    /// - The column must contain `DECIMAL` data with exactly this `width`.
375    #[inline]
376    pub const unsafe fn read_decimal(&self, idx: usize, width: u8) -> i128 {
377        // SAFETY: the caller guarantees `width` matches the column's declared
378        // width, which fixes the physical storage type.
379        unsafe {
380            if width <= 4 {
381                self.read_i16(idx) as i128
382            } else if width <= 9 {
383                self.read_i32(idx) as i128
384            } else if width <= 18 {
385                self.read_i64(idx) as i128
386            } else {
387                self.read_i128(idx)
388            }
389        }
390    }
391
392    /// Returns `true` if `idx` addresses a row of this vector.
393    ///
394    /// Every `read_*` method requires `idx < row_count()`; this is the check to
395    /// pair with them when the index comes from somewhere other than a
396    /// `0..row_count()` loop.
397    #[must_use]
398    #[inline]
399    pub const fn contains(&self, idx: usize) -> bool {
400        idx < self.row_count
401    }
402
403    /// Reads a VARCHAR value at row `idx`.
404    ///
405    /// Returns an empty string if the data is not valid UTF-8 or if the internal
406    /// string pointer is null.
407    ///
408    /// # Pitfall P7
409    ///
410    /// `DuckDB` stores strings in a 16-byte `duckdb_string_t` with two formats
411    /// (inline for ≤ 12 bytes, pointer otherwise). This method handles both.
412    ///
413    /// # Safety
414    ///
415    /// - `idx` must be less than `self.row_count()`.
416    /// - The column must contain `VARCHAR` data.
417    /// - For pointer-format strings, the pointed-to heap memory must be valid
418    ///   for the lifetime of the returned `&str`.
419    pub unsafe fn read_str(&self, idx: usize) -> &str {
420        // SAFETY: Caller guarantees data is a VARCHAR vector and idx is in bounds.
421        unsafe { crate::vector::string::read_duck_string(self.data, idx) }
422    }
423
424    /// Reads a `BLOB` (binary) value at row `idx`.
425    ///
426    /// `DuckDB` stores BLOBs using the same 16-byte `duckdb_string_t` layout as
427    /// VARCHAR (inline for ≤12 bytes, pointer for larger values). The returned
428    /// slice borrows from the vector's data buffer.
429    ///
430    /// The bytes are returned without UTF-8 validation.
431    ///
432    /// # Safety
433    ///
434    /// - `idx` must be less than `self.row_count()`.
435    /// - The column must contain `BLOB` data.
436    /// - The pointed-to memory must be valid for the lifetime of the returned slice.
437    pub unsafe fn read_blob(&self, idx: usize) -> &[u8] {
438        // SAFETY: BLOB uses the same duckdb_string_t layout as VARCHAR.
439        unsafe { crate::vector::string::read_duck_blob(self.data, idx) }
440    }
441
442    /// Reads a `UUID` value at row `idx` as an `i128`.
443    ///
444    /// Reads the UUID's **textual** 128 bits — the value the column renders,
445    /// and what every Rust `Uuid` type holds.
446    ///
447    /// A `UUID` column is physically a `HUGEINT`, but `DuckDB` stores it with
448    /// the top bit flipped so that signed integer ordering matches UUID string
449    /// ordering, so the raw storage of
450    /// `'11111111-2222-3333-4444-555555555555'` is `0x9111...`, not `0x1111...`.
451    /// This undoes that. Use [`read_i128`][Self::read_i128] for the raw storage,
452    /// and [`uuid_from_storage`][crate::vector::uuid_from_storage] to convert.
453    ///
454    /// # Safety
455    ///
456    /// - `idx` must be less than `self.row_count()`.
457    /// - The column must contain `UUID` data.
458    #[inline]
459    pub const unsafe fn read_uuid(&self, idx: usize) -> u128 {
460        // SAFETY: UUID is stored as HUGEINT; undo DuckDB's top-bit flip.
461        unsafe { crate::vector::uuid::uuid_from_storage(self.read_i128(idx)) }
462    }
463
464    /// Reads a `DATE` value at row `idx` as days since the Unix epoch.
465    ///
466    /// `DuckDB` stores DATE as a 4-byte `i32` representing the number of days
467    /// since 1970-01-01. This is a semantic alias for [`read_i32`][Self::read_i32].
468    ///
469    /// # Safety
470    ///
471    /// - `idx` must be less than `self.row_count()`.
472    /// - The column must contain `DATE` data.
473    #[inline]
474    pub const unsafe fn read_date(&self, idx: usize) -> i32 {
475        // SAFETY: DATE is stored as i32 (days since epoch).
476        unsafe { self.read_i32(idx) }
477    }
478
479    /// Reads a `TIMESTAMP` value at row `idx` as microseconds since the Unix epoch.
480    ///
481    /// `DuckDB` stores TIMESTAMP as an 8-byte `i64` representing microseconds
482    /// since 1970-01-01 00:00:00 UTC. This is a semantic alias for
483    /// [`read_i64`][Self::read_i64].
484    ///
485    /// # Safety
486    ///
487    /// - `idx` must be less than `self.row_count()`.
488    /// - The column must contain `TIMESTAMP` data.
489    #[inline]
490    pub const unsafe fn read_timestamp(&self, idx: usize) -> i64 {
491        // SAFETY: TIMESTAMP is stored as i64 (microseconds since epoch).
492        unsafe { self.read_i64(idx) }
493    }
494
495    /// Reads a `TIME` value at row `idx` as microseconds since midnight.
496    ///
497    /// `DuckDB` stores TIME as an 8-byte `i64` representing microseconds since
498    /// midnight. This is a semantic alias for [`read_i64`][Self::read_i64].
499    ///
500    /// # Safety
501    ///
502    /// - `idx` must be less than `self.row_count()`.
503    /// - The column must contain `TIME` data.
504    #[inline]
505    pub const unsafe fn read_time(&self, idx: usize) -> i64 {
506        // SAFETY: TIME is stored as i64 (microseconds since midnight).
507        unsafe { self.read_i64(idx) }
508    }
509
510    /// Reads an `INTERVAL` value at row `idx`.
511    ///
512    /// Returns a [`DuckInterval`][crate::interval::DuckInterval] struct.
513    ///
514    /// # Pitfall P8
515    ///
516    /// The `INTERVAL` struct is 16 bytes: `{ months: i32, days: i32, micros: i64 }`.
517    /// This method handles the layout correctly using [`read_interval_at`][crate::interval::read_interval_at].
518    ///
519    /// # Safety
520    ///
521    /// - `idx` must be less than `self.row_count()`.
522    /// - The column must contain `INTERVAL` data.
523    #[inline]
524    pub const unsafe fn read_interval(&self, idx: usize) -> crate::interval::DuckInterval {
525        // SAFETY: data is a valid INTERVAL vector and idx is in bounds.
526        unsafe { crate::interval::read_interval_at(self.data, idx) }
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    /// Verify that `VectorReader` handles the boolean-as-u8 pattern correctly.
535    #[test]
536    fn bool_read_u8_pattern() {
537        // Simulate a DuckDB BOOLEAN vector with a non-standard value (e.g., 2)
538        // to verify we use != 0 comparison rather than transmuting to bool.
539        let data: [u8; 4] = [0, 1, 2, 255];
540
541        // Directly test the read_bool logic by checking values
542        // (We can't easily create a real VectorReader without DuckDB, so we test
543        // the underlying invariant: any non-zero byte is `true`.)
544        let as_bools: Vec<bool> = data.iter().map(|&b| b != 0).collect();
545        assert_eq!(as_bools, [false, true, true, true]);
546    }
547
548    #[test]
549    fn row_count_is_zero_for_empty_state() {
550        // This exercises the struct layout; actual DuckDB integration is in tests/
551        let reader = VectorReader {
552            data: std::ptr::null(),
553            validity: std::ptr::null_mut(),
554            row_count: 0,
555        };
556        assert_eq!(reader.row_count(), 0);
557    }
558
559    #[test]
560    fn contains_bounds_checks_against_row_count() {
561        let reader = VectorReader {
562            data: std::ptr::null(),
563            validity: std::ptr::null_mut(),
564            row_count: 3,
565        };
566        assert!(reader.contains(0));
567        assert!(reader.contains(2));
568        assert!(!reader.contains(3));
569        assert!(!reader.contains(usize::MAX));
570    }
571
572    #[test]
573    fn decimal_width_thresholds_match_duckdb_storage() {
574        // DuckDB picks the physical type from the declared width:
575        // <=4 -> INT16, <=9 -> INT32, <=18 -> INT64, <=38 -> INT128
576        // (duckdb/common/types/decimal.hpp). Reading with the wrong width reads
577        // the wrong number of bytes, so pin the boundaries.
578        let mut buf = [0u8; 16];
579        buf[..2].copy_from_slice(&(-1234_i16).to_le_bytes());
580        let reader = VectorReader {
581            data: buf.as_ptr(),
582            validity: std::ptr::null_mut(),
583            row_count: 1,
584        };
585        // SAFETY: `buf` holds one INT16 at index 0.
586        assert_eq!(unsafe { reader.read_decimal(0, 4) }, -1234);
587
588        let mut buf = [0u8; 16];
589        buf[..4].copy_from_slice(&(-123_456_789_i32).to_le_bytes());
590        let reader = VectorReader {
591            data: buf.as_ptr(),
592            validity: std::ptr::null_mut(),
593            row_count: 1,
594        };
595        // SAFETY: `buf` holds one INT32 at index 0.
596        assert_eq!(unsafe { reader.read_decimal(0, 9) }, -123_456_789);
597
598        let mut buf = [0u8; 16];
599        buf[..8].copy_from_slice(&(-1_234_567_890_123_456_789_i64).to_le_bytes());
600        let reader = VectorReader {
601            data: buf.as_ptr(),
602            validity: std::ptr::null_mut(),
603            row_count: 1,
604        };
605        // SAFETY: `buf` holds one INT64 at index 0.
606        assert_eq!(
607            unsafe { reader.read_decimal(0, 18) },
608            -1_234_567_890_123_456_789
609        );
610
611        let value: i128 = -170_141_183_460_469_231_731_687_303_715_884_105_727;
612        let buf = value.to_le_bytes();
613        let reader = VectorReader {
614            data: buf.as_ptr(),
615            validity: std::ptr::null_mut(),
616            row_count: 1,
617        };
618        // SAFETY: `buf` holds one INT128 at index 0.
619        assert_eq!(unsafe { reader.read_decimal(0, 38) }, value);
620    }
621
622    #[test]
623    fn u128_reads_little_endian_halves() {
624        let value: u128 = (0xdead_beef_u128 << 64) | 0x1234_5678;
625        let buf = value.to_le_bytes();
626        let reader = VectorReader {
627            data: buf.as_ptr(),
628            validity: std::ptr::null_mut(),
629            row_count: 1,
630        };
631        // SAFETY: `buf` holds one UHUGEINT at index 0.
632        assert_eq!(unsafe { reader.read_u128(0) }, value);
633    }
634
635    #[test]
636    fn is_valid_when_validity_null() {
637        // When validity is null, all rows are considered valid
638        let reader = VectorReader {
639            data: std::ptr::null(),
640            validity: std::ptr::null_mut(),
641            row_count: 5,
642        };
643        // SAFETY: row 0 is in bounds (row_count = 5), validity is null (all valid)
644        assert!(unsafe { reader.is_valid(0) });
645        assert!(unsafe { reader.is_valid(4) });
646    }
647}