1use 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
20fn quote_table(table: &str) -> String {
25 table
26 .split('.')
27 .map(quote_ident)
28 .collect::<Vec<_>>()
29 .join(".")
30}
31
32fn 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
41pub 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
59fn 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 other => DuckValue::Text(other.to_string()),
78 }
79}
80
81fn 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
112fn 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 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 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
220fn 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 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 #[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 #[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 #[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 assert_eq!(
467 split_table("db.analytics.events"),
468 (Some("db.analytics"), "events")
469 );
470 }
471}