Skip to main content

better_duck_core/udf/cast/
mod.rs

1//! DuckDB custom cast functions: register a conversion from one logical type to
2//! another, used implicitly by the binder or explicitly via `CAST`.
3//!
4//! A cast is defined by its source/target types, an implicit-cast cost (how eagerly
5//! the binder applies it), and a per-row conversion. DuckDB runs the conversion in
6//! two *modes*: a normal `CAST` (a conversion error fails the query) and a `TRY_CAST`
7//! (a conversion error yields `NULL` for that row instead). [`VCast::cast_row`]
8//! reports a row error and the trampoline routes it per the active mode.
9// FFI pointer args are used safely inside `unsafe` blocks.
10#![allow(clippy::not_unsafe_ptr_arg_deref)]
11
12mod function;
13
14use crate::{
15    connection::Connection,
16    error::Result,
17    ffi::{duckdb_cast_mode_DUCKDB_CAST_TRY, duckdb_function_info, duckdb_vector, idx_t},
18};
19
20use self::function::{CastFunction, CastFunctionInfo};
21use super::{vector::VectorMut, vector::VectorRef};
22use crate::types::LogicalType;
23
24/// A DuckDB custom cast: converts values of a source logical type to a target type.
25///
26/// See the callback-containment contract in [`crate::udf`].
27pub trait VCast: Sized {
28    /// Registration-time shared state, available read-only to the cast callback.
29    /// Use `()` if the cast needs none.
30    type Shared: Send + Sync + 'static;
31
32    /// The source (input) logical type.
33    ///
34    /// # Errors
35    /// Returns an error if the type cannot be built.
36    fn source_type() -> Result<LogicalType>;
37
38    /// The target (output) logical type.
39    ///
40    /// # Errors
41    /// Returns an error if the type cannot be built.
42    fn target_type() -> Result<LogicalType>;
43
44    /// The implicit-cast cost the binder uses to choose this cast (lower = preferred;
45    /// a negative value disables implicit application, requiring an explicit `CAST`).
46    /// Defaults to `-1` (explicit only).
47    fn implicit_cast_cost() -> i64 {
48        -1
49    }
50
51    /// Converts `input[row]` and writes it to `output[row]`.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error for a value that cannot be converted. Under a normal `CAST`
56    /// the query fails with that message; under `TRY_CAST` the output row is set to
57    /// `NULL` instead.
58    fn cast_row(
59        shared: &Self::Shared,
60        input: &VectorRef<'_>,
61        output: &mut VectorMut<'_>,
62        row: usize,
63    ) -> super::UdfResult<()>;
64}
65
66/// The C trampoline installed via `duckdb_cast_function_set_function`.
67///
68/// Returns `true` on full success. On a per-row conversion error it consults the
69/// active cast mode: `TRY` sets that output row to `NULL` and continues; a normal
70/// cast reports the error and returns `false`. A panic is contained the same way as
71/// a hard error.
72unsafe extern "C" fn cast_trampoline<C: VCast>(
73    info: duckdb_function_info,
74    count: idx_t,
75    input: duckdb_vector,
76    output: duckdb_vector,
77) -> bool {
78    let sink = CastFunctionInfo::from(info);
79    // SAFETY: the extra-info stored at registration has type `C::Shared`.
80    let shared = unsafe { sink.extra_info::<C::Shared>() };
81    // SAFETY: DuckDB owns `input`/`output` for the call and guarantees they stay live.
82    let in_vec = unsafe { VectorRef::new(input) };
83    let is_try = sink.cast_mode() == duckdb_cast_mode_DUCKDB_CAST_TRY;
84
85    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
86        // SAFETY: `output` is a valid, live output vector owned by DuckDB for the call.
87        let mut out = unsafe { VectorMut::new(output) };
88        for row in 0..count as usize {
89            if let Err(e) = C::cast_row(shared, &in_vec, &mut out, row) {
90                if is_try {
91                    // TRY_CAST: this row becomes NULL; keep converting the rest.
92                    sink.set_row_error(&e.to_string(), row as idx_t, output);
93                } else {
94                    // Normal CAST: fail the whole cast with this message.
95                    sink.set_error(&e.to_string());
96                    return false;
97                }
98            }
99        }
100        true
101    }));
102
103    match result {
104        Ok(ok) => ok,
105        Err(_) => {
106            sink.set_error("cast function panicked");
107            false
108        },
109    }
110}
111
112impl Connection {
113    /// Registers `C` as a custom cast, with a default shared state.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if a logical type cannot be built or DuckDB rejects the
118    /// registration.
119    pub fn register_cast_function<C: VCast>(&mut self) -> Result<()>
120    where
121        C::Shared: Default,
122    {
123        self.register_cast_function_with_state::<C>(C::Shared::default())
124    }
125
126    /// Registers `C` as a custom cast, with an explicit shared state.
127    ///
128    /// # Errors
129    ///
130    /// As [`register_cast_function`](Connection::register_cast_function).
131    pub fn register_cast_function_with_state<C: VCast>(
132        &mut self,
133        shared: C::Shared,
134    ) -> Result<()> {
135        let f = CastFunction::new();
136        f.set_source_type(&C::source_type()?);
137        f.set_target_type(&C::target_type()?);
138        f.set_implicit_cast_cost(C::implicit_cast_cost());
139        f.set_extra_info(shared);
140        f.set_function(Some(cast_trampoline::<C>));
141        f.register(self.raw_con())
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::types::value::DuckValue;
149
150    /// Casts VARCHAR → INTEGER by parsing; an unparseable value is a row error
151    /// (NULL under TRY_CAST, a query failure under a normal CAST).
152    struct StrToInt;
153
154    impl VCast for StrToInt {
155        type Shared = ();
156
157        fn source_type() -> Result<LogicalType> {
158            LogicalType::of::<String>()
159        }
160
161        fn target_type() -> Result<LogicalType> {
162            LogicalType::of::<i32>()
163        }
164
165        fn implicit_cast_cost() -> i64 {
166            -1
167        }
168
169        fn cast_row(
170            _shared: &(),
171            input: &VectorRef<'_>,
172            output: &mut VectorMut<'_>,
173            row: usize,
174        ) -> super::super::UdfResult<()> {
175            let s: &str = input.get(row)?;
176            let v: i32 = s.trim().parse().map_err(|_| format!("not an integer: {s:?}"))?;
177            output.set(row, v)?;
178            Ok(())
179        }
180    }
181
182    #[test]
183    fn custom_cast_converts_and_try_cast_nulls_bad_rows() {
184        let mut conn = Connection::open_in_memory().unwrap();
185        conn.register_cast_function::<StrToInt>().unwrap();
186
187        // Explicit CAST of a good value.
188        let mut ok = conn.execute("SELECT CAST('42' AS INTEGER) AS n").unwrap();
189        assert_eq!(ok.next().unwrap().unwrap().get("n"), Some(&DuckValue::Int(42)));
190
191        // TRY_CAST of a bad value yields NULL (row error path).
192        let mut bad = conn.execute("SELECT TRY_CAST('oops' AS INTEGER) AS n").unwrap();
193        assert_eq!(bad.next().unwrap().unwrap().get("n"), Some(&DuckValue::Null));
194
195        // A normal CAST of a bad value fails the query (whole-cast error path), and
196        // the connection stays usable afterwards.
197        assert!(conn.execute("SELECT CAST('oops' AS INTEGER) AS n").is_err());
198        let _ = conn.execute("SELECT 1").unwrap();
199    }
200}