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}