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);