Skip to main content

quack_rs/
table_description.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//! Table description metadata.
7//!
8//! Allows querying a table's structure — column names, whether a column has a
9//! `DEFAULT`, and (with `duckdb-1-5`) the column count and types — at runtime
10//! from within an extension. Useful for replacement scans, table functions, and
11//! copy functions that need to inspect existing tables before deciding what to
12//! do.
13//!
14//! # Feature flags
15//!
16//! Creating and naming columns needs no feature flag: `duckdb_table_description_*`
17//! has been in the frozen stable prefix of the extension API (slots 292–297)
18//! since v1.2.0. Two accessors are `DuckDB` 1.5.0 additions living in the
19//! unstable region and are gated on `duckdb-1-5`:
20//! [`column_count`][TableDescription::column_count] and
21//! [`column_type`][TableDescription::column_type].
22//!
23//! # Example
24//!
25//! ```rust,no_run
26//! use quack_rs::table_description::TableDescription;
27//!
28//! // From within a function callback with a valid connection:
29//! // let desc = unsafe { TableDescription::create(con, "main", "my_table")? };
30//! // let first = desc.column_name(0);
31//! ```
32
33use std::ffi::{CStr, CString};
34
35use libduckdb_sys::{
36    duckdb_column_has_default, duckdb_connection, duckdb_table_description,
37    duckdb_table_description_create, duckdb_table_description_create_ext,
38    duckdb_table_description_destroy, duckdb_table_description_error,
39    duckdb_table_description_get_column_name, idx_t, DuckDBSuccess,
40};
41#[cfg(feature = "duckdb-1-5")]
42use libduckdb_sys::{
43    duckdb_table_description_get_column_count, duckdb_table_description_get_column_type,
44};
45
46use crate::error::ExtensionError;
47#[cfg(feature = "duckdb-1-5")]
48use crate::types::LogicalType;
49
50/// RAII wrapper for a `duckdb_table_description`.
51///
52/// Provides metadata about a table's columns. Automatically destroyed on drop.
53pub struct TableDescription {
54    desc: duckdb_table_description,
55}
56
57impl TableDescription {
58    /// Creates a table description for the given schema and table.
59    ///
60    /// # Errors
61    ///
62    /// Returns `ExtensionError` if the table does not exist or cannot be described.
63    ///
64    /// # Safety
65    ///
66    /// `con` must be a valid, open `duckdb_connection`.
67    pub unsafe fn create(
68        con: duckdb_connection,
69        schema: &str,
70        table: &str,
71    ) -> Result<Self, ExtensionError> {
72        let c_schema = CString::new(schema)
73            .map_err(|_| ExtensionError::new("schema name contains null byte"))?;
74        let c_table = CString::new(table)
75            .map_err(|_| ExtensionError::new("table name contains null byte"))?;
76
77        let mut desc: duckdb_table_description = core::ptr::null_mut();
78        // SAFETY: con is valid per caller's contract.
79        let rc = unsafe {
80            duckdb_table_description_create(con, c_schema.as_ptr(), c_table.as_ptr(), &raw mut desc)
81        };
82
83        // SAFETY: `desc` is whatever DuckDB wrote; the helper takes ownership
84        // from here, including destroying it on the error path.
85        unsafe { Self::from_create_result(rc, desc, schema, table) }
86    }
87
88    /// Turns a `duckdb_table_description_create*` outcome into a `Result`.
89    ///
90    /// `duckdb.h` requires `duckdb_table_description_destroy` to be called on
91    /// the result "even if the function returns `DuckDBError`", so the failure
92    /// path must destroy the handle rather than simply dropping it — and it
93    /// must read the error message first, because destroying frees it.
94    ///
95    /// # Safety
96    ///
97    /// `desc` must be the out-parameter of a `duckdb_table_description_create*`
98    /// call that returned `rc`, and must not be used by the caller afterwards.
99    unsafe fn from_create_result(
100        rc: libduckdb_sys::duckdb_state,
101        mut desc: duckdb_table_description,
102        schema: &str,
103        table: &str,
104    ) -> Result<Self, ExtensionError> {
105        if rc == DuckDBSuccess && !desc.is_null() {
106            return Ok(Self { desc });
107        }
108        let mut message = format!("failed to describe table '{schema}.{table}'");
109        if !desc.is_null() {
110            // SAFETY: desc is non-null and was produced by a create call.
111            let err_ptr = unsafe { duckdb_table_description_error(desc) };
112            if !err_ptr.is_null() {
113                // SAFETY: err_ptr is a NUL-terminated string owned by the
114                // description; it stays valid until the destroy below.
115                let detail = unsafe { CStr::from_ptr(err_ptr) }
116                    .to_str()
117                    .unwrap_or("unknown error");
118                message.push_str(": ");
119                message.push_str(detail);
120            }
121            // SAFETY: desc is a non-null handle we own and have not returned.
122            unsafe { duckdb_table_description_destroy(&raw mut desc) };
123        }
124        Err(ExtensionError::new(message))
125    }
126
127    /// Creates a table description, fully qualified by optional `catalog` and
128    /// `schema`.
129    ///
130    /// `None` means "the default", matching `duckdb_table_description_create_ext`.
131    ///
132    /// # Errors
133    ///
134    /// Returns `ExtensionError` if any name contains an interior NUL byte, or
135    /// if the table does not exist or cannot be described.
136    ///
137    /// # Safety
138    ///
139    /// `con` must be a valid, open `duckdb_connection`.
140    pub unsafe fn with_catalog(
141        con: duckdb_connection,
142        catalog: Option<&str>,
143        schema: Option<&str>,
144        table: &str,
145    ) -> Result<Self, ExtensionError> {
146        fn to_c(label: &str, value: Option<&str>) -> Result<Option<CString>, ExtensionError> {
147            value
148                .map(|v| {
149                    CString::new(v)
150                        .map_err(|_| ExtensionError::new(format!("{label} contains null byte")))
151                })
152                .transpose()
153        }
154        let c_catalog = to_c("catalog name", catalog)?;
155        let c_schema = to_c("schema name", schema)?;
156        let c_table = CString::new(table)
157            .map_err(|_| ExtensionError::new("table name contains null byte"))?;
158        let ptr = |c: &Option<CString>| c.as_ref().map_or(core::ptr::null(), |v| v.as_ptr());
159
160        let mut desc: duckdb_table_description = core::ptr::null_mut();
161        // SAFETY: con is valid per caller's contract; each pointer is either
162        // null (meaning "default") or a NUL-terminated string alive for the call.
163        let rc = unsafe {
164            duckdb_table_description_create_ext(
165                con,
166                ptr(&c_catalog),
167                ptr(&c_schema),
168                c_table.as_ptr(),
169                &raw mut desc,
170            )
171        };
172        // SAFETY: `desc` is whatever DuckDB wrote; the helper takes it from here,
173        // including destroying it on the error path as duckdb.h requires.
174        unsafe { Self::from_create_result(rc, desc, schema.unwrap_or("<default>"), table) }
175    }
176
177    /// Returns the number of columns in the table.
178    #[cfg(feature = "duckdb-1-5")]
179    #[must_use]
180    pub fn column_count(&self) -> idx_t {
181        // SAFETY: self.desc is valid.
182        unsafe { duckdb_table_description_get_column_count(self.desc) }
183    }
184
185    /// Returns the name of the column at the given index.
186    ///
187    /// Returns `None` if the index is out of bounds or the name is not valid UTF-8.
188    #[must_use]
189    pub fn column_name(&self, index: idx_t) -> Option<String> {
190        // SAFETY: self.desc is valid. `DuckDB` returns a newly allocated string.
191        let ptr = unsafe { duckdb_table_description_get_column_name(self.desc, index) };
192        if ptr.is_null() {
193            return None;
194        }
195        // SAFETY: ptr is a valid null-terminated string allocated by `DuckDB`.
196        let result = unsafe { CStr::from_ptr(ptr) }
197            .to_str()
198            .ok()
199            .map(String::from);
200        // SAFETY: `duckdb_table_description_get_column_name` returns a `char *`
201        // DuckDB allocated (`malloc` + `memcpy`), so this owns it and must free
202        // it. Contrast `duckdb_table_description_error`, which returns a
203        // borrowed `const char *` and must not be freed — see LESSONS.md P11.
204        unsafe {
205            libduckdb_sys::duckdb_free(ptr.cast::<core::ffi::c_void>());
206        }
207        result
208    }
209
210    /// Returns the logical type of the column at the given index.
211    ///
212    /// Returns `None` if the index is out of bounds. The returned [`LogicalType`]
213    /// is RAII-managed and will be destroyed automatically on drop.
214    #[cfg(feature = "duckdb-1-5")]
215    #[must_use]
216    pub fn column_type(&self, index: idx_t) -> Option<LogicalType> {
217        // SAFETY: self.desc is valid.
218        let lt = unsafe { duckdb_table_description_get_column_type(self.desc, index) };
219        if lt.is_null() {
220            None
221        } else {
222            // SAFETY: lt is a freshly created handle from duckdb_table_description_get_column_type.
223            Some(unsafe { LogicalType::from_raw(lt) })
224        }
225    }
226
227    /// Returns whether the column at `index` has a `DEFAULT` value.
228    ///
229    /// Returns `None` if the index is out of bounds. This is what makes
230    /// [`Appender::append_default`][crate::appender::Appender::append_default]
231    /// safe to reach for: appending a default to a column that has none is an
232    /// error, and this is the only way to find out first.
233    #[must_use]
234    pub fn column_has_default(&self, index: idx_t) -> Option<bool> {
235        let mut out = false;
236        // SAFETY: self.desc is valid; DuckDB bounds-checks `index` and reports
237        // failure through the return state rather than writing `out`.
238        let state = unsafe { duckdb_column_has_default(self.desc, index, &raw mut out) };
239        if state == DuckDBSuccess {
240            Some(out)
241        } else {
242            None
243        }
244    }
245
246    /// Returns the raw `duckdb_table_description` handle without consuming the
247    /// wrapper. The wrapper retains ownership and destroys it on drop.
248    #[inline]
249    #[must_use]
250    pub const fn as_raw(&self) -> duckdb_table_description {
251        self.desc
252    }
253}
254
255impl Drop for TableDescription {
256    fn drop(&mut self) {
257        if !self.desc.is_null() {
258            // SAFETY: self.desc is a non-null handle obtained from
259            // duckdb_table_description_create.
260            unsafe {
261                duckdb_table_description_destroy(&raw mut self.desc);
262            }
263        }
264    }
265}
266
267#[cfg(all(test, feature = "_duckdb-testing"))]
268mod tests {
269    use super::*;
270
271    /// Opens a raw `duckdb_connection` for testing.
272    ///
273    /// Uses `InMemoryDb::open()` to ensure the dispatch table is initialized,
274    /// then opens a separate raw database + connection via `libduckdb_sys`.
275    fn open_raw_connection() -> (libduckdb_sys::duckdb_database, duckdb_connection) {
276        // Ensure dispatch table is populated.
277        let _db = crate::testing::InMemoryDb::open().unwrap();
278
279        let mut db: libduckdb_sys::duckdb_database = core::ptr::null_mut();
280        let mut con: duckdb_connection = core::ptr::null_mut();
281
282        // SAFETY: dispatch table is initialized, nullptr opens in-memory.
283        unsafe {
284            let rc = libduckdb_sys::duckdb_open(core::ptr::null(), &raw mut db);
285            assert_eq!(rc, libduckdb_sys::DuckDBSuccess, "duckdb_open failed");
286            let rc = libduckdb_sys::duckdb_connect(db, &raw mut con);
287            assert_eq!(rc, libduckdb_sys::DuckDBSuccess, "duckdb_connect failed");
288        }
289        (db, con)
290    }
291
292    /// Closes a raw connection and database.
293    ///
294    /// # Safety
295    ///
296    /// `con` and `db` must be valid handles from `open_raw_connection`.
297    unsafe fn close_raw_connection(
298        mut con: duckdb_connection,
299        mut db: libduckdb_sys::duckdb_database,
300    ) {
301        unsafe {
302            libduckdb_sys::duckdb_disconnect(&raw mut con);
303            libduckdb_sys::duckdb_close(&raw mut db);
304        }
305    }
306
307    #[test]
308    fn describe_existing_table() {
309        let (db, con) = open_raw_connection();
310
311        // Create a table to describe.
312        let sql = c"CREATE TABLE test_tbl (id INTEGER, name VARCHAR, score DOUBLE)";
313        // SAFETY: con is valid.
314        unsafe {
315            let rc = libduckdb_sys::duckdb_query(con, sql.as_ptr(), core::ptr::null_mut());
316            assert_eq!(rc, libduckdb_sys::DuckDBSuccess, "CREATE TABLE failed");
317        }
318
319        // SAFETY: con is valid, table exists.
320        let desc = unsafe { TableDescription::create(con, "main", "test_tbl") };
321        assert!(desc.is_ok(), "describe should succeed: {:?}", desc.err());
322        let desc = desc.unwrap();
323
324        // `column_count` and `column_type` are the two DuckDB 1.5 additions in
325        // this module; the names and defaults are stable-prefix.
326        #[cfg(feature = "duckdb-1-5")]
327        assert_eq!(desc.column_count(), 3);
328
329        assert_eq!(desc.column_name(0), Some("id".to_string()));
330        assert_eq!(desc.column_name(1), Some("name".to_string()));
331        assert_eq!(desc.column_name(2), Some("score".to_string()));
332
333        // Out-of-bounds index should return None.
334        assert_eq!(desc.column_name(99), None);
335
336        #[cfg(feature = "duckdb-1-5")]
337        {
338            // Column types should be non-null.
339            let lt0 = desc.column_type(0);
340            assert!(lt0.is_some(), "column_type(0) should be Some");
341            // LogicalType is RAII — automatically destroyed on drop.
342            drop(lt0);
343
344            // Out-of-bounds column type should return None.
345            assert!(desc.column_type(99).is_none());
346        }
347
348        drop(desc);
349        // SAFETY: valid handles.
350        unsafe { close_raw_connection(con, db) };
351    }
352
353    #[test]
354    fn describe_nonexistent_table_returns_error() {
355        let (db, con) = open_raw_connection();
356
357        // SAFETY: con is valid, table does NOT exist.
358        let result = unsafe { TableDescription::create(con, "main", "no_such_table") };
359        assert!(result.is_err());
360        let err_msg = result.err().unwrap().to_string();
361        assert!(
362            err_msg.contains("no_such_table"),
363            "error should mention table name, got: {err_msg}"
364        );
365
366        // SAFETY: valid handles.
367        unsafe { close_raw_connection(con, db) };
368    }
369
370    #[test]
371    fn describe_schema_null_byte_rejected() {
372        let (db, con) = open_raw_connection();
373
374        // SAFETY: con is valid.
375        let result = unsafe { TableDescription::create(con, "bad\0schema", "t") };
376        assert!(result.is_err());
377        assert!(result.err().unwrap().to_string().contains("null byte"));
378
379        // SAFETY: valid handles.
380        unsafe { close_raw_connection(con, db) };
381    }
382
383    #[test]
384    fn describe_table_null_byte_rejected() {
385        let (db, con) = open_raw_connection();
386
387        // SAFETY: con is valid.
388        let result = unsafe { TableDescription::create(con, "main", "bad\0table") };
389        assert!(result.is_err());
390        assert!(result.err().unwrap().to_string().contains("null byte"));
391
392        // SAFETY: valid handles.
393        unsafe { close_raw_connection(con, db) };
394    }
395}
396
397crate::debug_repr::impl_handle_debug!(TableDescription.desc);