Skip to main content

better_duck_core/raw/
result.rs

1use std::{
2    cell::OnceCell,
3    ffi::CStr,
4    ops::{Deref, DerefMut},
5    sync::Arc,
6};
7
8use crate::ffi::{
9    duckdb_column_count, duckdb_column_logical_type, duckdb_column_name, duckdb_destroy_result,
10    duckdb_result_return_type, duckdb_result_statement_type, duckdb_result_type, DUCKDB_TYPE,
11};
12
13use crate::{
14    error::{DuckDBConversionError, Error, Result},
15    ffi,
16    raw::row::DuckRow,
17    raw::statement::StatementType,
18    result_set::ResultSet,
19    types::{LogicalType, TypeInfo},
20};
21
22use super::data_chunk::DataChunk;
23
24/// What kind of output a query produced.
25///
26/// Mirrors DuckDB's `duckdb_result_type`. `#[non_exhaustive]` with
27/// [`Unknown`](ResultType::Unknown) so a value a future DuckDB adds is preserved
28/// rather than lost.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30#[non_exhaustive]
31pub enum ResultType {
32    /// The result carries queryable rows (`SELECT`, `RETURNING`, …).
33    QueryResult,
34    /// The result reports a changed-row count (`INSERT`/`UPDATE`/`DELETE`).
35    ChangedRows,
36    /// The statement produced no result (many DDL statements).
37    Nothing,
38    /// `DUCKDB_RESULT_TYPE_INVALID`.
39    Invalid,
40    /// A type this build does not recognise; the raw value is preserved.
41    Unknown(duckdb_result_type),
42}
43
44impl ResultType {
45    /// Classifies a raw `duckdb_result_type`, preserving unrecognised values.
46    #[must_use]
47    pub fn from_raw(raw: duckdb_result_type) -> ResultType {
48        use crate::ffi as f;
49        match raw {
50            f::duckdb_result_type_DUCKDB_RESULT_TYPE_QUERY_RESULT => ResultType::QueryResult,
51            f::duckdb_result_type_DUCKDB_RESULT_TYPE_CHANGED_ROWS => ResultType::ChangedRows,
52            f::duckdb_result_type_DUCKDB_RESULT_TYPE_NOTHING => ResultType::Nothing,
53            f::duckdb_result_type_DUCKDB_RESULT_TYPE_INVALID => ResultType::Invalid,
54            other => ResultType::Unknown(other),
55        }
56    }
57}
58
59/// Represents the result of a DuckDB query, providing row-by-row iteration over
60/// the returned data.
61///
62/// `DuckResult` owns the underlying `duckdb_result` and destroys it in [`Drop`].
63/// It implements [`Iterator`] yielding `Result<DuckRow>`.
64///
65/// # Safety
66///
67/// This struct interacts directly with the DuckDB C API. The underlying
68/// `duckdb_result` must be a fully initialized result from a successful query.
69pub struct DuckResult {
70    res: ffi::duckdb_result,
71    chunk: Option<DataChunk>,
72    /// Owned column names, populated once on construction. `Arc`-shared so every
73    /// [`DuckRow`] built from this result clones it in O(1) instead of re-allocating
74    /// the whole column-name array per row.
75    column_names: OnceCell<Arc<[Box<str>]>>,
76    /// Coarse per-column type ids. Retained for the existing migration-facing
77    /// `column_type` API; the lossless descriptor is `column_schema`.
78    column_types: Box<[DUCKDB_TYPE]>,
79    /// Lossless per-column type descriptors, resolved once via
80    /// `duckdb_column_logical_type` + [`LogicalType::describe`]. Owned, so it is
81    /// `Clone`/`Send` and outlives the DuckDB logical-type handles it came from.
82    column_schema: Arc<[TypeInfo]>,
83    /// The kind of statement that produced this result (`duckdb_result_statement_type`).
84    statement_type: StatementType,
85    /// What kind of output the result is (`duckdb_result_return_type`).
86    result_type: ResultType,
87    /// Number of columns in the result.
88    pub col_count: u64,
89    /// Rows already pulled from the underlying result. Only populated once
90    /// [`enable_rewind`](DuckResult::enable_rewind) has been called — plain forward
91    /// iteration (the common case) never touches this, so it costs nothing unless
92    /// a caller opts in.
93    cache: Vec<DuckRow>,
94    /// Read position into `cache` consulted by the `Iterator` implementation.
95    cursor: usize,
96    /// `true` once the underlying result has yielded its last row.
97    exhausted: bool,
98    /// Set by [`enable_rewind`](DuckResult::enable_rewind); gates whether `next()`
99    /// clones each row into `cache`.
100    rewind_enabled: bool,
101    /// A single-row lookahead buffer for [`exists`](DuckResult::exists), independent
102    /// of `cache` — peeking a row must work whether or not rewind support is enabled.
103    peeked: Option<DuckRow>,
104}
105
106impl DuckResult {
107    /// Creates a new `DuckResult` from an owned `duckdb_result`.
108    ///
109    /// Immediately resolves column names and types. Panics if the result is in
110    /// an invalid state (should not happen for a result from a successful query).
111    pub fn new(mut result: ffi::duckdb_result) -> DuckResult {
112        let mut res = DuckResult {
113            // SAFETY: `result` is a valid, fully initialized `duckdb_result` that was
114            // returned by `duckdb_query` or `duckdb_execute_prepared` and is now moved
115            // (heap-allocated by the caller). `duckdb_column_count` reads from this struct.
116            col_count: unsafe { duckdb_column_count(&mut result) },
117            // SAFETY: `result` is a valid `duckdb_result`. These take it by value (a
118            // small `Copy` struct) and only read its classification fields.
119            statement_type: StatementType::from_raw(unsafe {
120                duckdb_result_statement_type(result)
121            }),
122            // SAFETY: as above.
123            result_type: ResultType::from_raw(unsafe { duckdb_result_return_type(result) }),
124            res: result,
125            chunk: None,
126            column_names: OnceCell::new(),
127            column_types: Box::new([]),
128            column_schema: Arc::from([]),
129            cache: Vec::new(),
130            cursor: 0,
131            exhausted: false,
132            rewind_enabled: false,
133            peeked: None,
134        };
135        res.resolve_columns_name().expect("failed to resolve column names");
136        res.resolve_columns_types().expect("failed to resolve column types");
137        res.resolve_columns_schema();
138        res
139    }
140
141    /// Resolves each column's lossless [`TypeInfo`] once, via
142    /// `duckdb_column_logical_type` + [`LogicalType::describe`].
143    ///
144    /// The `LogicalType` RAII wrapper destroys each handle right after it is
145    /// described, so no `duckdb_logical_type` outlives this call; the owned
146    /// `TypeInfo` values are what the result keeps.
147    #[inline]
148    fn resolve_columns_schema(&mut self) {
149        let mut schema = Vec::with_capacity(self.col_count as usize);
150        for i in 0..self.col_count {
151            // SAFETY: `self.res` is valid; `i` is within [0, col_count).
152            // `duckdb_column_logical_type` returns an owned handle (or null).
153            let raw = unsafe { duckdb_column_logical_type(&mut self.res, i) };
154            let info = LogicalType::from_raw(raw)
155                .map(|lt| lt.describe())
156                // A null/failed handle should not happen for a valid result column;
157                // fall back to the coarse id so schema length always matches col_count.
158                .unwrap_or_else(|_| TypeInfo::Scalar(self.column_types[i as usize]));
159            schema.push(info);
160        }
161        self.column_schema = Arc::from(schema);
162    }
163
164    #[inline]
165    // SAFETY: caller must ensure `col_index` is within [0, col_count).
166    fn get_col_type(
167        &mut self,
168        col_index: u64,
169    ) -> DUCKDB_TYPE {
170        // SAFETY: `self.res` is valid; `col_index` is within bounds (enforced by caller).
171        unsafe { ffi::duckdb_column_type(&mut self.res, col_index) }
172    }
173
174    #[inline]
175    fn resolve_columns_types(&mut self) -> Result<()> {
176        // TODO: guard the uninit slice on early return (consider `scopeguard`)
177        let mut col_types = Box::<[DUCKDB_TYPE]>::new_uninit_slice(self.col_count as usize);
178
179        for each in 0..self.col_count {
180            // SAFETY: `each` is within [0, col_count), satisfying the invariant of
181            // `get_col_type`.
182            let temp_col_type = self.get_col_type(each);
183            // SAFETY: `col_types[each]` is within the allocation; we write an initialized
184            // `DUCKDB_TYPE` value.
185            unsafe {
186                col_types[each as usize].as_mut_ptr().write(temp_col_type);
187            }
188        }
189        // SAFETY: every element in `col_types` has been initialized above.
190        self.column_types = unsafe { col_types.assume_init() };
191        Ok(())
192    }
193
194    #[inline]
195    fn resolve_columns_name(&mut self) -> Result<()> {
196        let names = (0..self.col_count)
197            .map(|i| {
198                // SAFETY: `i` is within [0, col_count). `duckdb_column_name` returns a
199                // pointer into result-owned memory valid for the lifetime of `self.res`.
200                // We copy the bytes immediately so the raw pointer does not escape.
201                let raw = unsafe { duckdb_column_name(&mut self.res, i) };
202                if raw.is_null() {
203                    return Err(Error::InvalidColumnIndex(i as usize));
204                }
205                // SAFETY: DuckDB guarantees null-terminated valid UTF-8 for column names.
206                unsafe { CStr::from_ptr(raw) }
207                    .to_str()
208                    .map(|s| s.to_string().into_boxed_str())
209                    .map_err(|e| {
210                        Error::ConversionError(DuckDBConversionError::ConversionError(
211                            e.to_string(),
212                        ))
213                    })
214            })
215            .collect::<Result<Vec<Box<str>>>>()?;
216
217        self.column_names
218            .set(Arc::from(names))
219            .map_err(|_| Error::UNKNOWN("column names already set".into()))
220    }
221
222    /// Advances the internal cursor to the next row.
223    ///
224    /// Returns `Some(())` if a row is available, or `None` if all rows have been
225    /// consumed.
226    fn advance(&mut self) -> Option<()> {
227        loop {
228            if self.chunk.is_none() {
229                // SAFETY: `self.res` is a valid duckdb_result. `DataChunk::from_result`
230                // calls `duckdb_fetch_chunk` which returns null when exhausted.
231                let next_chunk = DataChunk::from_result(self);
232                match next_chunk {
233                    None => return None,
234                    Some(Err(_)) => {
235                        self.chunk = None;
236                        return None;
237                    },
238                    Some(Ok(chunk)) => {
239                        self.chunk = Some(chunk);
240                    },
241                }
242            }
243            let the_chunk = self.chunk.as_mut().unwrap();
244            // SAFETY: `the_chunk` wraps a valid duckdb_data_chunk.
245            if the_chunk.row_count() == 0 {
246                self.chunk = None;
247                return None;
248            }
249            // SAFETY: `the_chunk` wraps a valid duckdb_data_chunk whose row count > 0.
250            if the_chunk.next_row().is_some() {
251                let row_chunk = **the_chunk;
252                if row_chunk.is_null() {
253                    panic!("Data chunk is null");
254                }
255                return Some(());
256            } else {
257                self.chunk = None;
258                // Loop to fetch the next chunk.
259            }
260        }
261    }
262
263    /// Pulls the next row directly from the underlying DuckDB result, bypassing
264    /// the cache. Returns `None` once the underlying result is exhausted.
265    fn pull_next(&mut self) -> Option<Result<DuckRow>> {
266        if self.advance().is_some() {
267            Some(self.current())
268        } else {
269            None
270        }
271    }
272}
273
274// Exposed API
275impl DuckResult {
276    /// Returns the current row as a [`DuckRow`].
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if the chunk is not available or value conversion fails.
281    pub fn current(&mut self) -> Result<DuckRow> {
282        // O(1): `Arc` clone, not a fresh per-row allocation of the column-name array.
283        let col_names = self.column_names.get().expect("column names resolved in new()").clone();
284        let chunk = self.chunk.as_mut().unwrap();
285        DuckRow::from_chunk(chunk, col_names, &self.column_types)
286    }
287
288    /// Returns the number of rows changed by the last INSERT/UPDATE/DELETE.
289    ///
290    /// Returns `0` for SELECT statements.
291    #[allow(unused)]
292    #[inline]
293    pub fn changes(&mut self) -> u64 {
294        // SAFETY: `self.res` is a valid duckdb_result.
295        unsafe { ffi::duckdb_rows_changed(&mut self.res) }
296    }
297
298    /// Returns the number of columns in this result.
299    #[allow(unused)]
300    #[inline]
301    pub fn column_count(&self) -> u64 {
302        self.col_count
303    }
304
305    /// Returns the DuckDB type of the column at `col_index`.
306    ///
307    /// This is the coarse `duckdb_type` id, kept for migration. For the lossless
308    /// descriptor (DECIMAL precision, nested types, enum labels, …) use
309    /// [`column_logical_type`](DuckResult::column_logical_type).
310    ///
311    /// # Errors
312    ///
313    /// Returns [`Error::InvalidColumnIndex`] if `col_index` is out of range.
314    #[allow(unused)]
315    #[inline]
316    pub fn column_type(
317        &self,
318        col_index: usize,
319    ) -> Result<DUCKDB_TYPE> {
320        if col_index >= self.col_count as usize {
321            return Err(Error::InvalidColumnIndex(col_index));
322        }
323        Ok(self.column_types[col_index])
324    }
325
326    /// Returns the lossless [`TypeInfo`] descriptors for every column, in order.
327    #[must_use]
328    #[inline]
329    pub fn column_schema(&self) -> &[TypeInfo] {
330        &self.column_schema
331    }
332
333    /// The kind of statement that produced this result (SELECT, INSERT, …).
334    #[must_use]
335    #[inline]
336    pub fn statement_type(&self) -> StatementType {
337        self.statement_type
338    }
339
340    /// What kind of output this result is (rows, changed-row count, nothing).
341    #[must_use]
342    #[inline]
343    pub fn result_type(&self) -> ResultType {
344        self.result_type
345    }
346
347    /// Returns a cheaply-cloneable handle to the full column schema.
348    ///
349    /// Used to carry the schema into an owned [`ResultSet`](crate::result_set::ResultSet)
350    /// without re-resolving it.
351    #[must_use]
352    #[inline]
353    pub(crate) fn column_schema_arc(&self) -> Arc<[TypeInfo]> {
354        Arc::clone(&self.column_schema)
355    }
356
357    /// Returns the lossless [`TypeInfo`] of the column at `col_index`.
358    ///
359    /// # Errors
360    ///
361    /// Returns [`Error::InvalidColumnIndex`] if `col_index` is out of range.
362    #[allow(unused)]
363    #[inline]
364    pub fn column_logical_type(
365        &self,
366        col_index: usize,
367    ) -> Result<&TypeInfo> {
368        self.column_schema.get(col_index).ok_or(Error::InvalidColumnIndex(col_index))
369    }
370
371    /// Returns the name of the column at `col_index`.
372    ///
373    /// # Errors
374    ///
375    /// Returns [`Error::InvalidColumnIndex`] if `col_index` is out of range.
376    #[allow(unused)]
377    #[inline]
378    pub fn column_name(
379        &self,
380        col_index: usize,
381    ) -> Result<&str> {
382        if col_index >= self.col_count as usize {
383            return Err(Error::InvalidColumnIndex(col_index));
384        }
385        Ok(&self.column_names.get().unwrap()[col_index])
386    }
387
388    /// Returns a slice of all column names in result order.
389    #[allow(unused)]
390    #[inline]
391    pub fn column_names(&self) -> &[Box<str>] {
392        self.column_names.get().map(|v| v.as_ref()).unwrap_or(&[])
393    }
394
395    /// Enables the [`rewind`](DuckResult::rewind) / replay cache.
396    ///
397    /// Plain forward iteration (the default) never clones or caches rows, so it
398    /// costs nothing beyond decoding each row once. Call this *before* consuming any
399    /// rows if you need [`rewind`](DuckResult::rewind) to replay from the start —
400    /// once enabled, every row pulled through `next()` from that point on is cloned
401    /// into a cache so it can be replayed. Rows already consumed before this call was
402    /// made cannot be recovered.
403    pub fn enable_rewind(&mut self) {
404        self.rewind_enabled = true;
405    }
406
407    /// Returns the zero-based index of the column with the given name, or `None`
408    /// if no column matches.
409    #[allow(unused)]
410    #[inline]
411    pub fn column_idx(
412        &self,
413        col_name: &str,
414    ) -> Option<usize> {
415        self.column_names.get().unwrap().iter().position(|name| name.as_ref() == col_name)
416    }
417
418    /// Returns whether this result contains at least one more row, without
419    /// consuming it — a subsequent call to `next()` still yields that row.
420    ///
421    /// # Errors
422    ///
423    /// Returns an error if pulling the first row fails.
424    pub fn exists(&mut self) -> Result<bool> {
425        if self.rewind_enabled && self.cursor < self.cache.len() {
426            return Ok(true);
427        }
428        if self.peeked.is_some() {
429            return Ok(true);
430        }
431        if self.exhausted {
432            return Ok(false);
433        }
434        match self.pull_next() {
435            Some(Ok(row)) => {
436                // Buffer the row but don't touch `cursor`/`cache`, so `next()` still
437                // returns it (from the peek buffer, then decides whether to cache it).
438                self.peeked = Some(row);
439                Ok(true)
440            },
441            Some(Err(e)) => Err(e),
442            None => {
443                self.exhausted = true;
444                Ok(false)
445            },
446        }
447    }
448
449    /// Resets iteration to the first row.
450    ///
451    /// Requires [`enable_rewind`](DuckResult::enable_rewind) to have been called
452    /// before the rows you want to replay were consumed — without it, `cache` is
453    /// always empty and this is a no-op (iteration just continues forward as normal).
454    pub fn rewind(&mut self) {
455        self.cursor = 0;
456    }
457
458    /// Consumes this result, materializing every row into an owned [`ResultSet`].
459    ///
460    /// Unlike `DuckResult`, the returned `ResultSet` holds no FFI handles and is
461    /// `Send` + `Sync` + `Clone`, so it can cross thread boundaries (e.g. out of a
462    /// `spawn_blocking` closure).
463    ///
464    /// # Errors
465    ///
466    /// Returns an error if row conversion fails partway through iteration.
467    pub fn materialize(mut self) -> Result<ResultSet> {
468        let changes = self.changes();
469        let column_names = self.column_names().to_vec().into_boxed_slice();
470        // Capture the lossless schema and classifications before `self` is consumed.
471        let column_schema = self.column_schema_arc();
472        let statement_type = self.statement_type();
473        let result_type = self.result_type();
474        let mut rows = Vec::new();
475        for row in self {
476            rows.push(row?);
477        }
478        Ok(ResultSet::new(rows, changes, column_names, column_schema, statement_type, result_type))
479    }
480}
481
482impl Iterator for DuckResult {
483    type Item = Result<DuckRow>;
484
485    fn next(&mut self) -> Option<Self::Item> {
486        if self.rewind_enabled && self.cursor < self.cache.len() {
487            let row = self.cache[self.cursor].clone();
488            self.cursor += 1;
489            return Some(Ok(row));
490        }
491        if let Some(row) = self.peeked.take() {
492            if self.rewind_enabled {
493                self.cache.push(row.clone());
494                self.cursor += 1;
495            }
496            return Some(Ok(row));
497        }
498        if self.exhausted {
499            return None;
500        }
501        match self.pull_next() {
502            Some(Ok(row)) => {
503                if self.rewind_enabled {
504                    self.cache.push(row.clone());
505                    self.cursor += 1;
506                }
507                Some(Ok(row))
508            },
509            Some(Err(e)) => {
510                self.exhausted = true;
511                Some(Err(e))
512            },
513            None => {
514                self.exhausted = true;
515                None
516            },
517        }
518    }
519
520    /// Counts the remaining rows without materializing each one into a [`DuckRow`].
521    ///
522    /// The default `Iterator::count()` would call [`next`](Self::next) in a loop,
523    /// which for every row allocates a `Vec<DuckValue>` and converts every column —
524    /// wasted work when the caller only wants the row count. `count(self)` consumes
525    /// `self`, so no further iteration can observe `cache`/`peeked` afterwards; the
526    /// remaining rows are tallied by advancing the chunk cursor only, skipping the
527    /// per-row/per-column conversion entirely.
528    fn count(mut self) -> usize
529    where
530        Self: Sized,
531    {
532        let mut n = 0usize;
533        if self.rewind_enabled && self.cursor < self.cache.len() {
534            n += self.cache.len() - self.cursor;
535            self.cursor = self.cache.len();
536        }
537        if self.peeked.take().is_some() {
538            n += 1;
539        }
540        if self.exhausted {
541            return n;
542        }
543        while self.advance().is_some() {
544            n += 1;
545        }
546        n
547    }
548}
549
550impl Deref for DuckResult {
551    type Target = ffi::duckdb_result;
552
553    fn deref(&self) -> &Self::Target {
554        &self.res
555    }
556}
557impl DerefMut for DuckResult {
558    fn deref_mut(&mut self) -> &mut Self::Target {
559        &mut self.res
560    }
561}
562impl Drop for DuckResult {
563    fn drop(&mut self) {
564        // SAFETY: `self.res` is a valid duckdb_result created by `DuckResult::new`.
565        // `duckdb_destroy_result` is called exactly once here in `drop`.
566        unsafe {
567            duckdb_destroy_result(&mut self.res);
568        }
569    }
570}
571
572#[cfg(test)]
573#[allow(clippy::undocumented_unsafe_blocks)]
574mod tests {
575    use crate::{
576        config::Config,
577        error::Error,
578        ffi::{
579            DUCKDB_TYPE_DUCKDB_TYPE_DECIMAL, DUCKDB_TYPE_DUCKDB_TYPE_INTEGER,
580            DUCKDB_TYPE_DUCKDB_TYPE_VARCHAR,
581        },
582        helpers::path::path_to_cstring,
583        raw::connection::RawConnection,
584        raw::result::ResultType,
585        raw::statement::StatementType,
586        result_set::ResultSet,
587        types::value::DuckValue,
588    };
589
590    fn get_test_connection() -> RawConnection {
591        let c_path = path_to_cstring(":memory:".as_ref()).unwrap();
592        let config = Config::default().with("duckdb_api", "rust").unwrap();
593        RawConnection::open_with_flags(&c_path, config).unwrap()
594    }
595
596    #[test]
597    fn result_metadata_reports_columns_and_lookup_failures() {
598        let con = get_test_connection();
599        let mut stmt = con.prepare("SELECT 42::INTEGER AS id, 'duck'::VARCHAR AS label").unwrap();
600        let result = stmt.execute().unwrap();
601
602        assert_eq!(result.column_count(), 2);
603        assert_eq!(result.column_type(0), Ok(DUCKDB_TYPE_DUCKDB_TYPE_INTEGER));
604        assert_eq!(result.column_type(1), Ok(DUCKDB_TYPE_DUCKDB_TYPE_VARCHAR));
605        assert_eq!(result.column_name(0), Ok("id"));
606        assert_eq!(result.column_name(1), Ok("label"));
607        assert_eq!(
608            result.column_names().iter().map(AsRef::as_ref).collect::<Vec<_>>(),
609            ["id", "label"]
610        );
611        assert_eq!(result.column_idx("id"), Some(0));
612        assert_eq!(result.column_idx("label"), Some(1));
613        assert_eq!(result.column_idx("missing"), None);
614        assert_eq!(result.column_type(2), Err(Error::InvalidColumnIndex(2)));
615        assert_eq!(result.column_name(usize::MAX), Err(Error::InvalidColumnIndex(usize::MAX)));
616    }
617
618    /// The lossless column schema preserves what the coarse `column_type` id
619    /// discards: DECIMAL precision and nested LIST shape.
620    #[test]
621    fn column_schema_preserves_decimal_precision_and_nesting() {
622        use crate::types::TypeInfo;
623        let con = get_test_connection();
624        let mut stmt = con
625            .prepare("SELECT CAST(1.5 AS DECIMAL(9,3)) AS d, [10, 20]::INTEGER[] AS xs")
626            .unwrap();
627        let result = stmt.execute().unwrap();
628
629        // Coarse ids only say DECIMAL / LIST.
630        assert_eq!(result.column_type(0), Ok(DUCKDB_TYPE_DUCKDB_TYPE_DECIMAL));
631        // The lossless schema keeps the declared precision and the element type.
632        assert_eq!(result.column_logical_type(0), Ok(&TypeInfo::Decimal { width: 9, scale: 3 }));
633        assert_eq!(
634            result.column_logical_type(1),
635            Ok(&TypeInfo::List(Box::new(TypeInfo::Scalar(DUCKDB_TYPE_DUCKDB_TYPE_INTEGER))))
636        );
637        assert_eq!(result.column_schema().len(), 2);
638        assert!(matches!(result.column_logical_type(2), Err(Error::InvalidColumnIndex(2))));
639    }
640
641    /// `materialize()` carries the lossless schema into the owned `ResultSet`.
642    #[test]
643    fn materialized_result_carries_column_schema() {
644        use crate::types::TypeInfo;
645        let con = get_test_connection();
646        let set = con
647            .prepare("SELECT CAST(2.25 AS DECIMAL(6,2)) AS d")
648            .unwrap()
649            .execute()
650            .unwrap()
651            .materialize()
652            .unwrap();
653        assert_eq!(set.column_schema(), &[TypeInfo::Decimal { width: 6, scale: 2 }]);
654    }
655
656    /// A SELECT is classified as a query result; the classification also survives
657    /// materialization into the owned `ResultSet`.
658    #[test]
659    fn select_reports_query_result_classification() {
660        let mut con = get_test_connection();
661        let result = con.query("SELECT 1 AS a").unwrap();
662        assert_eq!(result.statement_type(), StatementType::Select);
663        assert_eq!(result.result_type(), ResultType::QueryResult);
664
665        let set = con.prepare("SELECT 1 AS a").unwrap().execute().unwrap().materialize().unwrap();
666        assert_eq!(set.statement_type(), StatementType::Select);
667        assert_eq!(set.result_type(), ResultType::QueryResult);
668    }
669
670    /// A DML statement is classified as INSERT producing a changed-row count,
671    /// distinct from a SELECT's query-result classification.
672    #[test]
673    fn insert_reports_changed_rows_classification() {
674        let mut con = get_test_connection();
675        con.query("CREATE TABLE t (id INTEGER)").unwrap();
676        let result = con.query("INSERT INTO t VALUES (1), (2)").unwrap();
677        assert_eq!(result.statement_type(), StatementType::Insert);
678        assert_eq!(result.result_type(), ResultType::ChangedRows);
679    }
680
681    #[test]
682    fn result_type_preserves_unknown_values() {
683        assert_eq!(ResultType::from_raw(9_999), ResultType::Unknown(9_999));
684    }
685
686    #[test]
687    fn materialize_select_preserves_rows_columns_and_values() {
688        let con = get_test_connection();
689        let mut stmt = con
690            .prepare("SELECT * FROM (VALUES (1, 'one'), (2, 'two')) AS t(id, label) ORDER BY id")
691            .unwrap();
692        let result = stmt.execute().unwrap().materialize().unwrap();
693
694        assert_eq!(result.len(), 2);
695        assert!(!result.is_empty());
696        assert_eq!(result.changes(), 0);
697        assert_eq!(
698            result.column_names().iter().map(AsRef::as_ref).collect::<Vec<_>>(),
699            ["id", "label"]
700        );
701        assert_eq!(result.rows()[0].get("id"), Some(&DuckValue::Int(1)));
702        assert_eq!(result.rows()[0].get("label"), Some(&DuckValue::Text("one".into())));
703        assert_eq!(result.rows()[1].get("id"), Some(&DuckValue::Int(2)));
704        assert_eq!(result.rows()[1].get("label"), Some(&DuckValue::Text("two".into())));
705    }
706
707    #[test]
708    fn materialize_empty_select_preserves_schema() {
709        let con = get_test_connection();
710        let mut stmt =
711            con.prepare("SELECT NULL::INTEGER AS id, NULL::VARCHAR AS label WHERE FALSE").unwrap();
712        let result = stmt.execute().unwrap().materialize().unwrap();
713
714        assert!(result.is_empty());
715        assert_eq!(result.len(), 0);
716        assert_eq!(result.changes(), 0);
717        assert_eq!(
718            result.column_names().iter().map(AsRef::as_ref).collect::<Vec<_>>(),
719            ["id", "label"]
720        );
721        assert!(result.first().is_none());
722    }
723
724    #[test]
725    fn materialize_dml_preserves_affected_row_count() {
726        let mut con = get_test_connection();
727        let _ = con.query("CREATE TABLE t (v INTEGER)").unwrap();
728        let result =
729            con.query("INSERT INTO t VALUES (1), (2), (3)").unwrap().materialize().unwrap();
730
731        assert_eq!(result.changes(), 3);
732        assert_eq!(result.len(), 1);
733        assert_eq!(result.first().unwrap().get_idx(0), Some(&DuckValue::BigInt(3)));
734    }
735
736    #[test]
737    fn result_set_is_send_and_sync() {
738        fn assert_send_sync<T: Send + Sync>() {}
739        assert_send_sync::<ResultSet>();
740    }
741
742    /// Plain forward iteration (the default, rewind not enabled) must not error and
743    /// must yield every row exactly once.
744    #[test]
745    fn forward_iteration_without_rewind_yields_all_rows() {
746        let mut con = get_test_connection();
747        con.query("CREATE TABLE t (v INTEGER)").unwrap();
748        con.query("INSERT INTO t VALUES (1), (2), (3)").unwrap();
749
750        let mut stmt = con.prepare("SELECT v FROM t ORDER BY v").unwrap();
751        let result = stmt.execute().unwrap();
752        let rows: Vec<_> = result.collect::<Result<_, _>>().unwrap();
753        assert_eq!(rows.len(), 3);
754    }
755
756    /// `exists()` must peek without consuming — a subsequent full iteration still
757    /// yields every row, including the one that was peeked.
758    #[test]
759    fn exists_peeks_without_consuming() {
760        let mut con = get_test_connection();
761        con.query("CREATE TABLE t (v INTEGER)").unwrap();
762        con.query("INSERT INTO t VALUES (1), (2)").unwrap();
763
764        let mut stmt = con.prepare("SELECT v FROM t ORDER BY v").unwrap();
765        let mut result = stmt.execute().unwrap();
766
767        assert!(result.exists().unwrap());
768        assert!(result.exists().unwrap()); // idempotent — still doesn't consume
769
770        let rows: Vec<_> = result.collect::<Result<_, _>>().unwrap();
771        assert_eq!(rows.len(), 2, "the peeked row must still be yielded by next()");
772    }
773
774    /// Without `enable_rewind()`, `rewind()` is a documented no-op: the cache stays
775    /// empty, so iteration just continues forward from wherever it was.
776    #[test]
777    fn rewind_without_enable_is_a_no_op() {
778        let mut con = get_test_connection();
779        con.query("CREATE TABLE t (v INTEGER)").unwrap();
780        con.query("INSERT INTO t VALUES (1), (2), (3)").unwrap();
781
782        let mut stmt = con.prepare("SELECT v FROM t ORDER BY v").unwrap();
783        let mut result = stmt.execute().unwrap();
784
785        let first = result.next().unwrap().unwrap();
786        result.rewind();
787        // Not the first row again — rewind had nothing cached to replay.
788        let second = result.next().unwrap().unwrap();
789        assert_ne!(first.get("v"), second.get("v"));
790    }
791
792    /// With `enable_rewind()`, `rewind()` replays every row pulled after it was
793    /// called, from the start.
794    #[test]
795    fn enable_rewind_then_rewind_replays_from_start() {
796        let mut con = get_test_connection();
797        con.query("CREATE TABLE t (v INTEGER)").unwrap();
798        con.query("INSERT INTO t VALUES (1), (2), (3)").unwrap();
799
800        let mut stmt = con.prepare("SELECT v FROM t ORDER BY v").unwrap();
801        let mut result = stmt.execute().unwrap();
802        result.enable_rewind();
803
804        let first_pass: Vec<_> = (&mut result).take(3).map(|r| r.unwrap()).collect();
805        assert_eq!(first_pass.len(), 3);
806
807        result.rewind();
808        let second_pass: Vec<_> = result.collect::<Result<_, _>>().unwrap();
809        assert_eq!(second_pass.len(), 3);
810        for (a, b) in first_pass.iter().zip(second_pass.iter()) {
811            assert_eq!(a.get("v"), b.get("v"));
812        }
813    }
814
815    /// `enable_rewind()` after `exists()` has already peeked a row must still cache
816    /// that row once it's consumed via `next()` — the peek buffer and the rewind
817    /// cache must not race each other.
818    #[test]
819    fn enable_rewind_after_exists_peek_still_caches_the_peeked_row() {
820        let mut con = get_test_connection();
821        con.query("CREATE TABLE t (v INTEGER)").unwrap();
822        con.query("INSERT INTO t VALUES (1), (2)").unwrap();
823
824        let mut stmt = con.prepare("SELECT v FROM t ORDER BY v").unwrap();
825        let mut result = stmt.execute().unwrap();
826
827        assert!(result.exists().unwrap()); // peeks row 1, before rewind is enabled
828        result.enable_rewind();
829        let first = result.next().unwrap().unwrap(); // drains the peek, now cached
830
831        result.rewind();
832        let replayed = result.next().unwrap().unwrap();
833        assert_eq!(first.get("v"), replayed.get("v"));
834    }
835
836    /// The fast-path `count()` (which skips per-row `DuckRow` materialization) must
837    /// match plain forward iteration for a simple, single-chunk result.
838    #[test]
839    fn count_matches_iteration_length_for_plain_forward_iteration() {
840        let mut con = get_test_connection();
841        con.query("CREATE TABLE t (v INTEGER)").unwrap();
842        con.query("INSERT INTO t VALUES (1), (2), (3)").unwrap();
843
844        let mut stmt = con.prepare("SELECT v FROM t").unwrap();
845        let result = stmt.execute().unwrap();
846        assert_eq!(result.count(), 3);
847    }
848
849    /// `count()` must correctly tally rows spanning more than one DuckDB vector
850    /// chunk (default chunk size is 2048 rows) — `advance()` must be called enough
851    /// times to cross chunk boundaries, not just enough for a single chunk.
852    #[test]
853    fn count_matches_iteration_length_across_multiple_chunks() {
854        let mut con = get_test_connection();
855        con.query("CREATE TABLE t AS SELECT * FROM range(5000) t(v)").unwrap();
856
857        let mut stmt = con.prepare("SELECT v FROM t").unwrap();
858        let result = stmt.execute().unwrap();
859        assert_eq!(result.count(), 5000);
860    }
861
862    /// `exists()` peeks a row without consuming it; `count()` must still include
863    /// that already-materialized peeked row in its total.
864    #[test]
865    fn count_after_exists_peek_includes_the_peeked_row() {
866        let mut con = get_test_connection();
867        con.query("CREATE TABLE t (v INTEGER)").unwrap();
868        con.query("INSERT INTO t VALUES (1), (2), (3)").unwrap();
869
870        let mut stmt = con.prepare("SELECT v FROM t").unwrap();
871        let mut result = stmt.execute().unwrap();
872        assert!(result.exists().unwrap());
873        assert_eq!(result.count(), 3);
874    }
875
876    /// With rewind enabled, some rows may already sit in `cache` (already pulled,
877    /// not yet re-consumed via `cursor`) when `count()` is called on the remainder.
878    /// `count()` must count both the not-yet-replayed cached rows and the rows still
879    /// to be freshly pulled.
880    #[test]
881    fn count_with_rewind_enabled_after_partial_consumption() {
882        let mut con = get_test_connection();
883        con.query("CREATE TABLE t (v INTEGER)").unwrap();
884        con.query("INSERT INTO t VALUES (1), (2), (3), (4), (5)").unwrap();
885
886        let mut stmt = con.prepare("SELECT v FROM t").unwrap();
887        let mut result = stmt.execute().unwrap();
888        result.enable_rewind();
889
890        // Pull the first two rows (now cached) and rewind, moving the cursor back
891        // to the start of the cache without clearing it.
892        let _ = result.next().unwrap().unwrap();
893        let _ = result.next().unwrap().unwrap();
894        result.rewind();
895
896        // From the start: 2 cached rows to replay + 3 rows still to be pulled fresh.
897        assert_eq!(result.count(), 5);
898    }
899}