use crate::config::{SqliteColumnMapping, SqliteSinkConfig};
use async_trait::async_trait;
use faucet_core::util::quote_ident;
use faucet_core::{FaucetError, SchemaEvolution, SqlBaseType, json_schema_base_type};
use serde_json::Value;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use sqlx::{Row, SqlitePool};
use std::str::FromStr;
use std::time::Duration;
fn quote_ident_sqlite(name: &str) -> String {
format!("`{}`", name.replace('`', "``"))
}
const CLEANUP_KEYS_TABLE: &str = "faucet_cleanup_keys";
fn cleanup_keys_ref() -> String {
format!("temp.{}", quote_ident_sqlite(CLEANUP_KEYS_TABLE))
}
fn safe_type_spec(declared: &str) -> Option<&str> {
let t = declared.trim();
if t.is_empty() {
return None;
}
t.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '(' | ')' | ',' | '.'))
.then_some(t)
}
fn build_cleanup_temp_table_sql(key_types: &[(String, String)]) -> String {
let cols = key_types
.iter()
.map(|(col, declared)| match safe_type_spec(declared) {
Some(t) => format!("{} {t}", quote_ident_sqlite(col)),
None => quote_ident_sqlite(col),
})
.collect::<Vec<_>>()
.join(", ");
format!("CREATE TEMP TABLE {} ({cols})", cleanup_keys_ref())
}
fn build_cleanup_insert_sql(key: &[String], rows: usize) -> String {
let col_list = key
.iter()
.map(|k| quote_ident_sqlite(k))
.collect::<Vec<_>>()
.join(", ");
let tuple = format!("({})", vec!["?"; key.len()].join(", "));
let tuples = vec![tuple; rows].join(", ");
format!(
"INSERT INTO {} ({col_list}) VALUES {tuples}",
cleanup_keys_ref()
)
}
fn build_cleanup_delete_sql(table: &str, scope_cols: &[String], key: &[String]) -> String {
let t = quote_ident_sqlite(table);
let scope_pred = scope_cols
.iter()
.map(|c| format!("{t}.{} = ?", quote_ident_sqlite(c)))
.collect::<Vec<_>>()
.join(" AND ");
let join_pred = key
.iter()
.map(|k| {
let q = quote_ident_sqlite(k);
format!("c.{q} = {t}.{q}")
})
.collect::<Vec<_>>()
.join(" AND ");
format!(
"DELETE FROM {t} WHERE {scope_pred} AND NOT EXISTS (SELECT 1 FROM {} c WHERE {join_pred})",
cleanup_keys_ref()
)
}
fn validate_cleanup_columns(
existing: &std::collections::HashSet<String>,
scope_cols: &[String],
key: &[String],
table: &str,
) -> Result<(), FaucetError> {
for col in scope_cols.iter().chain(key.iter()) {
if !existing.contains(col) {
return Err(FaucetError::Sink(format!(
"cleanup: column '{col}' does not exist on table '{table}' — the \
completeness claim and `key` are in destination column terms"
)));
}
}
Ok(())
}
fn bind_value<'q>(
q: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
v: &Value,
) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> {
match v {
Value::Null => q.bind(None::<String>),
Value::Bool(b) => q.bind(*b),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
q.bind(i)
} else if let Some(f) = n.as_f64() {
q.bind(f)
} else {
q.bind(n.to_string())
}
}
Value::String(s) => q.bind(s.clone()),
other => q.bind(other.to_string()),
}
}
fn sqlite_keyword(t: SqlBaseType) -> &'static str {
match t {
SqlBaseType::Integer => "INTEGER",
SqlBaseType::Double => "REAL",
SqlBaseType::Boolean => "INTEGER",
SqlBaseType::Text => "TEXT",
SqlBaseType::Json => "TEXT",
}
}
fn build_add_column_sql(table: &str, col: &str, t: SqlBaseType) -> String {
format!(
"ALTER TABLE {} ADD COLUMN {} {}",
quote_ident(table),
quote_ident(col),
sqlite_keyword(t)
)
}
fn sqlite_affinity_to_json_schema(declared: &str, nullable: bool) -> serde_json::Value {
let up = declared.to_ascii_uppercase();
let contains = |needle: &str| up.contains(needle);
let base = if contains("INT") {
"integer"
} else if contains("CHAR") || contains("CLOB") || contains("TEXT") {
"string"
} else if contains("REAL")
|| contains("FLOA")
|| contains("DOUB")
|| contains("NUMERIC")
|| contains("DECIMAL")
{
"number"
} else {
"string"
};
if nullable {
serde_json::json!({ "type": [base, "null"] })
} else {
serde_json::json!({ "type": base })
}
}
fn on_conflict_clause(key: &[String], all_cols: &[String]) -> String {
let key_list = key
.iter()
.map(|k| quote_ident(k))
.collect::<Vec<_>>()
.join(", ");
let updates: Vec<String> = all_cols
.iter()
.filter(|c| !key.iter().any(|k| k == *c))
.map(|c| format!("{q} = excluded.{q}", q = quote_ident(c)))
.collect();
if updates.is_empty() {
format!("ON CONFLICT({key_list}) DO NOTHING")
} else {
format!(
"ON CONFLICT({key_list}) DO UPDATE SET {}",
updates.join(", ")
)
}
}
pub struct SqliteSink {
config: SqliteSinkConfig,
pool: SqlitePool,
}
impl SqliteSink {
pub async fn new(config: SqliteSinkConfig) -> Result<Self, FaucetError> {
config.write.validate()?;
if matches!(
config.write.write_mode,
faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
) && !matches!(config.column_mapping, SqliteColumnMapping::AutoMap)
{
return Err(FaucetError::Config(
"sqlite sink: write_mode upsert/delete requires column_mapping: auto_map \
(key columns must be real columns, not inside a JSON blob)"
.into(),
));
}
let options = SqliteConnectOptions::from_str(&config.database_url)
.map_err(|e| FaucetError::Sink(format!("invalid SQLite database_url: {e}")))?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.busy_timeout(Duration::from_secs(5));
let pool = SqlitePoolOptions::new()
.max_connections(config.max_connections)
.connect_with(options)
.await
.map_err(|e| FaucetError::Sink(format!("SQLite connection failed: {e}")))?;
Ok(Self { config, pool })
}
fn staging_table(&self) -> String {
format!("{}__faucet_ovw", self.config.table_name)
}
fn effective_table(&self) -> String {
if self.config.write.is_overwrite() {
self.staging_table()
} else {
self.config.table_name.clone()
}
}
async fn insert_json_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
records: &[Value],
column: &str,
) -> Result<usize, FaucetError> {
if records.is_empty() {
return Ok(0);
}
const MAX_SQLITE_VARS: usize = 32766;
for chunk in records.chunks(MAX_SQLITE_VARS) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "(?)").collect();
let insert_sql = format!(
"INSERT INTO {} ({}) VALUES {}",
quote_ident(&self.effective_table()),
quote_ident(column),
placeholders.join(", ")
);
let mut q = sqlx::query(&insert_sql);
for record in chunk {
let json_str = serde_json::to_string(record)
.map_err(|e| FaucetError::Sink(format!("failed to serialize record: {e}")))?;
q = q.bind(json_str);
}
q.execute(&mut **tx)
.await
.map_err(|e| FaucetError::Sink(format!("SQLite insert failed: {e}")))?;
}
Ok(records.len())
}
async fn insert_json(&self, records: &[Value], column: &str) -> Result<usize, FaucetError> {
if records.is_empty() {
return Ok(0);
}
let mut tx = self
.pool
.begin()
.await
.map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
let n = self.insert_json_tx(&mut tx, records, column).await?;
tx.commit()
.await
.map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
Ok(n)
}
async fn insert_auto_map(&self, records: &[Value]) -> Result<usize, FaucetError> {
if records.is_empty() {
return Ok(0);
}
let mut tx = self
.pool
.begin()
.await
.map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
let written = self.insert_auto_map_tx(&mut tx, records).await?;
tx.commit()
.await
.map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
Ok(written)
}
async fn insert_auto_map_with_conflict_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
records: &[Value],
conflict_key: Option<&[String]>,
) -> Result<usize, FaucetError> {
if records.is_empty() {
return Ok(0);
}
let effective_table = self.effective_table();
let columns: Vec<String> = sqlx::query(&format!(
"PRAGMA table_info({})",
quote_ident(&effective_table)
))
.fetch_all(&mut **tx)
.await
.map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
.iter()
.map(|row| row.get::<String, _>("name"))
.collect();
if columns.is_empty() {
return Err(FaucetError::Sink(format!(
"table '{effective_table}' has no columns or does not exist"
)));
}
let mut matched_rows: Vec<Vec<(&String, &Value)>> = Vec::with_capacity(records.len());
let mut used: std::collections::HashSet<&str> = std::collections::HashSet::new();
for record in records {
let obj = record
.as_object()
.ok_or_else(|| FaucetError::Sink("AutoMap requires JSON object records".into()))?;
let matching: Vec<(&String, &Value)> = columns
.iter()
.filter_map(|col| obj.get(col).map(|v| (col, v)))
.collect();
if matching.is_empty() {
tracing::warn!(
record_keys = ?obj.keys().collect::<Vec<_>>(),
table_columns = ?columns,
"record has no keys matching table columns, skipping"
);
continue;
}
for (c, _) in &matching {
used.insert(c.as_str());
}
matched_rows.push(matching);
}
if matched_rows.is_empty() {
return Ok(0);
}
let insert_columns: Vec<String> = columns
.iter()
.filter(|c| used.contains(c.as_str()))
.cloned()
.collect();
let num_cols = insert_columns.len();
let num_rows = matched_rows.len();
let col_names: Vec<String> = insert_columns.iter().map(|c| quote_ident(c)).collect();
const MAX_SQLITE_VARS: usize = 32766;
let max_rows_per_insert = (MAX_SQLITE_VARS / num_cols).max(1);
for sub in matched_rows.chunks(max_rows_per_insert) {
let row_placeholder = format!("({})", vec!["?"; num_cols].join(", "));
let value_tuples: Vec<&str> =
(0..sub.len()).map(|_| row_placeholder.as_str()).collect();
let base_query = format!(
"INSERT INTO {} ({}) VALUES {}",
quote_ident(&effective_table),
col_names.join(", "),
value_tuples.join(", ")
);
let query = match conflict_key {
Some(key) => format!("{base_query} {}", on_conflict_clause(key, &insert_columns)),
None => base_query,
};
let mut q = sqlx::query(&query);
for matched in sub {
for col in &insert_columns {
let val = matched.iter().find(|(c, _)| *c == col).map(|(_, v)| *v);
q = match val {
None | Some(Value::Null) => q.bind(None::<String>),
Some(Value::Bool(b)) => q.bind(*b),
Some(Value::Number(n)) => {
if let Some(i) = n.as_i64() {
q.bind(i)
} else if let Some(f) = n.as_f64() {
q.bind(f)
} else {
q.bind(n.to_string())
}
}
Some(Value::String(s)) => q.bind(s.clone()),
Some(v) => q.bind(v.to_string()),
};
}
}
q.execute(&mut **tx)
.await
.map_err(|e| FaucetError::Sink(format!("SQLite insert failed: {e}")))?;
}
Ok(num_rows)
}
async fn insert_auto_map_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
records: &[Value],
) -> Result<usize, FaucetError> {
self.insert_auto_map_with_conflict_tx(tx, records, None)
.await
}
async fn delete_by_keys(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
deletes: &[faucet_core::KeyTuple],
) -> Result<usize, FaucetError> {
if deletes.is_empty() {
return Ok(0);
}
let key = &self.config.write.key;
let table_ref = quote_ident(&self.config.table_name);
let col_list = key
.iter()
.map(|k| quote_ident(k))
.collect::<Vec<_>>()
.join(", ");
const MAX_SQLITE_VARS: usize = 32766;
let per = (MAX_SQLITE_VARS / key.len().max(1)).max(1);
let mut total = 0usize;
for chunk in deletes.chunks(per) {
let tuples: Vec<String> = chunk
.iter()
.map(|_| format!("({})", vec!["?"; key.len()].join(", ")))
.collect();
let sql = format!(
"DELETE FROM {table_ref} WHERE ({col_list}) IN ({})",
tuples.join(", ")
);
let mut q = sqlx::query(&sql);
for kt in chunk {
for (_, v) in &kt.0 {
q = bind_value(q, v);
}
}
let res = q
.execute(&mut **tx)
.await
.map_err(|e| FaucetError::Sink(format!("SQLite delete failed: {e}")))?;
total += res.rows_affected() as usize;
}
Ok(total)
}
async fn apply_plan(&self, plan: &faucet_core::WritePlan) -> Result<usize, FaucetError> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
let mut affected = 0usize;
if !plan.upserts.is_empty() {
affected += self
.insert_auto_map_with_conflict_tx(
&mut tx,
&plan.upserts,
Some(&self.config.write.key),
)
.await?;
}
if !plan.deletes.is_empty() {
affected += self.delete_by_keys(&mut tx, &plan.deletes).await?;
}
tx.commit()
.await
.map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
Ok(affected)
}
async fn cleanup_scope_impl(
&self,
scope: &std::collections::BTreeMap<String, Value>,
seen: &faucet_core::SeenKeys,
) -> Result<u64, FaucetError> {
let key = &self.config.write.key;
if key.is_empty() {
return Err(FaucetError::Sink(
"cleanup requires a non-empty `key`".to_string(),
));
}
let table = &self.config.table_name;
let mut tx = self
.pool
.begin()
.await
.map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
let declared: std::collections::HashMap<String, String> =
sqlx::query(&format!("PRAGMA table_info({})", quote_ident_sqlite(table)))
.fetch_all(&mut *tx)
.await
.map_err(|e| FaucetError::Sink(format!("cleanup: table_info query failed: {e}")))?
.iter()
.map(|row| (row.get::<String, _>("name"), row.get::<String, _>("type")))
.collect();
let scope_cols: Vec<String> = scope.keys().cloned().collect();
let existing: std::collections::HashSet<String> = declared.keys().cloned().collect();
validate_cleanup_columns(&existing, &scope_cols, key, table)?;
let keys_ref = cleanup_keys_ref();
sqlx::query(&format!("DROP TABLE IF EXISTS {keys_ref}"))
.execute(&mut *tx)
.await
.map_err(|e| FaucetError::Sink(format!("cleanup: temp table drop failed: {e}")))?;
let key_types: Vec<(String, String)> = key
.iter()
.map(|k| (k.clone(), declared.get(k).cloned().unwrap_or_default()))
.collect();
sqlx::query(&build_cleanup_temp_table_sql(&key_types))
.execute(&mut *tx)
.await
.map_err(|e| FaucetError::Sink(format!("cleanup: temp table creation failed: {e}")))?;
const MAX_SQLITE_VARS: usize = 32766;
let per = (MAX_SQLITE_VARS / key.len()).max(1);
for chunk in seen.keys().chunks(per) {
let sql = build_cleanup_insert_sql(key, chunk.len());
let mut q = sqlx::query(&sql);
for kt in chunk {
for (_, v) in &kt.0 {
q = bind_value(q, v);
}
}
q.execute(&mut *tx)
.await
.map_err(|e| FaucetError::Sink(format!("cleanup: loading keys failed: {e}")))?;
}
let sql = build_cleanup_delete_sql(table, &scope_cols, key);
let mut q = sqlx::query(&sql);
for v in scope.values() {
q = bind_value(q, v);
}
let res = q
.execute(&mut *tx)
.await
.map_err(|e| FaucetError::Sink(format!("cleanup: delete failed: {e}")))?;
sqlx::query(&format!("DROP TABLE IF EXISTS {keys_ref}"))
.execute(&mut *tx)
.await
.map_err(|e| FaucetError::Sink(format!("cleanup: temp table drop failed: {e}")))?;
tx.commit()
.await
.map_err(|e| FaucetError::Sink(format!("cleanup: commit failed: {e}")))?;
Ok(res.rows_affected())
}
async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
let sql = format!(
"CREATE TABLE IF NOT EXISTS {t} ({s} TEXT PRIMARY KEY, {k} TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')))",
t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
);
sqlx::query(&sql)
.execute(&self.pool)
.await
.map_err(|e| FaucetError::Sink(format!("SQLite commit-table create failed: {e}")))?;
Ok(())
}
}
#[async_trait]
impl faucet_core::Sink for SqliteSink {
fn connector_name(&self) -> &'static str {
"sqlite"
}
fn config_schema(&self) -> serde_json::Value {
serde_json::to_value(faucet_core::schema_for!(SqliteSinkConfig))
.expect("schema serialization")
}
fn dataset_uri(&self) -> String {
let path = self
.config
.database_url
.trim_start_matches("sqlite://")
.trim_start_matches("sqlite:");
format!("sqlite://{}?table={}", path, self.config.table_name)
}
async fn check(
&self,
ctx: &faucet_core::check::CheckContext,
) -> Result<faucet_core::check::CheckReport, FaucetError> {
use faucet_core::check::{CheckReport, Probe};
let started = std::time::Instant::now();
let probe =
match tokio::time::timeout(ctx.timeout, sqlx::query("SELECT 1").execute(&self.pool))
.await
{
Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
Ok(Err(e)) => Probe::fail_hint(
"auth",
started.elapsed(),
e.to_string(),
"check database_url / that the database file is reachable and openable",
),
Err(_) => Probe::fail_hint(
"auth",
started.elapsed(),
"timed out",
"check database_url / that the database file is reachable and openable",
),
};
Ok(CheckReport::single(probe))
}
fn supports_cleanup(&self) -> bool {
matches!(self.config.column_mapping, SqliteColumnMapping::AutoMap)
}
async fn cleanup_scope(
&self,
scope: &std::collections::BTreeMap<String, Value>,
seen: &faucet_core::SeenKeys,
) -> Result<u64, FaucetError> {
self.cleanup_scope_impl(scope, seen).await
}
fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
&[
faucet_core::WriteMode::Append,
faucet_core::WriteMode::Upsert,
faucet_core::WriteMode::Delete,
faucet_core::WriteMode::Overwrite,
]
}
fn dedups_by_key(&self) -> bool {
self.config.write.dedups_by_key()
}
fn is_overwrite(&self) -> bool {
self.config.write.is_overwrite()
}
async fn begin_overwrite(&self) -> Result<(), FaucetError> {
let staging = quote_ident(&self.staging_table());
let target = quote_ident(&self.config.table_name);
sqlx::query(&format!("DROP TABLE IF EXISTS {staging}"))
.execute(&self.pool)
.await
.map_err(|e| FaucetError::Sink(format!("sqlite overwrite: drop stale staging: {e}")))?;
sqlx::query(&format!(
"CREATE TABLE {staging} AS SELECT * FROM {target} WHERE 0"
))
.execute(&self.pool)
.await
.map_err(|e| {
FaucetError::Sink(format!(
"sqlite overwrite: create staging from '{}' (does the table exist?): {e}",
self.config.table_name
))
})?;
Ok(())
}
async fn commit_overwrite(&self) -> Result<(), FaucetError> {
let staging = quote_ident(&self.staging_table());
let target = quote_ident(&self.config.table_name);
let mut tx = self
.pool
.begin()
.await
.map_err(|e| FaucetError::Sink(format!("sqlite overwrite: begin swap: {e}")))?;
for stmt in [
format!("DELETE FROM {target}"),
format!("INSERT INTO {target} SELECT * FROM {staging}"),
format!("DROP TABLE {staging}"),
] {
sqlx::query(&stmt)
.execute(&mut *tx)
.await
.map_err(|e| FaucetError::Sink(format!("sqlite overwrite swap failed: {e}")))?;
}
tx.commit()
.await
.map_err(|e| FaucetError::Sink(format!("sqlite overwrite: commit swap: {e}")))?;
Ok(())
}
async fn abort_overwrite(&self) -> Result<(), FaucetError> {
sqlx::query(&format!(
"DROP TABLE IF EXISTS {}",
quote_ident(&self.staging_table())
))
.execute(&self.pool)
.await
.map_err(|e| FaucetError::Sink(format!("sqlite overwrite: drop staging: {e}")))?;
Ok(())
}
fn supports_schema_evolution(&self) -> bool {
true
}
async fn current_schema(&self) -> Result<Option<serde_json::Value>, FaucetError> {
let rows = sqlx::query(&format!(
"PRAGMA table_info({})",
quote_ident(&self.config.table_name)
))
.fetch_all(&self.pool)
.await
.map_err(|e| FaucetError::Sink(format!("sqlite current_schema query failed: {e}")))?;
if rows.is_empty() {
return Ok(None); }
let mut props = serde_json::Map::new();
for row in &rows {
let name: String = row.get("name");
let declared: String = row.get("type");
let notnull: i64 = row.get("notnull");
props.insert(
name,
sqlite_affinity_to_json_schema(&declared, notnull == 0),
);
}
Ok(Some(
serde_json::json!({ "type": "object", "properties": props }),
))
}
async fn evolve_schema(&self, evolution: &SchemaEvolution) -> Result<(), FaucetError> {
let existing: std::collections::HashSet<String> = sqlx::query(&format!(
"PRAGMA table_info({})",
quote_ident(&self.config.table_name)
))
.fetch_all(&self.pool)
.await
.map_err(|e| FaucetError::Sink(format!("sqlite evolve table_info failed: {e}")))?
.iter()
.map(|row| row.get::<String, _>("name"))
.collect();
for c in &evolution.additions {
if existing.contains(&c.name) {
continue; }
let t = json_schema_base_type(&c.to).unwrap_or(SqlBaseType::Text);
sqlx::query(&build_add_column_sql(&self.config.table_name, &c.name, t))
.execute(&self.pool)
.await
.map_err(|e| {
FaucetError::Sink(format!("sqlite ADD COLUMN {} failed: {e}", c.name))
})?;
}
if !evolution.widenings.is_empty() {
tracing::debug!("sqlite: type widening is a no-op under dynamic typing");
}
for col in &evolution.relax_nullability {
tracing::debug!("sqlite cannot relax NOT NULL in place; column {col} left as-is");
}
Ok(())
}
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
if records.is_empty() {
return Ok(0);
}
if matches!(
self.config.write.write_mode,
faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
) {
let plan = faucet_core::plan_writes(records, &self.config.write);
if let Some((idx, msg)) = plan.failed.first() {
return Err(FaucetError::Sink(format!(
"sqlite {}: row {idx}: {msg}",
self.config.write.write_mode.as_str()
)));
}
return self.apply_plan(&plan).await;
}
let effective_chunk = if self.config.batch_size == 0 {
records.len()
} else {
self.config.batch_size
};
let mut total = 0;
for chunk in records.chunks(effective_chunk) {
total += match &self.config.column_mapping {
SqliteColumnMapping::Json { column } => self.insert_json(chunk, column).await?,
SqliteColumnMapping::AutoMap => self.insert_auto_map(chunk).await?,
};
}
tracing::info!(
table = %self.config.table_name,
rows = total,
"SQLite write complete"
);
Ok(total)
}
async fn write_batch_partial(
&self,
records: &[Value],
) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
if !matches!(
self.config.write.write_mode,
faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
) {
self.write_batch(records).await?;
return Ok(records.iter().map(|_| Ok(())).collect());
}
let plan = faucet_core::plan_writes(records, &self.config.write);
self.apply_plan(&plan).await?;
let mut outcomes: Vec<faucet_core::RowOutcome> = records.iter().map(|_| Ok(())).collect();
for (idx, msg) in &plan.failed {
outcomes[*idx] = Err(FaucetError::Sink(format!(
"sqlite {}: {msg}",
self.config.write.write_mode.as_str()
)));
}
Ok(outcomes)
}
fn supports_idempotent_writes(&self) -> bool {
true
}
async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
self.ensure_commit_table().await?;
let sql = format!(
"SELECT {k} FROM {t} WHERE {s} = ?",
t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
);
let row = sqlx::query(&sql)
.bind(scope)
.fetch_optional(&self.pool)
.await
.map_err(|e| FaucetError::Sink(format!("SQLite token read failed: {e}")))?;
Ok(row.map(|r| r.get::<String, _>(0)))
}
async fn write_batch_idempotent(
&self,
records: &[Value],
scope: &str,
token: &str,
) -> Result<usize, FaucetError> {
self.ensure_commit_table().await?;
let plan = if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
None
} else {
let plan = faucet_core::plan_writes(records, &self.config.write);
if let Some((idx, msg)) = plan.failed.first() {
return Err(FaucetError::Sink(format!(
"sqlite {}: row {idx}: {msg}",
self.config.write.write_mode.as_str()
)));
}
Some(plan)
};
let mut tx = self
.pool
.begin()
.await
.map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
let written = match &plan {
Some(plan) => {
let mut affected = 0usize;
if !plan.upserts.is_empty() {
affected += self
.insert_auto_map_with_conflict_tx(
&mut tx,
&plan.upserts,
Some(&self.config.write.key),
)
.await?;
}
if !plan.deletes.is_empty() {
affected += self.delete_by_keys(&mut tx, &plan.deletes).await?;
}
affected
}
None => match &self.config.column_mapping {
SqliteColumnMapping::Json { column } => {
self.insert_json_tx(&mut tx, records, column).await?
}
SqliteColumnMapping::AutoMap => self.insert_auto_map_tx(&mut tx, records).await?,
},
};
let upsert = format!(
"INSERT INTO {t} ({s}, {k}) VALUES (?, ?) ON CONFLICT({s}) DO UPDATE SET {k} = excluded.{k}, updated_at = datetime('now')",
t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
);
sqlx::query(&upsert)
.bind(scope)
.bind(token)
.execute(&mut *tx)
.await
.map_err(|e| FaucetError::Sink(format!("SQLite token upsert failed: {e}")))?;
tx.commit()
.await
.map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
Ok(written)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SqliteSinkConfig;
use faucet_core::Sink as _;
#[tokio::test]
async fn dataset_uri_strips_sqlite_prefix_and_includes_table() {
let config = SqliteSinkConfig::new("sqlite:///tmp/test.db", "events");
let sink = SqliteSink::new(config).await.unwrap();
assert_eq!(sink.dataset_uri(), "sqlite:///tmp/test.db?table=events");
}
#[tokio::test]
async fn dataset_uri_with_memory_db() {
let config = SqliteSinkConfig::new("sqlite::memory:", "logs");
let sink = SqliteSink::new(config).await.unwrap();
assert_eq!(sink.dataset_uri(), "sqlite://:memory:?table=logs");
}
#[test]
fn sqlite_on_conflict_clause() {
let clause =
on_conflict_clause(&["id".to_string()], &["id".to_string(), "name".to_string()]);
assert_eq!(
clause,
r#"ON CONFLICT("id") DO UPDATE SET "name" = excluded."name""#
);
}
#[test]
fn sqlite_on_conflict_all_keys_does_nothing() {
let clause = on_conflict_clause(&["id".to_string()], &["id".to_string()]);
assert_eq!(clause, r#"ON CONFLICT("id") DO NOTHING"#);
}
#[test]
fn sqlite_on_conflict_composite_key() {
let clause = on_conflict_clause(
&["a".to_string(), "b".to_string()],
&["a".to_string(), "b".to_string(), "v".to_string()],
);
assert_eq!(
clause,
r#"ON CONFLICT("a", "b") DO UPDATE SET "v" = excluded."v""#
);
}
#[test]
fn sqlite_add_column_ddl() {
assert_eq!(
build_add_column_sql("t", "email", SqlBaseType::Text),
r#"ALTER TABLE "t" ADD COLUMN "email" TEXT"#
);
assert_eq!(
build_add_column_sql("t", "age", SqlBaseType::Integer),
r#"ALTER TABLE "t" ADD COLUMN "age" INTEGER"#
);
assert_eq!(
build_add_column_sql("t", "score", SqlBaseType::Double),
r#"ALTER TABLE "t" ADD COLUMN "score" REAL"#
);
assert_eq!(
build_add_column_sql("t", "ok", SqlBaseType::Boolean),
r#"ALTER TABLE "t" ADD COLUMN "ok" INTEGER"#
);
assert_eq!(
build_add_column_sql("t", "meta", SqlBaseType::Json),
r#"ALTER TABLE "t" ADD COLUMN "meta" TEXT"#
);
}
#[test]
fn sqlite_keyword_mapping() {
assert_eq!(sqlite_keyword(SqlBaseType::Integer), "INTEGER");
assert_eq!(sqlite_keyword(SqlBaseType::Double), "REAL");
assert_eq!(sqlite_keyword(SqlBaseType::Boolean), "INTEGER");
assert_eq!(sqlite_keyword(SqlBaseType::Text), "TEXT");
assert_eq!(sqlite_keyword(SqlBaseType::Json), "TEXT");
}
fn cols(names: &[&str]) -> Vec<String> {
names.iter().map(|s| s.to_string()).collect()
}
#[test]
fn cleanup_quotes_identifiers_with_backticks() {
assert_eq!(quote_ident_sqlite("id"), "`id`");
assert_eq!(quote_ident_sqlite("ev`il"), "`ev``il`");
}
#[test]
fn cleanup_temp_table_mirrors_declared_types() {
let sql = build_cleanup_temp_table_sql(&[
("id".to_string(), "INTEGER".to_string()),
("slug".to_string(), "VARCHAR(255)".to_string()),
]);
assert_eq!(
sql,
"CREATE TEMP TABLE temp.`faucet_cleanup_keys` (`id` INTEGER, `slug` VARCHAR(255))"
);
}
#[test]
fn cleanup_temp_table_omits_an_unusable_type() {
let sql = build_cleanup_temp_table_sql(&[("id".to_string(), String::new())]);
assert_eq!(sql, "CREATE TEMP TABLE temp.`faucet_cleanup_keys` (`id`)");
let sql = build_cleanup_temp_table_sql(&[("id".to_string(), "INT); DROP".to_string())]);
assert_eq!(sql, "CREATE TEMP TABLE temp.`faucet_cleanup_keys` (`id`)");
}
#[test]
fn safe_type_spec_accepts_real_types_and_rejects_the_rest() {
assert_eq!(safe_type_spec("DOUBLE PRECISION"), Some("DOUBLE PRECISION"));
assert_eq!(safe_type_spec("DECIMAL(10, 2)"), Some("DECIMAL(10, 2)"));
assert_eq!(safe_type_spec(" TEXT "), Some("TEXT"));
assert_eq!(safe_type_spec(""), None);
assert_eq!(safe_type_spec(" "), None);
assert_eq!(safe_type_spec("TEXT`"), None);
assert_eq!(safe_type_spec("TEXT'"), None);
}
#[test]
fn cleanup_insert_emits_one_tuple_per_row() {
let sql = build_cleanup_insert_sql(&cols(&["a", "b"]), 3);
assert_eq!(
sql,
"INSERT INTO temp.`faucet_cleanup_keys` (`a`, `b`) VALUES (?, ?), (?, ?), (?, ?)"
);
}
#[test]
fn cleanup_delete_ands_the_scope_and_excludes_written_keys() {
let sql = build_cleanup_delete_sql("assoc", &cols(&["contact_id"]), &cols(&["id"]));
assert_eq!(
sql,
"DELETE FROM `assoc` WHERE `assoc`.`contact_id` = ? \
AND NOT EXISTS (SELECT 1 FROM temp.`faucet_cleanup_keys` c \
WHERE c.`id` = `assoc`.`id`)"
);
}
#[test]
fn cleanup_delete_composite_scope_and_key() {
let sql =
build_cleanup_delete_sql("t", &cols(&["tenant", "contact_id"]), &cols(&["a", "b"]));
assert_eq!(
sql,
"DELETE FROM `t` WHERE `t`.`tenant` = ? AND `t`.`contact_id` = ? \
AND NOT EXISTS (SELECT 1 FROM temp.`faucet_cleanup_keys` c \
WHERE c.`a` = `t`.`a` AND c.`b` = `t`.`b`)"
);
}
#[test]
fn cleanup_validation_names_a_missing_scope_column() {
let existing: std::collections::HashSet<String> =
cols(&["id", "name"]).into_iter().collect();
let err = validate_cleanup_columns(&existing, &cols(&["contact_id"]), &cols(&["id"]), "t")
.expect_err("unknown scope column must be refused");
let msg = err.to_string();
assert!(msg.contains("contact_id"), "{msg}");
assert!(msg.contains("'t'"), "{msg}");
}
#[test]
fn cleanup_validation_names_a_missing_key_column() {
let existing: std::collections::HashSet<String> =
cols(&["contact_id"]).into_iter().collect();
let err = validate_cleanup_columns(&existing, &cols(&["contact_id"]), &cols(&["id"]), "t")
.expect_err("unknown key column must be refused");
assert!(err.to_string().contains("id"), "{err}");
}
#[test]
fn cleanup_validation_passes_when_every_column_exists() {
let existing: std::collections::HashSet<String> =
cols(&["id", "contact_id"]).into_iter().collect();
assert!(
validate_cleanup_columns(&existing, &cols(&["contact_id"]), &cols(&["id"]), "t")
.is_ok()
);
}
#[tokio::test]
async fn supports_cleanup_only_in_auto_map_mode() {
let config = SqliteSinkConfig::new("sqlite::memory:", "t")
.column_mapping(SqliteColumnMapping::AutoMap);
let sink = SqliteSink::new(config).await.unwrap();
assert!(sink.supports_cleanup());
let config = SqliteSinkConfig::new("sqlite::memory:", "t");
let sink = SqliteSink::new(config).await.unwrap();
assert!(!sink.supports_cleanup());
}
#[test]
fn sqlite_affinity_round_trips_to_json_schema() {
use serde_json::json;
assert_eq!(
sqlite_affinity_to_json_schema("INTEGER", false),
json!({"type":"integer"})
);
assert_eq!(
sqlite_affinity_to_json_schema("BIGINT", false),
json!({"type":"integer"})
);
assert_eq!(
sqlite_affinity_to_json_schema("REAL", false),
json!({"type":"number"})
);
assert_eq!(
sqlite_affinity_to_json_schema("DOUBLE PRECISION", false),
json!({"type":"number"})
);
assert_eq!(
sqlite_affinity_to_json_schema("DECIMAL(10,2)", false),
json!({"type":"number"})
);
assert_eq!(
sqlite_affinity_to_json_schema("TEXT", false),
json!({"type":"string"})
);
assert_eq!(
sqlite_affinity_to_json_schema("VARCHAR(255)", false),
json!({"type":"string"})
);
assert_eq!(
sqlite_affinity_to_json_schema("BLOB", false),
json!({"type":"string"})
);
assert_eq!(
sqlite_affinity_to_json_schema("", false),
json!({"type":"string"})
);
assert_eq!(
sqlite_affinity_to_json_schema("integer", true),
json!({"type":["integer","null"]})
);
}
}