Skip to main content

quack_rs/table/
typed.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//! Closure-based table functions with typed scan state.
7//!
8//! This module provides [`TypedTableFunctionBuilder`], a higher-level builder
9//! layered on top of [`TableFunctionBuilder`]. It lets extension authors write
10//! table functions using safe Rust closures instead of `unsafe extern "C" fn`
11//! trampolines for `bind`, `init`, and `scan`.
12//!
13//! # Motivation
14//!
15//! The raw [`TableFunctionBuilder`] API requires authors to write three
16//! hand-rolled `unsafe extern "C" fn` callbacks and manually shuttle state
17//! through [`FfiBindData`] / [`FfiInitData`]. For extensions that merely need
18//! "take some parameters at bind, stream rows until exhausted", that ceremony
19//! is largely accidental complexity. [`TypedTableFunctionBuilder`] collapses
20//! that to two closures:
21//!
22//! ```rust,no_run
23//! use quack_rs::prelude::*;
24//!
25//! struct State { remaining: u64 }
26//!
27//! fn register(reg: &impl Registrar) -> ExtResult<()> {
28//!     let builder = TableFunctionBuilder::new("count_down")
29//!         .param(TypeId::BigInt)
30//!         .with_state::<State, _>(|bind| {
31//!             bind.add_result_column("n", TypeId::BigInt);
32//!             let raw = unsafe { bind.get_parameter_value(0) };
33//!             let n = raw.as_i64_or(0).max(0) as u64;
34//!             Ok(State { remaining: n })
35//!         })
36//!         .scan(|state, chunk| {
37//!             if state.remaining == 0 {
38//!                 unsafe { chunk.set_size(0) };
39//!                 return Ok(());
40//!             }
41//!             let mut writer = unsafe { chunk.writer(0) };
42//!             unsafe { writer.write_i64(0, state.remaining as i64) };
43//!             state.remaining -= 1;
44//!             unsafe { chunk.set_size(1) };
45//!             Ok(())
46//!         })
47//!         .build()?;
48//!     unsafe { reg.register_table(builder) }
49//! }
50//! ```
51//!
52//! # Design
53//!
54//! - The `bind` closure runs exactly once per query. It receives a
55//!   [`BindInfo`], declares the output schema, reads parameters, and returns
56//!   the initial state `S`.
57//! - The `scan` closure runs repeatedly until it sets the output chunk size
58//!   to zero. It receives `&mut S` and a [`DataChunk`] for output.
59//! - Panics in user closures are caught via `std::panic::catch_unwind`; the
60//!   error is reported through `DuckDB` and the chunk size is forced to zero
61//!   to safely terminate the scan.
62//!
63//! # Threading
64//!
65//! Because `S` is only required to be `Send + 'static` (not `Sync`), the typed
66//! builder forces scans to execute on a single worker via
67//! [`InitInfo::set_max_threads`] with `1`. Extensions that want true multi-worker
68//! parallelism should continue to use the raw [`TableFunctionBuilder`] API and
69//! split state across `local_init`.
70
71use std::os::raw::c_void;
72use std::panic::{catch_unwind, AssertUnwindSafe};
73use std::sync::Mutex;
74
75use libduckdb_sys::{
76    duckdb_bind_info, duckdb_data_chunk, duckdb_data_chunk_set_size, duckdb_function_info,
77    duckdb_init_info,
78};
79
80use crate::data_chunk::DataChunk;
81use crate::error::ExtensionError;
82use crate::table::bind_data::FfiBindData;
83use crate::table::builder::TableFunctionBuilder;
84use crate::table::info::{BindInfo, FunctionInfo, InitInfo};
85use crate::table::init_data::FfiInitData;
86use crate::types::{LogicalType, TypeId};
87
88/// Boxed bind closure signature stored inside [`TypedCallbacks`].
89type BindClosure<S> = dyn Fn(&BindInfo) -> Result<S, ExtensionError> + Send + Sync + 'static;
90
91/// Boxed scan closure signature stored inside [`TypedCallbacks`].
92type ScanClosure<S> =
93    dyn Fn(&mut S, &DataChunk) -> Result<(), ExtensionError> + Send + Sync + 'static;
94
95/// Heap-allocated bundle of user closures, stored as the table function's
96/// `extra_info` so it survives across FFI callbacks.
97struct TypedCallbacks<S: Send + 'static> {
98    bind: Box<BindClosure<S>>,
99    scan: Box<ScanClosure<S>>,
100}
101
102impl<S: Send + 'static> TypedCallbacks<S> {
103    /// `extra_info` destructor passed to `DuckDB`.
104    ///
105    /// # Safety
106    ///
107    /// `ptr` must have been produced by [`Box::into_raw`] on a
108    /// `Box<TypedCallbacks<S>>` created by [`TypedTableFunctionBuilder::build`].
109    /// `DuckDB` calls this exactly once when the table function is dropped.
110    unsafe extern "C" fn destroy_extra(ptr: *mut c_void) {
111        if ptr.is_null() {
112            return;
113        }
114        // SAFETY: ptr was produced by Box::into_raw in `build`.
115        unsafe {
116            drop(Box::from_raw(ptr.cast::<Self>()));
117        }
118    }
119}
120
121/// Closure-based builder for table functions with a typed, mutable scan state.
122///
123/// Obtain one via [`TableFunctionBuilder::with_state`]. Set a scan closure with
124/// [`scan`][Self::scan] and finish with [`build`][Self::build] to recover a
125/// fully-configured [`TableFunctionBuilder`] that can be passed to any
126/// [`Registrar`][crate::connection::Registrar].
127///
128/// # Example
129///
130/// See the [module-level docs][crate::table::typed] for a complete example.
131#[must_use]
132pub struct TypedTableFunctionBuilder<S: Send + 'static> {
133    inner: TableFunctionBuilder,
134    bind: Option<Box<BindClosure<S>>>,
135    scan: Option<Box<ScanClosure<S>>>,
136}
137
138impl TableFunctionBuilder {
139    /// Switches this builder into closure-based "typed state" mode.
140    ///
141    /// The supplied `bind` closure runs once per query invocation. It must:
142    ///
143    /// - Declare the output schema via
144    ///   [`BindInfo::add_result_column`][crate::table::BindInfo::add_result_column].
145    /// - Read parameters (positional or named) from the [`BindInfo`].
146    /// - Return the initial scan state `S` on success, or an
147    ///   [`ExtensionError`] on failure. Errors are propagated to `DuckDB` via
148    ///   `duckdb_bind_set_error`.
149    ///
150    /// Continue building the function by calling [`scan`][TypedTableFunctionBuilder::scan].
151    ///
152    /// See the [module-level docs][crate::table::typed] for an end-to-end example.
153    pub fn with_state<S, F>(self, bind: F) -> TypedTableFunctionBuilder<S>
154    where
155        S: Send + 'static,
156        F: Fn(&BindInfo) -> Result<S, ExtensionError> + Send + Sync + 'static,
157    {
158        TypedTableFunctionBuilder {
159            inner: self,
160            bind: Some(Box::new(bind)),
161            scan: None,
162        }
163    }
164}
165
166impl<S: Send + 'static> TypedTableFunctionBuilder<S> {
167    /// Sets the scan closure.
168    ///
169    /// The closure receives a mutable reference to the scan state produced by
170    /// the `bind` closure, plus a [`DataChunk`] for the output chunk. It must
171    /// fill the chunk with zero or more output rows and set the chunk size via
172    /// [`DataChunk::set_size`] or a [`ChunkWriter`][crate::chunk_writer::ChunkWriter].
173    /// Returning with chunk size zero signals end-of-stream to `DuckDB`.
174    ///
175    /// Errors are reported through `duckdb_function_set_error` and terminate
176    /// the scan.
177    pub fn scan<F>(mut self, f: F) -> Self
178    where
179        F: Fn(&mut S, &DataChunk) -> Result<(), ExtensionError> + Send + Sync + 'static,
180    {
181        self.scan = Some(Box::new(f));
182        self
183    }
184
185    /// Returns the underlying function name.
186    pub fn name(&self) -> &str {
187        self.inner.name()
188    }
189
190    /// Adds a positional parameter. Delegates to [`TableFunctionBuilder::param`].
191    pub fn param(mut self, type_id: TypeId) -> Self {
192        self.inner = self.inner.param(type_id);
193        self
194    }
195
196    /// Adds a positional parameter with a complex [`LogicalType`].
197    /// Delegates to [`TableFunctionBuilder::param_logical`].
198    pub fn param_logical(mut self, logical_type: LogicalType) -> Self {
199        self.inner = self.inner.param_logical(logical_type);
200        self
201    }
202
203    /// Adds a named parameter. Delegates to [`TableFunctionBuilder::named_param`].
204    pub fn named_param(mut self, name: &str, type_id: TypeId) -> Self {
205        self.inner = self.inner.named_param(name, type_id);
206        self
207    }
208
209    /// Adds a named parameter with a complex [`LogicalType`].
210    /// Delegates to [`TableFunctionBuilder::named_param_logical`].
211    pub fn named_param_logical(mut self, name: &str, logical_type: LogicalType) -> Self {
212        self.inner = self.inner.named_param_logical(name, logical_type);
213        self
214    }
215
216    /// Enables or disables projection pushdown.
217    /// Delegates to [`TableFunctionBuilder::projection_pushdown`].
218    pub fn projection_pushdown(mut self, enable: bool) -> Self {
219        self.inner = self.inner.projection_pushdown(enable);
220        self
221    }
222
223    /// Finalises the typed builder into a raw [`TableFunctionBuilder`] ready
224    /// for registration.
225    ///
226    /// The returned builder has its `bind`, `init`, and `scan` callbacks wired
227    /// to closure trampolines, and stores the user closures in `extra_info`
228    /// for the lifetime of the registered function.
229    ///
230    /// # Errors
231    ///
232    /// Returns an error if [`scan`][Self::scan] was never called. The bind
233    /// closure is always set at construction time via
234    /// [`TableFunctionBuilder::with_state`].
235    ///
236    /// # Leak note
237    ///
238    /// On success, the returned builder owns a heap allocation that `DuckDB`
239    /// will free via the registered `extra_info` destructor when registration
240    /// succeeds. If the returned builder is dropped without being registered,
241    /// the allocation leaks (one-shot leak per unused builder; not UB).
242    pub fn build(self) -> Result<TableFunctionBuilder, ExtensionError> {
243        let bind = self
244            .bind
245            .ok_or_else(|| ExtensionError::new("typed table function: bind closure not set"))?;
246        let scan = self
247            .scan
248            .ok_or_else(|| ExtensionError::new("typed table function: scan closure not set"))?;
249
250        let cbs = Box::new(TypedCallbacks::<S> { bind, scan });
251        let raw = Box::into_raw(cbs).cast::<c_void>();
252
253        // SAFETY: `raw` is a freshly-allocated, non-null pointer to a
254        // `TypedCallbacks<S>`. `destroy_extra` drops the same type.
255        let builder = unsafe {
256            self.inner
257                .bind(typed_bind_trampoline::<S>)
258                .init(typed_init_trampoline::<S>)
259                .scan(typed_scan_trampoline::<S>)
260                .extra_info(raw, TypedCallbacks::<S>::destroy_extra)
261        };
262        Ok(builder)
263    }
264}
265
266/// Extracts a human-readable message from a `catch_unwind` panic payload.
267fn panic_message(payload: &(dyn std::any::Any + Send)) -> &'static str {
268    if payload.downcast_ref::<&'static str>().is_some()
269        || payload.downcast_ref::<String>().is_some()
270    {
271        "quack-rs: typed table function closure panicked"
272    } else {
273        "quack-rs: typed table function closure panicked (unknown payload)"
274    }
275}
276
277/// Bind trampoline monomorphised per state type `S`.
278///
279/// # Safety
280///
281/// Invoked by `DuckDB` during query parsing. `info` is a valid `duckdb_bind_info`.
282unsafe extern "C" fn typed_bind_trampoline<S: Send + 'static>(info: duckdb_bind_info) {
283    let outcome = catch_unwind(AssertUnwindSafe(|| {
284        // SAFETY: DuckDB guarantees `info` is valid for the duration of the bind callback.
285        let bind_info = unsafe { BindInfo::new(info) };
286        // SAFETY: extra_info was set by `build()` to a `Box<TypedCallbacks<S>>`.
287        let raw = unsafe { bind_info.get_extra_info() };
288        if raw.is_null() {
289            bind_info.set_error("quack-rs: typed table function missing extra_info");
290            return;
291        }
292        // SAFETY: `raw` originated from `Box::into_raw(Box::new(TypedCallbacks::<S>))`
293        // in `build()`. It remains valid until DuckDB invokes `destroy_extra`.
294        let cbs = unsafe { &*raw.cast::<TypedCallbacks<S>>() };
295
296        match (cbs.bind)(&bind_info) {
297            Ok(state) => {
298                // SAFETY: `info` is valid; this is the bind callback's single
299                // opportunity to set bind data. We wrap the state in a
300                // Mutex<Option<_>> so the init trampoline can take it out.
301                unsafe {
302                    FfiBindData::<Mutex<Option<S>>>::set(info, Mutex::new(Some(state)));
303                }
304            }
305            Err(e) => bind_info.set_error(e.as_str()),
306        }
307    }));
308
309    if let Err(payload) = outcome {
310        // SAFETY: `info` is valid.
311        let bind_info = unsafe { BindInfo::new(info) };
312        bind_info.set_error(panic_message(&*payload));
313    }
314}
315
316/// Init trampoline monomorphised per state type `S`.
317///
318/// Moves the state produced during `bind` into the init-data slot so the
319/// scan trampoline can read `&mut S`.
320///
321/// # Safety
322///
323/// Invoked by `DuckDB` once per query. `info` is a valid `duckdb_init_info`.
324unsafe extern "C" fn typed_init_trampoline<S: Send + 'static>(info: duckdb_init_info) {
325    let outcome = catch_unwind(AssertUnwindSafe(|| {
326        // SAFETY: `info` is valid for the duration of this callback.
327        let init_info = unsafe { InitInfo::new(info) };
328
329        // SAFETY: bind_data was set by `typed_bind_trampoline` as a
330        // `Mutex<Option<S>>`. It remains alive until query teardown.
331        let bind_state = unsafe { FfiBindData::<Mutex<Option<S>>>::get_from_init(info) };
332
333        let Some(cell) = bind_state else {
334            init_info.set_error("quack-rs: typed table function missing bind state");
335            return;
336        };
337
338        let taken = if let Ok(mut guard) = cell.lock() {
339            guard.take()
340        } else {
341            init_info.set_error("quack-rs: typed table function bind-state mutex poisoned");
342            return;
343        };
344
345        let Some(state) = taken else {
346            init_info.set_error("quack-rs: typed table function bind state already consumed");
347            return;
348        };
349
350        // SAFETY: `info` is valid; `FfiInitData::set` boxes the state and
351        // registers a drop-on-destroy callback with DuckDB.
352        unsafe {
353            FfiInitData::<S>::set(info, state);
354        }
355
356        // `S` is only `Send`, not `Sync`, so we cannot safely share it across
357        // parallel scan workers. Force serial scans.
358        init_info.set_max_threads(1);
359    }));
360
361    if let Err(payload) = outcome {
362        // SAFETY: `info` is valid.
363        let init_info = unsafe { InitInfo::new(info) };
364        init_info.set_error(panic_message(&*payload));
365    }
366}
367
368/// Scan trampoline monomorphised per state type `S`.
369///
370/// # Safety
371///
372/// Invoked by `DuckDB` repeatedly until the chunk size is set to zero.
373unsafe extern "C" fn typed_scan_trampoline<S: Send + 'static>(
374    info: duckdb_function_info,
375    output: duckdb_data_chunk,
376) {
377    let outcome = catch_unwind(AssertUnwindSafe(|| {
378        // SAFETY: `info` is valid per DuckDB contract.
379        let fninfo = unsafe { FunctionInfo::new(info) };
380        // SAFETY: extra_info was set by `build()`.
381        let raw = unsafe { fninfo.get_extra_info() };
382        if raw.is_null() {
383            fninfo.set_error("quack-rs: typed table function missing extra_info");
384            // SAFETY: `output` is a valid data chunk.
385            unsafe { duckdb_data_chunk_set_size(output, 0) };
386            return;
387        }
388        // SAFETY: same provenance as the bind trampoline.
389        let cbs = unsafe { &*raw.cast::<TypedCallbacks<S>>() };
390
391        // SAFETY: init_data was set by `typed_init_trampoline`; the scan
392        // runs serialised (set_max_threads(1)), so no aliasing &mut exists.
393        let state = unsafe { FfiInitData::<S>::get_mut(info) };
394        let Some(state) = state else {
395            fninfo.set_error("quack-rs: typed table function missing scan state");
396            // SAFETY: `output` is valid.
397            unsafe { duckdb_data_chunk_set_size(output, 0) };
398            return;
399        };
400
401        // SAFETY: `output` is a valid data chunk provided by DuckDB.
402        let chunk = unsafe { DataChunk::from_raw(output) };
403        if let Err(e) = (cbs.scan)(state, &chunk) {
404            fninfo.set_error(e.as_str());
405            // SAFETY: `output` is valid.
406            unsafe { duckdb_data_chunk_set_size(output, 0) };
407        }
408    }));
409
410    if let Err(payload) = outcome {
411        // SAFETY: `info` is valid.
412        let fninfo = unsafe { FunctionInfo::new(info) };
413        fninfo.set_error(panic_message(&*payload));
414        // SAFETY: `output` is valid.
415        unsafe { duckdb_data_chunk_set_size(output, 0) };
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    struct DummyState {
424        _rows: u64,
425    }
426
427    #[test]
428    fn with_state_produces_typed_builder() {
429        let typed = TableFunctionBuilder::new("demo")
430            .with_state::<DummyState, _>(|_bind| Ok(DummyState { _rows: 10 }));
431        assert_eq!(typed.name(), "demo");
432        assert!(typed.bind.is_some());
433        assert!(typed.scan.is_none());
434    }
435
436    #[test]
437    fn build_without_scan_errors() {
438        let typed = TableFunctionBuilder::new("demo")
439            .with_state::<DummyState, _>(|_bind| Ok(DummyState { _rows: 10 }));
440        match typed.build() {
441            Err(e) => assert!(e.as_str().contains("scan closure not set")),
442            Ok(_) => panic!("expected error"),
443        }
444    }
445
446    #[test]
447    fn build_with_bind_and_scan_succeeds() {
448        let typed = TableFunctionBuilder::new("demo")
449            .param(TypeId::BigInt)
450            .with_state::<DummyState, _>(|_bind| Ok(DummyState { _rows: 10 }))
451            .scan(|_state, _chunk| Ok(()));
452        let builder = typed.build().expect("build should succeed");
453        assert_eq!(builder.name(), "demo");
454    }
455
456    #[test]
457    fn passthroughs_mutate_inner_builder() {
458        let typed = TableFunctionBuilder::new("demo")
459            .with_state::<DummyState, _>(|_| Ok(DummyState { _rows: 0 }))
460            .param(TypeId::Varchar)
461            .named_param("path", TypeId::Varchar)
462            .projection_pushdown(true);
463        assert_eq!(typed.name(), "demo");
464    }
465
466    #[test]
467    fn destroy_extra_null_is_noop() {
468        // Must not panic.
469        unsafe {
470            TypedCallbacks::<DummyState>::destroy_extra(std::ptr::null_mut());
471        }
472    }
473
474    #[test]
475    fn destroy_extra_drops_box() {
476        let cbs: Box<TypedCallbacks<DummyState>> = Box::new(TypedCallbacks {
477            bind: Box::new(|_| Ok(DummyState { _rows: 0 })),
478            scan: Box::new(|_, _| Ok(())),
479        });
480        let raw = Box::into_raw(cbs).cast::<c_void>();
481        unsafe { TypedCallbacks::<DummyState>::destroy_extra(raw) };
482    }
483
484    #[test]
485    fn panic_message_classifies_known_payloads() {
486        let s: Box<dyn std::any::Any + Send> = Box::new("boom");
487        assert!(panic_message(&*s).contains("panicked"));
488        let s: Box<dyn std::any::Any + Send> = Box::new(String::from("boom"));
489        assert!(panic_message(&*s).contains("panicked"));
490        // Unknown payload falls through to the "unknown payload" branch.
491        let s: Box<dyn std::any::Any + Send> = Box::new(42_i32);
492        assert!(panic_message(&*s).contains("unknown payload"));
493    }
494}