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}