Skip to main content

better_duck_core/raw/
table_description.rs

1//! RAII wrapper for `duckdb_table_description` — indexed column metadata for a
2//! catalog table.
3//!
4//! DuckDB's table-description API exposes a table's column *names* and whether each
5//! column has a `DEFAULT` expression, addressed by index. The retained C-API set has
6//! **no** column-count or per-column-type accessor, so this wrapper deliberately does
7//! not invent one: a caller obtains the number of columns from a finite bounds source
8//! it already trusts (an appender's column count, or a catalog query such as
9//! `SELECT * FROM t LIMIT 0`) and then reads names/defaults for `0..count`.
10//!
11//! The handle is owned: [`TableDescription`] destroys it exactly once on drop, and a
12//! creation failure still yields a handle whose [`error`](TableDescription::error)
13//! carries DuckDB's catalog-aware message before it is destroyed.
14// FFI pointer args are used safely inside `unsafe` blocks.
15#![allow(clippy::not_unsafe_ptr_arg_deref)]
16
17use std::ffi::{CStr, CString};
18use std::os::raw::c_void;
19use std::ptr;
20
21use crate::{
22    error::{Error, Result},
23    ffi,
24    helpers::duck_result::check_state,
25};
26
27/// An owned `duckdb_table_description` with indexed column-name/default access.
28pub struct TableDescription {
29    inner: ffi::duckdb_table_description,
30}
31
32impl std::fmt::Debug for TableDescription {
33    fn fmt(
34        &self,
35        f: &mut std::fmt::Formatter<'_>,
36    ) -> std::fmt::Result {
37        f.debug_struct("TableDescription").finish_non_exhaustive()
38    }
39}
40
41impl TableDescription {
42    /// Creates a description for `schema.table` on `con` (default catalog).
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if `schema`/`table` contain an interior NUL, or if DuckDB
47    /// cannot describe the table (e.g. it does not exist) — the error carries
48    /// DuckDB's catalog-aware message.
49    pub(crate) fn create(
50        con: ffi::duckdb_connection,
51        schema: &str,
52        table: &str,
53    ) -> Result<TableDescription> {
54        let c_schema = CString::new(schema)?;
55        let c_table = CString::new(table)?;
56        let mut out: ffi::duckdb_table_description = ptr::null_mut();
57        // SAFETY: `con` is a valid connection; the name pointers are valid and outlive
58        // the call; `out` is a valid out-pointer DuckDB writes the new handle into.
59        let state = unsafe {
60            ffi::duckdb_table_description_create(con, c_schema.as_ptr(), c_table.as_ptr(), &mut out)
61        };
62        Self::from_create(state, out)
63    }
64
65    /// Creates a description for `[catalog.]schema.table` on `con`. A `None` catalog
66    /// uses DuckDB's default.
67    ///
68    /// # Errors
69    ///
70    /// As [`create`](TableDescription::create), plus an interior NUL in `catalog`.
71    pub(crate) fn create_ext(
72        con: ffi::duckdb_connection,
73        catalog: Option<&str>,
74        schema: &str,
75        table: &str,
76    ) -> Result<TableDescription> {
77        let c_catalog = catalog.map(CString::new).transpose()?;
78        let c_schema = CString::new(schema)?;
79        let c_table = CString::new(table)?;
80        let catalog_ptr = c_catalog.as_ref().map_or(ptr::null(), |c| c.as_ptr());
81        let mut out: ffi::duckdb_table_description = ptr::null_mut();
82        // SAFETY: `con` is valid; the (optional) catalog/schema/table pointers are
83        // valid null-terminated strings (or null for the default catalog) that outlive
84        // the call; `out` receives the new handle.
85        let state = unsafe {
86            ffi::duckdb_table_description_create_ext(
87                con,
88                catalog_ptr,
89                c_schema.as_ptr(),
90                c_table.as_ptr(),
91                &mut out,
92            )
93        };
94        Self::from_create(state, out)
95    }
96
97    /// Wraps the `(state, out)` of a create call, surfacing a creation error via the
98    /// handle's own message and always taking ownership so the handle is destroyed.
99    fn from_create(
100        state: ffi::duckdb_state,
101        out: ffi::duckdb_table_description,
102    ) -> Result<TableDescription> {
103        if out.is_null() {
104            return Err(Error::DuckDBFailure(
105                ffi::Error::new(state),
106                Some("failed to create table description".to_owned()),
107            ));
108        }
109        let desc = TableDescription { inner: out };
110        if state != ffi::DuckDBSuccess {
111            let message =
112                desc.error().unwrap_or_else(|| "failed to create table description".to_owned());
113            // `desc` drops here on the early return, destroying the handle exactly once.
114            return Err(Error::DuckDBFailure(ffi::Error::new(state), Some(message)));
115        }
116        Ok(desc)
117    }
118
119    /// The description's error message, if any.
120    ///
121    /// (`duckdb_table_description_error`.) The returned string is owned by the
122    /// description and freed on destroy; it is copied out here and never freed.
123    #[must_use]
124    pub fn error(&self) -> Option<String> {
125        // SAFETY: `self.inner` is a valid table description; the returned pointer is a
126        // borrowed string owned by it (must NOT be freed) or null.
127        let ptr = unsafe { ffi::duckdb_table_description_error(self.inner) };
128        if ptr.is_null() {
129            return None;
130        }
131        // SAFETY: `ptr` is a valid, non-null, null-terminated C string owned by `self`.
132        Some(unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned())
133    }
134
135    /// The name of the column at `index`, or `None` if DuckDB returns no name.
136    ///
137    /// `index` must be within the table's column count (obtained from a trusted
138    /// bounds source — see the module docs); the returned name is owned.
139    #[must_use]
140    pub fn column_name(
141        &self,
142        index: u64,
143    ) -> Option<String> {
144        // SAFETY: `self.inner` is a valid table description; `duckdb_..._get_column_name`
145        // returns a heap `char*` (freed with `duckdb_free`) or null.
146        let ptr = unsafe {
147            ffi::duckdb_table_description_get_column_name(self.inner, index as ffi::idx_t)
148        };
149        if ptr.is_null() {
150            return None;
151        }
152        // SAFETY: `ptr` is a valid, non-null, null-terminated C string.
153        let name = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned();
154        // SAFETY: `ptr` was allocated by DuckDB and ownership transferred to us.
155        unsafe { ffi::duckdb_free(ptr as *mut c_void) };
156        Some(name)
157    }
158
159    /// Whether the column at `index` has a `DEFAULT` expression.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if DuckDB reports failure for `index` (e.g. out of range).
164    pub fn column_has_default(
165        &self,
166        index: u64,
167    ) -> Result<bool> {
168        let mut out = false;
169        // SAFETY: `self.inner` is a valid table description; `out` is a valid bool
170        // out-pointer DuckDB writes into. A failure (bad index) yields `DuckDBError`.
171        let state =
172            unsafe { ffi::duckdb_column_has_default(self.inner, index as ffi::idx_t, &mut out) };
173        check_state(state)?;
174        Ok(out)
175    }
176}
177
178impl Drop for TableDescription {
179    fn drop(&mut self) {
180        if !self.inner.is_null() {
181            // SAFETY: `self.inner` is a valid handle created by one of the create
182            // functions and not yet destroyed; `duckdb_table_description_destroy` is
183            // called exactly once here and nulls the pointer.
184            unsafe { ffi::duckdb_table_description_destroy(&mut self.inner) };
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::connection::Connection;
193
194    #[test]
195    fn describes_columns_names_and_defaults() {
196        let mut conn = Connection::open_in_memory().unwrap();
197        conn.execute_batch("CREATE TABLE t (id INTEGER, name VARCHAR DEFAULT 'x')").unwrap();
198        let desc = conn.table_description("main", "t").unwrap();
199        assert!(desc.error().is_none());
200
201        // Indexed name access (bounds known from the DDL: 2 columns).
202        assert_eq!(desc.column_name(0).as_deref(), Some("id"));
203        assert_eq!(desc.column_name(1).as_deref(), Some("name"));
204
205        // `id` has no DEFAULT; `name` does.
206        assert!(!desc.column_has_default(0).unwrap());
207        assert!(desc.column_has_default(1).unwrap());
208    }
209
210    #[test]
211    fn create_ext_with_default_catalog_matches_create() {
212        let mut conn = Connection::open_in_memory().unwrap();
213        conn.execute_batch("CREATE TABLE t2 (a INTEGER)").unwrap();
214        let desc = conn.table_description_ext(None, "main", "t2").unwrap();
215        assert_eq!(desc.column_name(0).as_deref(), Some("a"));
216    }
217
218    #[test]
219    fn missing_table_is_a_catalog_error() {
220        let conn = Connection::open_in_memory().unwrap();
221        let err = conn.table_description("main", "does_not_exist").unwrap_err();
222        // The message is DuckDB's own catalog-aware text (mentions the missing table).
223        let msg = format!("{err:?}");
224        assert!(msg.contains("does_not_exist") || msg.to_lowercase().contains("table"), "{msg}");
225    }
226
227    #[test]
228    fn rejects_interior_nul_in_names() {
229        let conn = Connection::open_in_memory().unwrap();
230        assert!(matches!(conn.table_description("main", "t\0x"), Err(Error::NulError(_))));
231    }
232}