Skip to main content

faucet_source_duckdb/
stream.rs

1//! DuckDB source implementation — the one module that performs I/O.
2//!
3//! `duckdb` is a synchronous, embedded engine, so every database call runs
4//! inside [`tokio::task::spawn_blocking`]. Streaming stays bounded-memory: a
5//! dedicated blocking task holds the connection, iterates the result row by
6//! row, and hands finished [`StreamPage`]s to the async side over a small
7//! bounded channel — never buffering the whole result set.
8
9use 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
21/// A source that executes a SQL query against DuckDB and returns rows as JSON.
22///
23/// The connection is opened once in [`DuckdbSource::new`] and reused for every
24/// fetch/stream, wrapped in `Arc<Mutex<_>>` so it can move into the blocking
25/// task that runs each query.
26pub struct DuckdbSource {
27    config: DuckdbSourceConfig,
28    conn: Arc<Mutex<Connection>>,
29}
30
31/// Open a DuckDB connection honouring the config's path and access mode.
32fn 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    /// Create a new DuckDB source, opening (and reusing) one connection.
52    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
65/// Build the effective SQL query and ordered context-bind values for a given
66/// parent context. Returns the literal query when there is no context.
67///
68/// DuckDB accepts positional `?` placeholders, so the bind-marker formatter
69/// ignores the index (mirrors the SQLite source).
70fn 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
83/// Convert a JSON context value into an owned DuckDB parameter value.
84fn 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        // Arrays/objects have no scalar SQL form — bind their JSON text.
101        other => DuckValue::Text(other.to_string()),
102    }
103}
104
105/// Convert a DuckDB column value to a `serde_json::Value`.
106///
107/// Scalar types map exactly. `Text` becomes a UTF-8 (lossy) string and `Blob`
108/// becomes base64 so binary survives the JSON round-trip. Temporal, decimal,
109/// and nested (LIST / STRUCT / MAP / …) types are best-effort: temporal values
110/// surface their raw integer, and everything else falls back to a stable
111/// debug string — documented in the crate README.
112fn 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            // DuckDB types bare fractional literals (e.g. `2.5`) as DECIMAL.
140            // Represent as a JSON number when the canonical string parses
141            // (f64-precision), else keep the exact decimal text.
142            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        // List, Struct, Map, Array, Enum, Union — best-effort.
156        other => Value::String(format!("{other:?}")),
157    }
158}
159
160/// Build a JSON object from the current row using the pre-fetched column names.
161fn 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
172/// Run the query on the blocking thread and drain every row into a `Vec`
173/// (used by `fetch_with_context`).
174fn 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    // DuckDB populates column metadata only after execution, so column names are
190    // read from the first row (via `Row: AsRef<Statement>`), not the prepared
191    // statement.
192    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
206/// Run the query on the blocking thread, sending bounded pages over `tx`.
207fn 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    // Column names come from the first row (see `collect_blocking`).
233    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            // Receiver dropped (stream cancelled) → stop cleanly.
246            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    /// Stream rows in bounded-memory pages. A blocking task holds the
288    /// connection and pushes each finished page over a small channel; the async
289    /// side never holds more than a couple of pages at once.
290    ///
291    /// The trait-level `batch_size` argument is ignored in favour of the config
292    /// field (the user-facing knob). `batch_size = 0` drains the whole result
293    /// into a single page. This is a full-query source with no incremental
294    /// mode, so every page carries `bookmark: None`.
295    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}