1use crate::config::DuckdbSourceConfig;
10use async_trait::async_trait;
11use base64::Engine as _;
12use duckdb::types::{Value as DuckValue, ValueRef};
13use duckdb::{AccessMode, Config, Connection};
14use faucet_core::{FaucetError, Stream, StreamPage};
15use serde_json::Value;
16use std::collections::HashMap;
17use std::pin::Pin;
18use std::sync::{Arc, Mutex};
19use tokio::sync::mpsc;
20
21pub struct DuckdbSource {
27 config: DuckdbSourceConfig,
28 conn: Arc<Mutex<Connection>>,
29}
30
31fn open(config: &DuckdbSourceConfig) -> Result<Connection, FaucetError> {
33 let path = config.resolved_path();
34 let mode = if config.read_only {
35 AccessMode::ReadOnly
36 } else {
37 AccessMode::ReadWrite
38 };
39 let flags = Config::default()
40 .access_mode(mode)
41 .map_err(|e| FaucetError::Config(format!("duckdb config: {e}")))?;
42 let conn = if path == ":memory:" {
43 Connection::open_in_memory_with_flags(flags)
44 } else {
45 Connection::open_with_flags(path, flags)
46 };
47 conn.map_err(|e| FaucetError::Config(format!("DuckDB open failed ({path}): {e}")))
48}
49
50impl DuckdbSource {
51 pub async fn new(config: DuckdbSourceConfig) -> Result<Self, FaucetError> {
53 faucet_core::validate_batch_size(config.batch_size)?;
54 let cfg = config.clone();
55 let conn = tokio::task::spawn_blocking(move || open(&cfg))
56 .await
57 .map_err(|e| FaucetError::Source(format!("duckdb open task panicked: {e}")))??;
58 Ok(Self {
59 config,
60 conn: Arc::new(Mutex::new(conn)),
61 })
62 }
63}
64
65fn resolve_query(
71 config: &DuckdbSourceConfig,
72 context: &HashMap<String, Value>,
73) -> (String, Vec<Value>) {
74 if context.is_empty() {
75 (config.query.clone(), Vec::new())
76 } else {
77 faucet_core::util::substitute_context_bind_params(&config.query, context, 1, |_| {
78 "?".to_string()
79 })
80 }
81}
82
83fn json_to_duck(v: &Value) -> DuckValue {
85 match v {
86 Value::Null => DuckValue::Null,
87 Value::Bool(b) => DuckValue::Boolean(*b),
88 Value::Number(n) => {
89 if let Some(i) = n.as_i64() {
90 DuckValue::BigInt(i)
91 } else if let Some(u) = n.as_u64() {
92 DuckValue::UBigInt(u)
93 } else if let Some(f) = n.as_f64() {
94 DuckValue::Double(f)
95 } else {
96 DuckValue::Null
97 }
98 }
99 Value::String(s) => DuckValue::Text(s.clone()),
100 other => DuckValue::Text(other.to_string()),
102 }
103}
104
105fn value_ref_to_json(v: ValueRef<'_>) -> Value {
113 use serde_json::json;
114 match v {
115 ValueRef::Null => Value::Null,
116 ValueRef::Boolean(b) => Value::Bool(b),
117 ValueRef::TinyInt(n) => json!(n),
118 ValueRef::SmallInt(n) => json!(n),
119 ValueRef::Int(n) => json!(n),
120 ValueRef::BigInt(n) => json!(n),
121 ValueRef::HugeInt(n) => i64::try_from(n)
122 .map(|x| json!(x))
123 .unwrap_or_else(|_| Value::String(n.to_string())),
124 ValueRef::UTinyInt(n) => json!(n),
125 ValueRef::USmallInt(n) => json!(n),
126 ValueRef::UInt(n) => json!(n),
127 ValueRef::UBigInt(n) => json!(n),
128 ValueRef::Float(f) => serde_json::Number::from_f64(f as f64)
129 .map(Value::Number)
130 .unwrap_or(Value::Null),
131 ValueRef::Double(f) => serde_json::Number::from_f64(f)
132 .map(Value::Number)
133 .unwrap_or(Value::Null),
134 ValueRef::Text(bytes) => Value::String(String::from_utf8_lossy(bytes).into_owned()),
135 ValueRef::Blob(bytes) => {
136 Value::String(base64::engine::general_purpose::STANDARD.encode(bytes))
137 }
138 ValueRef::Decimal(d) => {
139 let s = d.to_string();
143 serde_json::from_str::<serde_json::Number>(&s)
144 .map(Value::Number)
145 .unwrap_or(Value::String(s))
146 }
147 ValueRef::Timestamp(_, n) => json!(n),
148 ValueRef::Date32(n) => json!(n),
149 ValueRef::Time64(_, n) => json!(n),
150 ValueRef::Interval {
151 months,
152 days,
153 nanos,
154 } => json!({ "months": months, "days": days, "nanos": nanos }),
155 other => Value::String(format!("{other:?}")),
157 }
158}
159
160fn row_to_json(row: &duckdb::Row<'_>, col_names: &[String]) -> Result<Value, FaucetError> {
162 let mut map = serde_json::Map::with_capacity(col_names.len());
163 for (i, name) in col_names.iter().enumerate() {
164 let vr = row
165 .get_ref(i)
166 .map_err(|e| FaucetError::Source(format!("DuckDB column {name} read failed: {e}")))?;
167 map.insert(name.clone(), value_ref_to_json(vr));
168 }
169 Ok(Value::Object(map))
170}
171
172fn collect_blocking(
175 conn: &Arc<Mutex<Connection>>,
176 query: &str,
177 binds: &[Value],
178) -> Result<Vec<Value>, FaucetError> {
179 let guard = conn
180 .lock()
181 .map_err(|_| FaucetError::Source("duckdb connection mutex poisoned".into()))?;
182 let mut stmt = guard
183 .prepare(query)
184 .map_err(|e| FaucetError::Source(format!("DuckDB prepare failed: {e}")))?;
185 let params: Vec<DuckValue> = binds.iter().map(json_to_duck).collect();
186 let mut rows = stmt
187 .query(duckdb::params_from_iter(params))
188 .map_err(|e| FaucetError::Source(format!("DuckDB query failed: {e}")))?;
189 let mut col_names: Vec<String> = Vec::new();
193 let mut out = Vec::new();
194 while let Some(row) = rows
195 .next()
196 .map_err(|e| FaucetError::Source(format!("DuckDB row read failed: {e}")))?
197 {
198 if col_names.is_empty() {
199 col_names = row.as_ref().column_names();
200 }
201 out.push(row_to_json(row, &col_names)?);
202 }
203 Ok(out)
204}
205
206fn stream_blocking(
208 conn: &Arc<Mutex<Connection>>,
209 query: &str,
210 binds: &[Value],
211 batch_size: usize,
212 tx: &mpsc::Sender<Result<StreamPage, FaucetError>>,
213) -> Result<(), FaucetError> {
214 let guard = conn
215 .lock()
216 .map_err(|_| FaucetError::Source("duckdb connection mutex poisoned".into()))?;
217 let mut stmt = guard
218 .prepare(query)
219 .map_err(|e| FaucetError::Source(format!("DuckDB prepare failed: {e}")))?;
220 let params: Vec<DuckValue> = binds.iter().map(json_to_duck).collect();
221 let mut rows = stmt
222 .query(duckdb::params_from_iter(params))
223 .map_err(|e| FaucetError::Source(format!("DuckDB query failed: {e}")))?;
224
225 let chunk = if batch_size == 0 {
226 usize::MAX
227 } else {
228 batch_size
229 };
230 let cap = if batch_size == 0 { 1024 } else { batch_size };
231 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
232 let mut col_names: Vec<String> = Vec::new();
234
235 while let Some(row) = rows
236 .next()
237 .map_err(|e| FaucetError::Source(format!("DuckDB row read failed: {e}")))?
238 {
239 if col_names.is_empty() {
240 col_names = row.as_ref().column_names();
241 }
242 buffer.push(row_to_json(row, &col_names)?);
243 if buffer.len() >= chunk {
244 let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
245 if tx
247 .blocking_send(Ok(StreamPage {
248 records: page,
249 bookmark: None,
250 }))
251 .is_err()
252 {
253 return Ok(());
254 }
255 }
256 }
257 if !buffer.is_empty() {
258 let _ = tx.blocking_send(Ok(StreamPage {
259 records: buffer,
260 bookmark: None,
261 }));
262 }
263 Ok(())
264}
265
266#[async_trait]
267impl faucet_core::Source for DuckdbSource {
268 async fn fetch_with_context(
269 &self,
270 context: &HashMap<String, Value>,
271 ) -> Result<Vec<Value>, FaucetError> {
272 let conn = self.conn.clone();
273 let (query_str, binds) = resolve_query(&self.config, context);
274 let query_label = self.config.query.clone();
275 let records =
276 tokio::task::spawn_blocking(move || collect_blocking(&conn, &query_str, &binds))
277 .await
278 .map_err(|e| FaucetError::Source(format!("duckdb query task panicked: {e}")))??;
279 tracing::info!(
280 rows = records.len(),
281 query = %query_label,
282 "DuckDB source fetch complete"
283 );
284 Ok(records)
285 }
286
287 fn stream_pages<'a>(
296 &'a self,
297 context: &'a HashMap<String, Value>,
298 _batch_size: usize,
299 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
300 let conn = self.conn.clone();
301 let (query_str, binds) = resolve_query(&self.config, context);
302 let batch_size = self.config.batch_size;
303 let query_label = self.config.query.clone();
304 let (tx, mut rx) = mpsc::channel::<Result<StreamPage, FaucetError>>(4);
305
306 tokio::task::spawn_blocking(move || {
307 if let Err(e) = stream_blocking(&conn, &query_str, &binds, batch_size, &tx) {
308 let _ = tx.blocking_send(Err(e));
309 }
310 });
311
312 Box::pin(async_stream::try_stream! {
313 let mut total = 0usize;
314 while let Some(item) = rx.recv().await {
315 let page = item?;
316 total += page.records.len();
317 yield page;
318 }
319 tracing::info!(
320 rows = total,
321 batch_size,
322 query = %query_label,
323 "DuckDB source stream complete",
324 );
325 })
326 }
327
328 fn config_schema(&self) -> Value {
329 serde_json::to_value(faucet_core::schema_for!(DuckdbSourceConfig))
330 .expect("schema serialization")
331 }
332
333 fn connector_name(&self) -> &'static str {
334 "duckdb"
335 }
336
337 fn dataset_uri(&self) -> String {
338 format!(
339 "duckdb://{}?query={}",
340 self.config.resolved_path(),
341 self.config.query
342 )
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use faucet_core::Source;
350
351 async fn memory_source(setup: &str, query: &str) -> DuckdbSource {
352 let source = DuckdbSource::new(DuckdbSourceConfig::new(":memory:", query))
353 .await
354 .unwrap();
355 source
356 .conn
357 .lock()
358 .unwrap()
359 .execute_batch(setup)
360 .expect("seed");
361 source
362 }
363
364 #[tokio::test]
365 async fn fetch_scalar_row() {
366 let source = DuckdbSource::new(DuckdbSourceConfig::new(
367 ":memory:",
368 "SELECT 1 AS val, 'hello' AS msg, true AS flag, 2.5 AS score",
369 ))
370 .await
371 .unwrap();
372 let records = source.fetch_all().await.unwrap();
373 assert_eq!(records.len(), 1);
374 assert_eq!(records[0]["val"], 1);
375 assert_eq!(records[0]["msg"], "hello");
376 assert_eq!(records[0]["flag"], true);
377 assert_eq!(records[0]["score"], 2.5);
378 assert_eq!(source.connector_name(), "duckdb");
379 }
380
381 #[tokio::test]
382 async fn fetch_from_table() {
383 let source = memory_source(
384 "CREATE TABLE items (id INTEGER, name TEXT); \
385 INSERT INTO items VALUES (1, 'Alice'), (2, 'Bob');",
386 "SELECT * FROM items ORDER BY id",
387 )
388 .await;
389 let records = source.fetch_all().await.unwrap();
390 assert_eq!(records.len(), 2);
391 assert_eq!(records[0]["id"], 1);
392 assert_eq!(records[1]["name"], "Bob");
393 }
394
395 #[tokio::test]
396 async fn blob_column_decodes_to_base64() {
397 let source = DuckdbSource::new(DuckdbSourceConfig::new(
398 ":memory:",
399 "SELECT '\\x00\\xFF'::BLOB AS data",
400 ))
401 .await
402 .unwrap();
403 let records = source.fetch_all().await.unwrap();
404 assert_eq!(records[0]["data"], "AP8=");
405 }
406
407 #[tokio::test]
408 async fn streaming_pages_are_bounded() {
409 let source = {
410 let s = memory_source(
411 "CREATE TABLE t (id INTEGER); \
412 INSERT INTO t SELECT * FROM range(0, 250);",
413 "SELECT id FROM t ORDER BY id",
414 )
415 .await;
416 DuckdbSource {
417 config: s.config.with_batch_size(100),
418 conn: s.conn,
419 }
420 };
421 let ctx = HashMap::new();
422 let mut stream = source.stream_pages(&ctx, 100);
423 let mut seen = 0usize;
424 let mut peak = 0usize;
425 while let Some(page) = futures::StreamExt::next(&mut stream).await {
426 let page = page.unwrap();
427 peak = peak.max(page.records.len());
428 seen += page.records.len();
429 }
430 assert_eq!(seen, 250);
431 assert!(peak <= 100, "peak page {peak} exceeds batch_size");
432 assert!(peak < 250, "buffered everything into one page");
433 }
434
435 #[tokio::test]
436 async fn empty_result() {
437 let source = DuckdbSource::new(DuckdbSourceConfig::new(
438 ":memory:",
439 "SELECT 1 AS x WHERE 1 = 0",
440 ))
441 .await
442 .unwrap();
443 assert!(source.fetch_all().await.unwrap().is_empty());
444 }
445
446 #[tokio::test]
447 async fn invalid_query_returns_error() {
448 let source = DuckdbSource::new(DuckdbSourceConfig::new(":memory:", "NOT VALID SQL"))
449 .await
450 .unwrap();
451 assert!(source.fetch_all().await.is_err());
452 }
453
454 #[tokio::test]
455 async fn fetch_with_context_binds_params_safely() {
456 let source = DuckdbSource::new(DuckdbSourceConfig::new(
457 ":memory:",
458 "SELECT {val} AS result",
459 ))
460 .await
461 .unwrap();
462 let mut context = HashMap::new();
463 context.insert("val".to_string(), serde_json::json!("1; DROP TABLE x; --"));
464 let records = source.fetch_with_context(&context).await.unwrap();
465 assert_eq!(records[0]["result"], "1; DROP TABLE x; --");
466 }
467
468 #[tokio::test]
469 async fn new_rejects_out_of_range_batch_size() {
470 let config = DuckdbSourceConfig::new(":memory:", "SELECT 1")
471 .with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
472 assert!(matches!(
473 DuckdbSource::new(config).await,
474 Err(FaucetError::Config(_))
475 ));
476 }
477
478 #[test]
479 fn value_ref_json_scalars() {
480 assert_eq!(value_ref_to_json(ValueRef::Null), Value::Null);
481 assert_eq!(
482 value_ref_to_json(ValueRef::Boolean(true)),
483 Value::Bool(true)
484 );
485 assert_eq!(value_ref_to_json(ValueRef::Int(7)), serde_json::json!(7));
486 assert_eq!(
487 value_ref_to_json(ValueRef::Text(b"hi")),
488 Value::String("hi".into())
489 );
490 }
491}