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}