1use crate::config::{SqliteColumnMapping, SqliteSinkConfig};
4use async_trait::async_trait;
5use faucet_core::util::quote_ident;
6use faucet_core::{FaucetError, SchemaEvolution, SqlBaseType, json_schema_base_type};
7use serde_json::Value;
8use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
9use sqlx::{Row, SqlitePool};
10use std::str::FromStr;
11use std::time::Duration;
12
13fn quote_ident_sqlite(name: &str) -> String {
25 format!("`{}`", name.replace('`', "``"))
26}
27
28const CLEANUP_KEYS_TABLE: &str = "faucet_cleanup_keys";
35
36fn cleanup_keys_ref() -> String {
38 format!("temp.{}", quote_ident_sqlite(CLEANUP_KEYS_TABLE))
39}
40
41fn safe_type_spec(declared: &str) -> Option<&str> {
51 let t = declared.trim();
52 if t.is_empty() {
53 return None;
54 }
55 t.chars()
56 .all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '(' | ')' | ',' | '.'))
57 .then_some(t)
58}
59
60fn build_cleanup_temp_table_sql(key_types: &[(String, String)]) -> String {
64 let cols = key_types
65 .iter()
66 .map(|(col, declared)| match safe_type_spec(declared) {
67 Some(t) => format!("{} {t}", quote_ident_sqlite(col)),
68 None => quote_ident_sqlite(col),
69 })
70 .collect::<Vec<_>>()
71 .join(", ");
72 format!("CREATE TEMP TABLE {} ({cols})", cleanup_keys_ref())
73}
74
75fn build_cleanup_insert_sql(key: &[String], rows: usize) -> String {
79 let col_list = key
80 .iter()
81 .map(|k| quote_ident_sqlite(k))
82 .collect::<Vec<_>>()
83 .join(", ");
84 let tuple = format!("({})", vec!["?"; key.len()].join(", "));
85 let tuples = vec![tuple; rows].join(", ");
86 format!(
87 "INSERT INTO {} ({col_list}) VALUES {tuples}",
88 cleanup_keys_ref()
89 )
90}
91
92fn build_cleanup_delete_sql(table: &str, scope_cols: &[String], key: &[String]) -> String {
99 let t = quote_ident_sqlite(table);
100 let scope_pred = scope_cols
101 .iter()
102 .map(|c| format!("{t}.{} = ?", quote_ident_sqlite(c)))
103 .collect::<Vec<_>>()
104 .join(" AND ");
105 let join_pred = key
106 .iter()
107 .map(|k| {
108 let q = quote_ident_sqlite(k);
109 format!("c.{q} = {t}.{q}")
110 })
111 .collect::<Vec<_>>()
112 .join(" AND ");
113 format!(
114 "DELETE FROM {t} WHERE {scope_pred} AND NOT EXISTS (SELECT 1 FROM {} c WHERE {join_pred})",
115 cleanup_keys_ref()
116 )
117}
118
119fn validate_cleanup_columns(
125 existing: &std::collections::HashSet<String>,
126 scope_cols: &[String],
127 key: &[String],
128 table: &str,
129) -> Result<(), FaucetError> {
130 for col in scope_cols.iter().chain(key.iter()) {
131 if !existing.contains(col) {
132 return Err(FaucetError::Sink(format!(
133 "cleanup: column '{col}' does not exist on table '{table}' — the \
134 completeness claim and `key` are in destination column terms"
135 )));
136 }
137 }
138 Ok(())
139}
140
141fn bind_value<'q>(
147 q: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
148 v: &Value,
149) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> {
150 match v {
151 Value::Null => q.bind(None::<String>),
152 Value::Bool(b) => q.bind(*b),
153 Value::Number(n) => {
154 if let Some(i) = n.as_i64() {
155 q.bind(i)
156 } else if let Some(f) = n.as_f64() {
157 q.bind(f)
158 } else {
159 q.bind(n.to_string())
161 }
162 }
163 Value::String(s) => q.bind(s.clone()),
164 other => q.bind(other.to_string()),
167 }
168}
169
170fn sqlite_keyword(t: SqlBaseType) -> &'static str {
176 match t {
177 SqlBaseType::Integer => "INTEGER",
178 SqlBaseType::Double => "REAL",
179 SqlBaseType::Boolean => "INTEGER",
180 SqlBaseType::Text => "TEXT",
181 SqlBaseType::Json => "TEXT",
182 }
183}
184
185fn build_add_column_sql(table: &str, col: &str, t: SqlBaseType) -> String {
190 format!(
191 "ALTER TABLE {} ADD COLUMN {} {}",
192 quote_ident(table),
193 quote_ident(col),
194 sqlite_keyword(t)
195 )
196}
197
198fn sqlite_affinity_to_json_schema(declared: &str, nullable: bool) -> serde_json::Value {
208 let up = declared.to_ascii_uppercase();
209 let contains = |needle: &str| up.contains(needle);
210 let base = if contains("INT") {
211 "integer"
212 } else if contains("CHAR") || contains("CLOB") || contains("TEXT") {
213 "string"
214 } else if contains("REAL")
215 || contains("FLOA")
216 || contains("DOUB")
217 || contains("NUMERIC")
218 || contains("DECIMAL")
219 {
220 "number"
221 } else {
222 "string"
223 };
224 if nullable {
225 serde_json::json!({ "type": [base, "null"] })
226 } else {
227 serde_json::json!({ "type": base })
228 }
229}
230
231fn on_conflict_clause(key: &[String], all_cols: &[String]) -> String {
235 let key_list = key
236 .iter()
237 .map(|k| quote_ident(k))
238 .collect::<Vec<_>>()
239 .join(", ");
240 let updates: Vec<String> = all_cols
241 .iter()
242 .filter(|c| !key.iter().any(|k| k == *c))
243 .map(|c| format!("{q} = excluded.{q}", q = quote_ident(c)))
244 .collect();
245 if updates.is_empty() {
246 format!("ON CONFLICT({key_list}) DO NOTHING")
247 } else {
248 format!(
249 "ON CONFLICT({key_list}) DO UPDATE SET {}",
250 updates.join(", ")
251 )
252 }
253}
254
255pub struct SqliteSink {
257 config: SqliteSinkConfig,
258 pool: SqlitePool,
259}
260
261impl SqliteSink {
262 pub async fn new(config: SqliteSinkConfig) -> Result<Self, FaucetError> {
272 config.write.validate()?;
273 if !matches!(config.write.write_mode, faucet_core::WriteMode::Append)
274 && !matches!(config.column_mapping, SqliteColumnMapping::AutoMap)
275 {
276 return Err(FaucetError::Config(
277 "sqlite sink: write_mode upsert/delete requires column_mapping: auto_map \
278 (key columns must be real columns, not inside a JSON blob)"
279 .into(),
280 ));
281 }
282
283 let options = SqliteConnectOptions::from_str(&config.database_url)
284 .map_err(|e| FaucetError::Sink(format!("invalid SQLite database_url: {e}")))?
285 .create_if_missing(true)
286 .journal_mode(SqliteJournalMode::Wal)
287 .busy_timeout(Duration::from_secs(5));
288
289 let pool = SqlitePoolOptions::new()
290 .max_connections(config.max_connections)
291 .connect_with(options)
292 .await
293 .map_err(|e| FaucetError::Sink(format!("SQLite connection failed: {e}")))?;
294
295 Ok(Self { config, pool })
296 }
297
298 async fn insert_json_tx(
301 &self,
302 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
303 records: &[Value],
304 column: &str,
305 ) -> Result<usize, FaucetError> {
306 if records.is_empty() {
307 return Ok(0);
308 }
309 const MAX_SQLITE_VARS: usize = 32766;
312 for chunk in records.chunks(MAX_SQLITE_VARS) {
313 let placeholders: Vec<&str> = chunk.iter().map(|_| "(?)").collect();
314 let insert_sql = format!(
315 "INSERT INTO {} ({}) VALUES {}",
316 quote_ident(&self.config.table_name),
317 quote_ident(column),
318 placeholders.join(", ")
319 );
320 let mut q = sqlx::query(&insert_sql);
321 for record in chunk {
322 let json_str = serde_json::to_string(record)
323 .map_err(|e| FaucetError::Sink(format!("failed to serialize record: {e}")))?;
324 q = q.bind(json_str);
325 }
326 q.execute(&mut **tx)
327 .await
328 .map_err(|e| FaucetError::Sink(format!("SQLite insert failed: {e}")))?;
329 }
330 Ok(records.len())
331 }
332
333 async fn insert_json(&self, records: &[Value], column: &str) -> Result<usize, FaucetError> {
337 if records.is_empty() {
338 return Ok(0);
339 }
340 let mut tx = self
341 .pool
342 .begin()
343 .await
344 .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
345 let n = self.insert_json_tx(&mut tx, records, column).await?;
346 tx.commit()
347 .await
348 .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
349 Ok(n)
350 }
351
352 async fn insert_auto_map(&self, records: &[Value]) -> Result<usize, FaucetError> {
358 if records.is_empty() {
359 return Ok(0);
360 }
361
362 let mut tx = self
363 .pool
364 .begin()
365 .await
366 .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
367
368 let written = self.insert_auto_map_tx(&mut tx, records).await?;
369
370 tx.commit()
371 .await
372 .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
373
374 Ok(written)
375 }
376
377 async fn insert_auto_map_with_conflict_tx(
392 &self,
393 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
394 records: &[Value],
395 conflict_key: Option<&[String]>,
396 ) -> Result<usize, FaucetError> {
397 if records.is_empty() {
398 return Ok(0);
399 }
400
401 let columns: Vec<String> = sqlx::query(&format!(
404 "PRAGMA table_info({})",
405 quote_ident(&self.config.table_name)
406 ))
407 .fetch_all(&mut **tx)
408 .await
409 .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
410 .iter()
411 .map(|row| row.get::<String, _>("name"))
412 .collect();
413
414 if columns.is_empty() {
415 return Err(FaucetError::Sink(format!(
416 "table '{}' has no columns or does not exist",
417 self.config.table_name
418 )));
419 }
420
421 let mut matched_rows: Vec<Vec<(&String, &Value)>> = Vec::with_capacity(records.len());
428 let mut used: std::collections::HashSet<&str> = std::collections::HashSet::new();
429
430 for record in records {
431 let obj = record
432 .as_object()
433 .ok_or_else(|| FaucetError::Sink("AutoMap requires JSON object records".into()))?;
434
435 let matching: Vec<(&String, &Value)> = columns
436 .iter()
437 .filter_map(|col| obj.get(col).map(|v| (col, v)))
438 .collect();
439
440 if matching.is_empty() {
441 tracing::warn!(
442 record_keys = ?obj.keys().collect::<Vec<_>>(),
443 table_columns = ?columns,
444 "record has no keys matching table columns, skipping"
445 );
446 continue;
447 }
448
449 for (c, _) in &matching {
450 used.insert(c.as_str());
451 }
452 matched_rows.push(matching);
453 }
454
455 if matched_rows.is_empty() {
456 return Ok(0);
457 }
458
459 let insert_columns: Vec<String> = columns
461 .iter()
462 .filter(|c| used.contains(c.as_str()))
463 .cloned()
464 .collect();
465
466 let num_cols = insert_columns.len();
467 let num_rows = matched_rows.len();
468 let col_names: Vec<String> = insert_columns.iter().map(|c| quote_ident(c)).collect();
469
470 const MAX_SQLITE_VARS: usize = 32766;
476 let max_rows_per_insert = (MAX_SQLITE_VARS / num_cols).max(1);
477
478 for sub in matched_rows.chunks(max_rows_per_insert) {
479 let row_placeholder = format!("({})", vec!["?"; num_cols].join(", "));
481 let value_tuples: Vec<&str> =
482 (0..sub.len()).map(|_| row_placeholder.as_str()).collect();
483 let base_query = format!(
484 "INSERT INTO {} ({}) VALUES {}",
485 quote_ident(&self.config.table_name),
486 col_names.join(", "),
487 value_tuples.join(", ")
488 );
489 let query = match conflict_key {
490 Some(key) => format!("{base_query} {}", on_conflict_clause(key, &insert_columns)),
491 None => base_query,
492 };
493
494 let mut q = sqlx::query(&query);
495 for matched in sub {
496 for col in &insert_columns {
497 let val = matched.iter().find(|(c, _)| *c == col).map(|(_, v)| *v);
498 q = match val {
504 None | Some(Value::Null) => q.bind(None::<String>),
505 Some(Value::Bool(b)) => q.bind(*b),
506 Some(Value::Number(n)) => {
507 if let Some(i) = n.as_i64() {
508 q.bind(i)
509 } else if let Some(f) = n.as_f64() {
510 q.bind(f)
511 } else {
512 q.bind(n.to_string())
514 }
515 }
516 Some(Value::String(s)) => q.bind(s.clone()),
517 Some(v) => q.bind(v.to_string()),
520 };
521 }
522 }
523
524 q.execute(&mut **tx)
525 .await
526 .map_err(|e| FaucetError::Sink(format!("SQLite insert failed: {e}")))?;
527 }
528
529 Ok(num_rows)
530 }
531
532 async fn insert_auto_map_tx(
540 &self,
541 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
542 records: &[Value],
543 ) -> Result<usize, FaucetError> {
544 self.insert_auto_map_with_conflict_tx(tx, records, None)
545 .await
546 }
547
548 async fn delete_by_keys(
552 &self,
553 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
554 deletes: &[faucet_core::KeyTuple],
555 ) -> Result<usize, FaucetError> {
556 if deletes.is_empty() {
557 return Ok(0);
558 }
559 let key = &self.config.write.key;
560 let table_ref = quote_ident(&self.config.table_name);
561 let col_list = key
562 .iter()
563 .map(|k| quote_ident(k))
564 .collect::<Vec<_>>()
565 .join(", ");
566
567 const MAX_SQLITE_VARS: usize = 32766;
568 let per = (MAX_SQLITE_VARS / key.len().max(1)).max(1);
569 let mut total = 0usize;
570
571 for chunk in deletes.chunks(per) {
572 let tuples: Vec<String> = chunk
573 .iter()
574 .map(|_| format!("({})", vec!["?"; key.len()].join(", ")))
575 .collect();
576 let sql = format!(
577 "DELETE FROM {table_ref} WHERE ({col_list}) IN ({})",
578 tuples.join(", ")
579 );
580 let mut q = sqlx::query(&sql);
581 for kt in chunk {
582 for (_, v) in &kt.0 {
583 q = bind_value(q, v);
585 }
586 }
587 let res = q
588 .execute(&mut **tx)
589 .await
590 .map_err(|e| FaucetError::Sink(format!("SQLite delete failed: {e}")))?;
591 total += res.rows_affected() as usize;
592 }
593 Ok(total)
594 }
595
596 async fn apply_plan(&self, plan: &faucet_core::WritePlan) -> Result<usize, FaucetError> {
600 let mut tx = self
601 .pool
602 .begin()
603 .await
604 .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
605
606 let mut affected = 0usize;
607 if !plan.upserts.is_empty() {
608 affected += self
609 .insert_auto_map_with_conflict_tx(
610 &mut tx,
611 &plan.upserts,
612 Some(&self.config.write.key),
613 )
614 .await?;
615 }
616 if !plan.deletes.is_empty() {
617 affected += self.delete_by_keys(&mut tx, &plan.deletes).await?;
618 }
619
620 tx.commit()
621 .await
622 .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
623 Ok(affected)
624 }
625
626 async fn cleanup_scope_impl(
644 &self,
645 scope: &std::collections::BTreeMap<String, Value>,
646 seen: &faucet_core::SeenKeys,
647 ) -> Result<u64, FaucetError> {
648 let key = &self.config.write.key;
649 if key.is_empty() {
650 return Err(FaucetError::Sink(
651 "cleanup requires a non-empty `key`".to_string(),
652 ));
653 }
654 let table = &self.config.table_name;
655
656 let mut tx = self
657 .pool
658 .begin()
659 .await
660 .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
661
662 let declared: std::collections::HashMap<String, String> =
664 sqlx::query(&format!("PRAGMA table_info({})", quote_ident_sqlite(table)))
665 .fetch_all(&mut *tx)
666 .await
667 .map_err(|e| FaucetError::Sink(format!("cleanup: table_info query failed: {e}")))?
668 .iter()
669 .map(|row| (row.get::<String, _>("name"), row.get::<String, _>("type")))
670 .collect();
671
672 let scope_cols: Vec<String> = scope.keys().cloned().collect();
673 let existing: std::collections::HashSet<String> = declared.keys().cloned().collect();
674 validate_cleanup_columns(&existing, &scope_cols, key, table)?;
675
676 let keys_ref = cleanup_keys_ref();
680 sqlx::query(&format!("DROP TABLE IF EXISTS {keys_ref}"))
681 .execute(&mut *tx)
682 .await
683 .map_err(|e| FaucetError::Sink(format!("cleanup: temp table drop failed: {e}")))?;
684
685 let key_types: Vec<(String, String)> = key
686 .iter()
687 .map(|k| (k.clone(), declared.get(k).cloned().unwrap_or_default()))
688 .collect();
689 sqlx::query(&build_cleanup_temp_table_sql(&key_types))
690 .execute(&mut *tx)
691 .await
692 .map_err(|e| FaucetError::Sink(format!("cleanup: temp table creation failed: {e}")))?;
693
694 const MAX_SQLITE_VARS: usize = 32766;
696 let per = (MAX_SQLITE_VARS / key.len()).max(1);
697 for chunk in seen.keys().chunks(per) {
698 let sql = build_cleanup_insert_sql(key, chunk.len());
699 let mut q = sqlx::query(&sql);
700 for kt in chunk {
701 for (_, v) in &kt.0 {
702 q = bind_value(q, v);
703 }
704 }
705 q.execute(&mut *tx)
706 .await
707 .map_err(|e| FaucetError::Sink(format!("cleanup: loading keys failed: {e}")))?;
708 }
709
710 let sql = build_cleanup_delete_sql(table, &scope_cols, key);
712 let mut q = sqlx::query(&sql);
713 for v in scope.values() {
714 q = bind_value(q, v);
715 }
716 let res = q
717 .execute(&mut *tx)
718 .await
719 .map_err(|e| FaucetError::Sink(format!("cleanup: delete failed: {e}")))?;
720
721 sqlx::query(&format!("DROP TABLE IF EXISTS {keys_ref}"))
723 .execute(&mut *tx)
724 .await
725 .map_err(|e| FaucetError::Sink(format!("cleanup: temp table drop failed: {e}")))?;
726
727 tx.commit()
728 .await
729 .map_err(|e| FaucetError::Sink(format!("cleanup: commit failed: {e}")))?;
730 Ok(res.rows_affected())
731 }
732
733 async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
735 let sql = format!(
736 "CREATE TABLE IF NOT EXISTS {t} ({s} TEXT PRIMARY KEY, {k} TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')))",
737 t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
738 s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
739 k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
740 );
741 sqlx::query(&sql)
742 .execute(&self.pool)
743 .await
744 .map_err(|e| FaucetError::Sink(format!("SQLite commit-table create failed: {e}")))?;
745 Ok(())
746 }
747}
748
749#[async_trait]
750impl faucet_core::Sink for SqliteSink {
751 fn connector_name(&self) -> &'static str {
752 "sqlite"
753 }
754
755 fn config_schema(&self) -> serde_json::Value {
756 serde_json::to_value(faucet_core::schema_for!(SqliteSinkConfig))
757 .expect("schema serialization")
758 }
759
760 fn dataset_uri(&self) -> String {
761 let path = self
762 .config
763 .database_url
764 .trim_start_matches("sqlite://")
765 .trim_start_matches("sqlite:");
766 format!("sqlite://{}?table={}", path, self.config.table_name)
767 }
768
769 async fn check(
775 &self,
776 ctx: &faucet_core::check::CheckContext,
777 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
778 use faucet_core::check::{CheckReport, Probe};
779
780 let started = std::time::Instant::now();
781 let probe =
782 match tokio::time::timeout(ctx.timeout, sqlx::query("SELECT 1").execute(&self.pool))
783 .await
784 {
785 Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
786 Ok(Err(e)) => Probe::fail_hint(
787 "auth",
788 started.elapsed(),
789 e.to_string(),
790 "check database_url / that the database file is reachable and openable",
791 ),
792 Err(_) => Probe::fail_hint(
793 "auth",
794 started.elapsed(),
795 "timed out",
796 "check database_url / that the database file is reachable and openable",
797 ),
798 };
799 Ok(CheckReport::single(probe))
800 }
801
802 fn supports_cleanup(&self) -> bool {
803 matches!(self.config.column_mapping, SqliteColumnMapping::AutoMap)
806 }
807
808 async fn cleanup_scope(
809 &self,
810 scope: &std::collections::BTreeMap<String, Value>,
811 seen: &faucet_core::SeenKeys,
812 ) -> Result<u64, FaucetError> {
813 self.cleanup_scope_impl(scope, seen).await
814 }
815
816 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
817 &[
818 faucet_core::WriteMode::Append,
819 faucet_core::WriteMode::Upsert,
820 faucet_core::WriteMode::Delete,
821 ]
822 }
823
824 fn dedups_by_key(&self) -> bool {
825 self.config.write.dedups_by_key()
826 }
827
828 fn supports_schema_evolution(&self) -> bool {
829 true
830 }
831
832 async fn current_schema(&self) -> Result<Option<serde_json::Value>, FaucetError> {
843 let rows = sqlx::query(&format!(
844 "PRAGMA table_info({})",
845 quote_ident(&self.config.table_name)
846 ))
847 .fetch_all(&self.pool)
848 .await
849 .map_err(|e| FaucetError::Sink(format!("sqlite current_schema query failed: {e}")))?;
850
851 if rows.is_empty() {
852 return Ok(None); }
854
855 let mut props = serde_json::Map::new();
856 for row in &rows {
857 let name: String = row.get("name");
858 let declared: String = row.get("type");
859 let notnull: i64 = row.get("notnull");
860 props.insert(
861 name,
862 sqlite_affinity_to_json_schema(&declared, notnull == 0),
863 );
864 }
865 Ok(Some(
866 serde_json::json!({ "type": "object", "properties": props }),
867 ))
868 }
869
870 async fn evolve_schema(&self, evolution: &SchemaEvolution) -> Result<(), FaucetError> {
882 let existing: std::collections::HashSet<String> = sqlx::query(&format!(
885 "PRAGMA table_info({})",
886 quote_ident(&self.config.table_name)
887 ))
888 .fetch_all(&self.pool)
889 .await
890 .map_err(|e| FaucetError::Sink(format!("sqlite evolve table_info failed: {e}")))?
891 .iter()
892 .map(|row| row.get::<String, _>("name"))
893 .collect();
894
895 for c in &evolution.additions {
896 if existing.contains(&c.name) {
897 continue; }
899 let t = json_schema_base_type(&c.to).unwrap_or(SqlBaseType::Text);
900 sqlx::query(&build_add_column_sql(&self.config.table_name, &c.name, t))
901 .execute(&self.pool)
902 .await
903 .map_err(|e| {
904 FaucetError::Sink(format!("sqlite ADD COLUMN {} failed: {e}", c.name))
905 })?;
906 }
907
908 if !evolution.widenings.is_empty() {
909 tracing::debug!("sqlite: type widening is a no-op under dynamic typing");
910 }
911 for col in &evolution.relax_nullability {
912 tracing::debug!("sqlite cannot relax NOT NULL in place; column {col} left as-is");
913 }
914
915 Ok(())
916 }
917
918 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
919 if records.is_empty() {
920 return Ok(0);
921 }
922
923 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
925 let plan = faucet_core::plan_writes(records, &self.config.write);
926 if let Some((idx, msg)) = plan.failed.first() {
927 return Err(FaucetError::Sink(format!(
928 "sqlite {}: row {idx}: {msg}",
929 self.config.write.write_mode.as_str()
930 )));
931 }
932 return self.apply_plan(&plan).await;
933 }
934
935 let effective_chunk = if self.config.batch_size == 0 {
941 records.len()
942 } else {
943 self.config.batch_size
944 };
945
946 let mut total = 0;
947 for chunk in records.chunks(effective_chunk) {
948 total += match &self.config.column_mapping {
949 SqliteColumnMapping::Json { column } => self.insert_json(chunk, column).await?,
950 SqliteColumnMapping::AutoMap => self.insert_auto_map(chunk).await?,
951 };
952 }
953
954 tracing::info!(
955 table = %self.config.table_name,
956 rows = total,
957 "SQLite write complete"
958 );
959 Ok(total)
960 }
961
962 async fn write_batch_partial(
971 &self,
972 records: &[Value],
973 ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
974 if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
975 self.write_batch(records).await?;
976 return Ok(records.iter().map(|_| Ok(())).collect());
977 }
978
979 let plan = faucet_core::plan_writes(records, &self.config.write);
980 self.apply_plan(&plan).await?;
981
982 let mut outcomes: Vec<faucet_core::RowOutcome> = records.iter().map(|_| Ok(())).collect();
983 for (idx, msg) in &plan.failed {
984 outcomes[*idx] = Err(FaucetError::Sink(format!(
985 "sqlite {}: {msg}",
986 self.config.write.write_mode.as_str()
987 )));
988 }
989 Ok(outcomes)
990 }
991
992 fn supports_idempotent_writes(&self) -> bool {
993 true
994 }
995
996 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
997 self.ensure_commit_table().await?;
998 let sql = format!(
999 "SELECT {k} FROM {t} WHERE {s} = ?",
1000 t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
1001 k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
1002 s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
1003 );
1004 let row = sqlx::query(&sql)
1005 .bind(scope)
1006 .fetch_optional(&self.pool)
1007 .await
1008 .map_err(|e| FaucetError::Sink(format!("SQLite token read failed: {e}")))?;
1009 Ok(row.map(|r| r.get::<String, _>(0)))
1010 }
1011
1012 async fn write_batch_idempotent(
1013 &self,
1014 records: &[Value],
1015 scope: &str,
1016 token: &str,
1017 ) -> Result<usize, FaucetError> {
1018 self.ensure_commit_table().await?;
1019
1020 let plan = if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
1023 None
1024 } else {
1025 let plan = faucet_core::plan_writes(records, &self.config.write);
1026 if let Some((idx, msg)) = plan.failed.first() {
1027 return Err(FaucetError::Sink(format!(
1028 "sqlite {}: row {idx}: {msg}",
1029 self.config.write.write_mode.as_str()
1030 )));
1031 }
1032 Some(plan)
1033 };
1034
1035 let mut tx = self
1036 .pool
1037 .begin()
1038 .await
1039 .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
1040
1041 let written = match &plan {
1048 Some(plan) => {
1049 let mut affected = 0usize;
1050 if !plan.upserts.is_empty() {
1051 affected += self
1052 .insert_auto_map_with_conflict_tx(
1053 &mut tx,
1054 &plan.upserts,
1055 Some(&self.config.write.key),
1056 )
1057 .await?;
1058 }
1059 if !plan.deletes.is_empty() {
1060 affected += self.delete_by_keys(&mut tx, &plan.deletes).await?;
1061 }
1062 affected
1063 }
1064 None => match &self.config.column_mapping {
1065 SqliteColumnMapping::Json { column } => {
1066 self.insert_json_tx(&mut tx, records, column).await?
1067 }
1068 SqliteColumnMapping::AutoMap => self.insert_auto_map_tx(&mut tx, records).await?,
1069 },
1070 };
1071
1072 let upsert = format!(
1073 "INSERT INTO {t} ({s}, {k}) VALUES (?, ?) ON CONFLICT({s}) DO UPDATE SET {k} = excluded.{k}, updated_at = datetime('now')",
1074 t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
1075 s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
1076 k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
1077 );
1078 sqlx::query(&upsert)
1079 .bind(scope)
1080 .bind(token)
1081 .execute(&mut *tx)
1082 .await
1083 .map_err(|e| FaucetError::Sink(format!("SQLite token upsert failed: {e}")))?;
1084
1085 tx.commit()
1086 .await
1087 .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
1088 Ok(written)
1089 }
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use super::*;
1095 use crate::config::SqliteSinkConfig;
1096 use faucet_core::Sink as _;
1097
1098 #[tokio::test]
1099 async fn dataset_uri_strips_sqlite_prefix_and_includes_table() {
1100 let config = SqliteSinkConfig::new("sqlite:///tmp/test.db", "events");
1101 let sink = SqliteSink::new(config).await.unwrap();
1102 assert_eq!(sink.dataset_uri(), "sqlite:///tmp/test.db?table=events");
1103 }
1104
1105 #[tokio::test]
1106 async fn dataset_uri_with_memory_db() {
1107 let config = SqliteSinkConfig::new("sqlite::memory:", "logs");
1108 let sink = SqliteSink::new(config).await.unwrap();
1109 assert_eq!(sink.dataset_uri(), "sqlite://:memory:?table=logs");
1110 }
1111
1112 #[test]
1113 fn sqlite_on_conflict_clause() {
1114 let clause =
1115 on_conflict_clause(&["id".to_string()], &["id".to_string(), "name".to_string()]);
1116 assert_eq!(
1117 clause,
1118 r#"ON CONFLICT("id") DO UPDATE SET "name" = excluded."name""#
1119 );
1120 }
1121
1122 #[test]
1123 fn sqlite_on_conflict_all_keys_does_nothing() {
1124 let clause = on_conflict_clause(&["id".to_string()], &["id".to_string()]);
1125 assert_eq!(clause, r#"ON CONFLICT("id") DO NOTHING"#);
1126 }
1127
1128 #[test]
1129 fn sqlite_on_conflict_composite_key() {
1130 let clause = on_conflict_clause(
1131 &["a".to_string(), "b".to_string()],
1132 &["a".to_string(), "b".to_string(), "v".to_string()],
1133 );
1134 assert_eq!(
1135 clause,
1136 r#"ON CONFLICT("a", "b") DO UPDATE SET "v" = excluded."v""#
1137 );
1138 }
1139
1140 #[test]
1141 fn sqlite_add_column_ddl() {
1142 assert_eq!(
1143 build_add_column_sql("t", "email", SqlBaseType::Text),
1144 r#"ALTER TABLE "t" ADD COLUMN "email" TEXT"#
1145 );
1146 assert_eq!(
1147 build_add_column_sql("t", "age", SqlBaseType::Integer),
1148 r#"ALTER TABLE "t" ADD COLUMN "age" INTEGER"#
1149 );
1150 assert_eq!(
1151 build_add_column_sql("t", "score", SqlBaseType::Double),
1152 r#"ALTER TABLE "t" ADD COLUMN "score" REAL"#
1153 );
1154 assert_eq!(
1156 build_add_column_sql("t", "ok", SqlBaseType::Boolean),
1157 r#"ALTER TABLE "t" ADD COLUMN "ok" INTEGER"#
1158 );
1159 assert_eq!(
1160 build_add_column_sql("t", "meta", SqlBaseType::Json),
1161 r#"ALTER TABLE "t" ADD COLUMN "meta" TEXT"#
1162 );
1163 }
1164
1165 #[test]
1166 fn sqlite_keyword_mapping() {
1167 assert_eq!(sqlite_keyword(SqlBaseType::Integer), "INTEGER");
1168 assert_eq!(sqlite_keyword(SqlBaseType::Double), "REAL");
1169 assert_eq!(sqlite_keyword(SqlBaseType::Boolean), "INTEGER");
1170 assert_eq!(sqlite_keyword(SqlBaseType::Text), "TEXT");
1171 assert_eq!(sqlite_keyword(SqlBaseType::Json), "TEXT");
1172 }
1173
1174 fn cols(names: &[&str]) -> Vec<String> {
1179 names.iter().map(|s| s.to_string()).collect()
1180 }
1181
1182 #[test]
1183 fn cleanup_quotes_identifiers_with_backticks() {
1184 assert_eq!(quote_ident_sqlite("id"), "`id`");
1187 assert_eq!(quote_ident_sqlite("ev`il"), "`ev``il`");
1188 }
1189
1190 #[test]
1191 fn cleanup_temp_table_mirrors_declared_types() {
1192 let sql = build_cleanup_temp_table_sql(&[
1193 ("id".to_string(), "INTEGER".to_string()),
1194 ("slug".to_string(), "VARCHAR(255)".to_string()),
1195 ]);
1196 assert_eq!(
1197 sql,
1198 "CREATE TEMP TABLE temp.`faucet_cleanup_keys` (`id` INTEGER, `slug` VARCHAR(255))"
1199 );
1200 }
1201
1202 #[test]
1203 fn cleanup_temp_table_omits_an_unusable_type() {
1204 let sql = build_cleanup_temp_table_sql(&[("id".to_string(), String::new())]);
1206 assert_eq!(sql, "CREATE TEMP TABLE temp.`faucet_cleanup_keys` (`id`)");
1207 let sql = build_cleanup_temp_table_sql(&[("id".to_string(), "INT); DROP".to_string())]);
1210 assert_eq!(sql, "CREATE TEMP TABLE temp.`faucet_cleanup_keys` (`id`)");
1211 }
1212
1213 #[test]
1214 fn safe_type_spec_accepts_real_types_and_rejects_the_rest() {
1215 assert_eq!(safe_type_spec("DOUBLE PRECISION"), Some("DOUBLE PRECISION"));
1216 assert_eq!(safe_type_spec("DECIMAL(10, 2)"), Some("DECIMAL(10, 2)"));
1217 assert_eq!(safe_type_spec(" TEXT "), Some("TEXT"));
1218 assert_eq!(safe_type_spec(""), None);
1219 assert_eq!(safe_type_spec(" "), None);
1220 assert_eq!(safe_type_spec("TEXT`"), None);
1221 assert_eq!(safe_type_spec("TEXT'"), None);
1222 }
1223
1224 #[test]
1225 fn cleanup_insert_emits_one_tuple_per_row() {
1226 let sql = build_cleanup_insert_sql(&cols(&["a", "b"]), 3);
1227 assert_eq!(
1228 sql,
1229 "INSERT INTO temp.`faucet_cleanup_keys` (`a`, `b`) VALUES (?, ?), (?, ?), (?, ?)"
1230 );
1231 }
1232
1233 #[test]
1234 fn cleanup_delete_ands_the_scope_and_excludes_written_keys() {
1235 let sql = build_cleanup_delete_sql("assoc", &cols(&["contact_id"]), &cols(&["id"]));
1236 assert_eq!(
1237 sql,
1238 "DELETE FROM `assoc` WHERE `assoc`.`contact_id` = ? \
1239 AND NOT EXISTS (SELECT 1 FROM temp.`faucet_cleanup_keys` c \
1240 WHERE c.`id` = `assoc`.`id`)"
1241 );
1242 }
1243
1244 #[test]
1245 fn cleanup_delete_composite_scope_and_key() {
1246 let sql =
1247 build_cleanup_delete_sql("t", &cols(&["tenant", "contact_id"]), &cols(&["a", "b"]));
1248 assert_eq!(
1249 sql,
1250 "DELETE FROM `t` WHERE `t`.`tenant` = ? AND `t`.`contact_id` = ? \
1251 AND NOT EXISTS (SELECT 1 FROM temp.`faucet_cleanup_keys` c \
1252 WHERE c.`a` = `t`.`a` AND c.`b` = `t`.`b`)"
1253 );
1254 }
1255
1256 #[test]
1257 fn cleanup_validation_names_a_missing_scope_column() {
1258 let existing: std::collections::HashSet<String> =
1259 cols(&["id", "name"]).into_iter().collect();
1260 let err = validate_cleanup_columns(&existing, &cols(&["contact_id"]), &cols(&["id"]), "t")
1261 .expect_err("unknown scope column must be refused");
1262 let msg = err.to_string();
1263 assert!(msg.contains("contact_id"), "{msg}");
1264 assert!(msg.contains("'t'"), "{msg}");
1265 }
1266
1267 #[test]
1268 fn cleanup_validation_names_a_missing_key_column() {
1269 let existing: std::collections::HashSet<String> =
1270 cols(&["contact_id"]).into_iter().collect();
1271 let err = validate_cleanup_columns(&existing, &cols(&["contact_id"]), &cols(&["id"]), "t")
1272 .expect_err("unknown key column must be refused");
1273 assert!(err.to_string().contains("id"), "{err}");
1274 }
1275
1276 #[test]
1277 fn cleanup_validation_passes_when_every_column_exists() {
1278 let existing: std::collections::HashSet<String> =
1279 cols(&["id", "contact_id"]).into_iter().collect();
1280 assert!(
1281 validate_cleanup_columns(&existing, &cols(&["contact_id"]), &cols(&["id"]), "t")
1282 .is_ok()
1283 );
1284 }
1285
1286 #[tokio::test]
1287 async fn supports_cleanup_only_in_auto_map_mode() {
1288 let config = SqliteSinkConfig::new("sqlite::memory:", "t")
1289 .column_mapping(SqliteColumnMapping::AutoMap);
1290 let sink = SqliteSink::new(config).await.unwrap();
1291 assert!(sink.supports_cleanup());
1292
1293 let config = SqliteSinkConfig::new("sqlite::memory:", "t");
1296 let sink = SqliteSink::new(config).await.unwrap();
1297 assert!(!sink.supports_cleanup());
1298 }
1299
1300 #[test]
1301 fn sqlite_affinity_round_trips_to_json_schema() {
1302 use serde_json::json;
1303 assert_eq!(
1305 sqlite_affinity_to_json_schema("INTEGER", false),
1306 json!({"type":"integer"})
1307 );
1308 assert_eq!(
1309 sqlite_affinity_to_json_schema("BIGINT", false),
1310 json!({"type":"integer"})
1311 );
1312 assert_eq!(
1313 sqlite_affinity_to_json_schema("REAL", false),
1314 json!({"type":"number"})
1315 );
1316 assert_eq!(
1317 sqlite_affinity_to_json_schema("DOUBLE PRECISION", false),
1318 json!({"type":"number"})
1319 );
1320 assert_eq!(
1321 sqlite_affinity_to_json_schema("DECIMAL(10,2)", false),
1322 json!({"type":"number"})
1323 );
1324 assert_eq!(
1325 sqlite_affinity_to_json_schema("TEXT", false),
1326 json!({"type":"string"})
1327 );
1328 assert_eq!(
1329 sqlite_affinity_to_json_schema("VARCHAR(255)", false),
1330 json!({"type":"string"})
1331 );
1332 assert_eq!(
1334 sqlite_affinity_to_json_schema("BLOB", false),
1335 json!({"type":"string"})
1336 );
1337 assert_eq!(
1338 sqlite_affinity_to_json_schema("", false),
1339 json!({"type":"string"})
1340 );
1341 assert_eq!(
1343 sqlite_affinity_to_json_schema("integer", true),
1344 json!({"type":["integer","null"]})
1345 );
1346 }
1347}