Skip to main content

faucet_sink_duckdb/
sink.rs

1//! DuckDB sink implementation — the one module that performs I/O.
2//!
3//! `duckdb` is synchronous, so every write runs inside
4//! [`tokio::task::spawn_blocking`]. Each `write_batch` is applied as one
5//! `BEGIN`/`COMMIT` transaction of `batch_size`-row multi-row `INSERT`s
6//! (rolled back on error). The sink is append-only; keyed upsert and an
7//! Arrow-native columnar fast path are tracked as follow-ups.
8
9use crate::config::{DuckdbColumnMapping, DuckdbSinkConfig};
10use async_trait::async_trait;
11use duckdb::types::Value as DuckValue;
12use duckdb::{AccessMode, Config, Connection};
13use faucet_core::FaucetError;
14use faucet_core::util::quote_ident;
15
16use serde_json::Value;
17use std::collections::HashSet;
18use std::sync::{Arc, Mutex};
19
20/// Quote a possibly schema-qualified table name, one segment at a time, so
21/// `analytics.events` becomes `"analytics"."events"` rather than the single
22/// identifier `"analytics.events"` (which names a table with a dot in it and can
23/// never resolve). Mirrors the ClickHouse sink's `quote_table` (#456 L3).
24fn quote_table(table: &str) -> String {
25    table
26        .split('.')
27        .map(quote_ident)
28        .collect::<Vec<_>>()
29        .join(".")
30}
31
32/// The bare table name of a possibly schema-qualified target, plus its schema —
33/// `information_schema.columns` stores the two separately.
34fn split_table(table: &str) -> (Option<&str>, &str) {
35    match table.rsplit_once('.') {
36        Some((schema, name)) => (Some(schema), name),
37        None => (None, table),
38    }
39}
40
41/// A sink that writes JSON records to a DuckDB table.
42pub struct DuckdbSink {
43    config: DuckdbSinkConfig,
44    conn: Arc<Mutex<Connection>>,
45}
46
47fn open(path: &str) -> Result<Connection, FaucetError> {
48    let flags = Config::default()
49        .access_mode(AccessMode::ReadWrite)
50        .map_err(|e| FaucetError::Config(format!("duckdb config: {e}")))?;
51    let conn = if path == ":memory:" {
52        Connection::open_in_memory_with_flags(flags)
53    } else {
54        Connection::open_with_flags(path, flags)
55    };
56    conn.map_err(|e| FaucetError::Sink(format!("DuckDB open failed ({path}): {e}")))
57}
58
59/// Convert a JSON value into an owned DuckDB parameter value.
60fn json_to_duck(v: &Value) -> DuckValue {
61    match v {
62        Value::Null => DuckValue::Null,
63        Value::Bool(b) => DuckValue::Boolean(*b),
64        Value::Number(n) => {
65            if let Some(i) = n.as_i64() {
66                DuckValue::BigInt(i)
67            } else if let Some(u) = n.as_u64() {
68                DuckValue::UBigInt(u)
69            } else if let Some(f) = n.as_f64() {
70                DuckValue::Double(f)
71            } else {
72                DuckValue::Null
73            }
74        }
75        Value::String(s) => DuckValue::Text(s.clone()),
76        // Arrays/objects have no scalar SQL form — store their JSON text.
77        other => DuckValue::Text(other.to_string()),
78    }
79}
80
81/// Insert records as a single JSON text column via one multi-row INSERT.
82fn insert_json(
83    conn: &Connection,
84    table: &str,
85    column: &str,
86    records: &[Value],
87) -> Result<usize, FaucetError> {
88    if records.is_empty() {
89        return Ok(0);
90    }
91    let placeholders = vec!["(?)"; records.len()].join(", ");
92    let sql = format!(
93        "INSERT INTO {} ({}) VALUES {}",
94        quote_table(table),
95        quote_ident(column),
96        placeholders
97    );
98    let mut params: Vec<DuckValue> = Vec::with_capacity(records.len());
99    for r in records {
100        let text = serde_json::to_string(r)
101            .map_err(|e| FaucetError::Sink(format!("failed to serialize record: {e}")))?;
102        params.push(DuckValue::Text(text));
103    }
104    let mut stmt = conn
105        .prepare(&sql)
106        .map_err(|e| FaucetError::Sink(format!("DuckDB prepare failed: {e}")))?;
107    stmt.execute(duckdb::params_from_iter(params))
108        .map_err(|e| FaucetError::Sink(format!("DuckDB insert failed: {e}")))?;
109    Ok(records.len())
110}
111
112/// Insert records mapping top-level JSON keys onto existing table columns.
113fn insert_auto_map(
114    conn: &Connection,
115    table: &str,
116    records: &[Value],
117) -> Result<usize, FaucetError> {
118    if records.is_empty() {
119        return Ok(0);
120    }
121
122    // Discover the table's columns (in declared order) via information_schema.
123    // `table` may be schema-qualified, and information_schema keeps the schema and
124    // the name in separate columns — matching `table_name` against the whole
125    // dotted string would find nothing (#456 L3).
126    let (schema, name) = split_table(table);
127    let cols: Vec<String> = match schema {
128        Some(schema) => {
129            let mut cstmt = conn
130                .prepare(
131                    "SELECT column_name FROM information_schema.columns \
132                     WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position",
133                )
134                .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?;
135            cstmt
136                .query_map([schema, name], |row| row.get::<_, String>(0))
137                .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
138                .collect::<Result<Vec<String>, _>>()
139        }
140        None => {
141            let mut cstmt = conn
142                .prepare(
143                    "SELECT column_name FROM information_schema.columns \
144                     WHERE table_name = ? ORDER BY ordinal_position",
145                )
146                .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?;
147            cstmt
148                .query_map([name], |row| row.get::<_, String>(0))
149                .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
150                .collect::<Result<Vec<String>, _>>()
151        }
152    }
153    .map_err(|e| FaucetError::Sink(format!("failed to decode table columns: {e}")))?;
154
155    if cols.is_empty() {
156        return Err(FaucetError::Sink(format!(
157            "table '{table}' has no columns or does not exist"
158        )));
159    }
160
161    // Records with at least one matching column; the INSERT column set is the
162    // union of table columns present in any such record (declared order). A row
163    // missing a unioned column binds SQL NULL. Records with no matching key are
164    // skipped (mirrors the SQLite sink).
165    let mut used: HashSet<&str> = HashSet::new();
166    let mut rows: Vec<&serde_json::Map<String, Value>> = Vec::with_capacity(records.len());
167    for rec in records {
168        let obj = rec
169            .as_object()
170            .ok_or_else(|| FaucetError::Sink("AutoMap requires JSON object records".into()))?;
171        if !cols.iter().any(|c| obj.contains_key(c)) {
172            tracing::warn!(
173                record_keys = ?obj.keys().collect::<Vec<_>>(),
174                "record has no keys matching table columns, skipping"
175            );
176            continue;
177        }
178        for c in &cols {
179            if obj.contains_key(c) {
180                used.insert(c.as_str());
181            }
182        }
183        rows.push(obj);
184    }
185    if rows.is_empty() {
186        return Ok(0);
187    }
188
189    let insert_cols: Vec<&String> = cols.iter().filter(|c| used.contains(c.as_str())).collect();
190    let num_cols = insert_cols.len();
191    let col_list = insert_cols
192        .iter()
193        .map(|c| quote_ident(c))
194        .collect::<Vec<_>>()
195        .join(", ");
196    let row_ph = format!("({})", vec!["?"; num_cols].join(", "));
197    let values = vec![row_ph.as_str(); rows.len()].join(", ");
198    let sql = format!(
199        "INSERT INTO {} ({}) VALUES {}",
200        quote_table(table),
201        col_list,
202        values
203    );
204
205    let mut params: Vec<DuckValue> = Vec::with_capacity(rows.len() * num_cols);
206    for obj in &rows {
207        for c in &insert_cols {
208            params.push(obj.get(*c).map(json_to_duck).unwrap_or(DuckValue::Null));
209        }
210    }
211
212    let mut stmt = conn
213        .prepare(&sql)
214        .map_err(|e| FaucetError::Sink(format!("DuckDB prepare failed: {e}")))?;
215    stmt.execute(duckdb::params_from_iter(params))
216        .map_err(|e| FaucetError::Sink(format!("DuckDB insert failed: {e}")))?;
217    Ok(rows.len())
218}
219
220/// Apply the whole batch inside one `BEGIN`/`COMMIT` transaction, re-chunking
221/// into `batch_size` multi-row INSERTs. Any error rolls the transaction back.
222fn write_all_blocking(
223    conn: &Arc<Mutex<Connection>>,
224    config: &DuckdbSinkConfig,
225    records: &[Value],
226) -> Result<usize, FaucetError> {
227    let guard = conn
228        .lock()
229        .map_err(|_| FaucetError::Sink("duckdb connection mutex poisoned".into()))?;
230
231    let chunk = if config.batch_size == 0 {
232        records.len().max(1)
233    } else {
234        config.batch_size
235    };
236
237    guard
238        .execute_batch("BEGIN TRANSACTION")
239        .map_err(|e| FaucetError::Sink(format!("DuckDB begin failed: {e}")))?;
240
241    let applied = (|| -> Result<usize, FaucetError> {
242        let mut total = 0usize;
243        for c in records.chunks(chunk) {
244            total += match &config.column_mapping {
245                DuckdbColumnMapping::Json { column } => {
246                    insert_json(&guard, &config.table_name, column, c)?
247                }
248                DuckdbColumnMapping::AutoMap => insert_auto_map(&guard, &config.table_name, c)?,
249            };
250        }
251        Ok(total)
252    })();
253
254    match applied {
255        Ok(total) => {
256            guard
257                .execute_batch("COMMIT")
258                .map_err(|e| FaucetError::Sink(format!("DuckDB commit failed: {e}")))?;
259            Ok(total)
260        }
261        Err(e) => {
262            let _ = guard.execute_batch("ROLLBACK");
263            Err(e)
264        }
265    }
266}
267
268impl DuckdbSink {
269    /// Create a new DuckDB sink, opening (and reusing) one read-write connection.
270    pub async fn new(config: DuckdbSinkConfig) -> Result<Self, FaucetError> {
271        faucet_core::validate_batch_size(config.batch_size)?;
272        let path = config.resolved_path().to_string();
273        let conn = tokio::task::spawn_blocking(move || open(&path))
274            .await
275            .map_err(|e| FaucetError::Sink(format!("duckdb open task panicked: {e}")))??;
276        Ok(Self {
277            config,
278            conn: Arc::new(Mutex::new(conn)),
279        })
280    }
281
282    /// Run an arbitrary SQL statement (e.g. DDL) on the sink's connection.
283    ///
284    /// Exposed for setup/introspection in tests and tooling — DuckDB permits a
285    /// single read-write handle per database, so callers that need to create a
286    /// table or inspect state must go through the sink's own connection.
287    #[doc(hidden)]
288    pub async fn run_sql(&self, sql: &str) -> Result<(), FaucetError> {
289        let conn = self.conn.clone();
290        let sql = sql.to_string();
291        tokio::task::spawn_blocking(move || {
292            let guard = conn
293                .lock()
294                .map_err(|_| FaucetError::Sink("duckdb connection mutex poisoned".into()))?;
295            guard
296                .execute_batch(&sql)
297                .map_err(|e| FaucetError::Sink(format!("DuckDB statement failed: {e}")))
298        })
299        .await
300        .map_err(|e| FaucetError::Sink(format!("duckdb task panicked: {e}")))?
301    }
302
303    /// `SELECT count(*)` over `table` on the sink's connection.
304    #[doc(hidden)]
305    pub async fn scalar_count(&self, table: &str) -> Result<i64, FaucetError> {
306        let conn = self.conn.clone();
307        let sql = format!("SELECT count(*) FROM {}", quote_table(table));
308        tokio::task::spawn_blocking(move || {
309            let guard = conn
310                .lock()
311                .map_err(|_| FaucetError::Sink("duckdb connection mutex poisoned".into()))?;
312            guard
313                .query_row(&sql, [], |r| r.get::<_, i64>(0))
314                .map_err(|e| FaucetError::Sink(format!("DuckDB count failed: {e}")))
315        })
316        .await
317        .map_err(|e| FaucetError::Sink(format!("duckdb task panicked: {e}")))?
318    }
319}
320
321#[async_trait]
322impl faucet_core::Sink for DuckdbSink {
323    fn config_schema(&self) -> Value {
324        serde_json::to_value(faucet_core::schema_for!(DuckdbSinkConfig))
325            .expect("schema serialization")
326    }
327
328    fn connector_name(&self) -> &'static str {
329        "duckdb"
330    }
331
332    fn dataset_uri(&self) -> String {
333        format!(
334            "duckdb://{}?table={}",
335            self.config.resolved_path(),
336            self.config.table_name
337        )
338    }
339
340    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
341        if records.is_empty() {
342            return Ok(0);
343        }
344        let conn = self.conn.clone();
345        let config = self.config.clone();
346        let owned = records.to_vec();
347        let n = tokio::task::spawn_blocking(move || write_all_blocking(&conn, &config, &owned))
348            .await
349            .map_err(|e| FaucetError::Sink(format!("duckdb write task panicked: {e}")))??;
350        tracing::info!(
351            table = %self.config.table_name,
352            rows = n,
353            "DuckDB write complete"
354        );
355        Ok(n)
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use faucet_core::Sink as _;
363    use serde_json::json;
364
365    async fn sink_with_table(ddl: &str, table: &str, mapping: DuckdbColumnMapping) -> DuckdbSink {
366        let sink =
367            DuckdbSink::new(DuckdbSinkConfig::new(":memory:", table).column_mapping(mapping))
368                .await
369                .unwrap();
370        sink.conn.lock().unwrap().execute_batch(ddl).expect("ddl");
371        sink
372    }
373
374    fn count(sink: &DuckdbSink, table: &str) -> i64 {
375        let guard = sink.conn.lock().unwrap();
376        guard
377            .query_row(
378                &format!("SELECT count(*) FROM {}", quote_table(table)),
379                [],
380                |r| r.get::<_, i64>(0),
381            )
382            .unwrap()
383    }
384
385    #[tokio::test]
386    async fn writes_json_column() {
387        let sink = sink_with_table(
388            "CREATE TABLE events (data TEXT)",
389            "events",
390            DuckdbColumnMapping::Json {
391                column: "data".into(),
392            },
393        )
394        .await;
395        let n = sink
396            .write_batch(&[json!({"a": 1}), json!({"a": 2})])
397            .await
398            .unwrap();
399        assert_eq!(n, 2);
400        assert_eq!(count(&sink, "events"), 2);
401        assert_eq!(sink.connector_name(), "duckdb");
402    }
403
404    #[tokio::test]
405    async fn writes_auto_mapped_columns() {
406        let sink = sink_with_table(
407            "CREATE TABLE t (id INTEGER, name TEXT)",
408            "t",
409            DuckdbColumnMapping::AutoMap,
410        )
411        .await;
412        let n = sink
413            .write_batch(&[
414                json!({"id": 1, "name": "a", "extra": "ignored"}),
415                json!({"id": 2, "name": "b"}),
416            ])
417            .await
418            .unwrap();
419        assert_eq!(n, 2);
420        assert_eq!(count(&sink, "t"), 2);
421    }
422
423    #[tokio::test]
424    async fn empty_batch_is_noop() {
425        let sink = sink_with_table(
426            "CREATE TABLE t (data TEXT)",
427            "t",
428            DuckdbColumnMapping::default(),
429        )
430        .await;
431        assert_eq!(sink.write_batch(&[]).await.unwrap(), 0);
432    }
433
434    #[tokio::test]
435    async fn missing_table_errors_not_panics() {
436        let sink = DuckdbSink::new(
437            DuckdbSinkConfig::new(":memory:", "nope").column_mapping(DuckdbColumnMapping::AutoMap),
438        )
439        .await
440        .unwrap();
441        assert!(sink.write_batch(&[json!({"a": 1})]).await.is_err());
442    }
443}
444
445#[cfg(test)]
446mod schema_qualified_tests {
447    use super::*;
448
449    /// #456 L3: a schema-qualified target must quote each segment, or it names a
450    /// table with a literal dot in it and can never resolve. The ClickHouse sink
451    /// already did this; DuckDB used a single `quote_ident`.
452    #[test]
453    fn quote_table_quotes_each_segment() {
454        assert_eq!(quote_table("events"), "\"events\"");
455        assert_eq!(quote_table("analytics.events"), "\"analytics\".\"events\"");
456    }
457
458    #[test]
459    fn split_table_separates_schema_from_name() {
460        assert_eq!(split_table("events"), (None, "events"));
461        assert_eq!(
462            split_table("analytics.events"),
463            (Some("analytics"), "events")
464        );
465        // Deepest qualifier wins (catalog.schema.table → schema is the prefix).
466        assert_eq!(
467            split_table("db.analytics.events"),
468            (Some("db.analytics"), "events")
469        );
470    }
471}