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 sqlite_keyword(t: SqlBaseType) -> &'static str {
19 match t {
20 SqlBaseType::Integer => "INTEGER",
21 SqlBaseType::Double => "REAL",
22 SqlBaseType::Boolean => "INTEGER",
23 SqlBaseType::Text => "TEXT",
24 SqlBaseType::Json => "TEXT",
25 }
26}
27
28fn build_add_column_sql(table: &str, col: &str, t: SqlBaseType) -> String {
33 format!(
34 "ALTER TABLE {} ADD COLUMN {} {}",
35 quote_ident(table),
36 quote_ident(col),
37 sqlite_keyword(t)
38 )
39}
40
41fn sqlite_affinity_to_json_schema(declared: &str, nullable: bool) -> serde_json::Value {
51 let up = declared.to_ascii_uppercase();
52 let contains = |needle: &str| up.contains(needle);
53 let base = if contains("INT") {
54 "integer"
55 } else if contains("CHAR") || contains("CLOB") || contains("TEXT") {
56 "string"
57 } else if contains("REAL")
58 || contains("FLOA")
59 || contains("DOUB")
60 || contains("NUMERIC")
61 || contains("DECIMAL")
62 {
63 "number"
64 } else {
65 "string"
66 };
67 if nullable {
68 serde_json::json!({ "type": [base, "null"] })
69 } else {
70 serde_json::json!({ "type": base })
71 }
72}
73
74fn on_conflict_clause(key: &[String], all_cols: &[String]) -> String {
78 let key_list = key
79 .iter()
80 .map(|k| quote_ident(k))
81 .collect::<Vec<_>>()
82 .join(", ");
83 let updates: Vec<String> = all_cols
84 .iter()
85 .filter(|c| !key.iter().any(|k| k == *c))
86 .map(|c| format!("{q} = excluded.{q}", q = quote_ident(c)))
87 .collect();
88 if updates.is_empty() {
89 format!("ON CONFLICT({key_list}) DO NOTHING")
90 } else {
91 format!(
92 "ON CONFLICT({key_list}) DO UPDATE SET {}",
93 updates.join(", ")
94 )
95 }
96}
97
98pub struct SqliteSink {
100 config: SqliteSinkConfig,
101 pool: SqlitePool,
102}
103
104impl SqliteSink {
105 pub async fn new(config: SqliteSinkConfig) -> Result<Self, FaucetError> {
115 config.write.validate()?;
116 if !matches!(config.write.write_mode, faucet_core::WriteMode::Append)
117 && !matches!(config.column_mapping, SqliteColumnMapping::AutoMap)
118 {
119 return Err(FaucetError::Config(
120 "sqlite sink: write_mode upsert/delete requires column_mapping: auto_map \
121 (key columns must be real columns, not inside a JSON blob)"
122 .into(),
123 ));
124 }
125
126 let options = SqliteConnectOptions::from_str(&config.database_url)
127 .map_err(|e| FaucetError::Sink(format!("invalid SQLite database_url: {e}")))?
128 .create_if_missing(true)
129 .journal_mode(SqliteJournalMode::Wal)
130 .busy_timeout(Duration::from_secs(5));
131
132 let pool = SqlitePoolOptions::new()
133 .max_connections(config.max_connections)
134 .connect_with(options)
135 .await
136 .map_err(|e| FaucetError::Sink(format!("SQLite connection failed: {e}")))?;
137
138 Ok(Self { config, pool })
139 }
140
141 async fn insert_json_tx(
144 &self,
145 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
146 records: &[Value],
147 column: &str,
148 ) -> Result<usize, FaucetError> {
149 if records.is_empty() {
150 return Ok(0);
151 }
152 const MAX_SQLITE_VARS: usize = 32766;
155 for chunk in records.chunks(MAX_SQLITE_VARS) {
156 let placeholders: Vec<&str> = chunk.iter().map(|_| "(?)").collect();
157 let insert_sql = format!(
158 "INSERT INTO {} ({}) VALUES {}",
159 quote_ident(&self.config.table_name),
160 quote_ident(column),
161 placeholders.join(", ")
162 );
163 let mut q = sqlx::query(&insert_sql);
164 for record in chunk {
165 let json_str = serde_json::to_string(record)
166 .map_err(|e| FaucetError::Sink(format!("failed to serialize record: {e}")))?;
167 q = q.bind(json_str);
168 }
169 q.execute(&mut **tx)
170 .await
171 .map_err(|e| FaucetError::Sink(format!("SQLite insert failed: {e}")))?;
172 }
173 Ok(records.len())
174 }
175
176 async fn insert_json(&self, records: &[Value], column: &str) -> Result<usize, FaucetError> {
180 if records.is_empty() {
181 return Ok(0);
182 }
183 let mut tx = self
184 .pool
185 .begin()
186 .await
187 .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
188 let n = self.insert_json_tx(&mut tx, records, column).await?;
189 tx.commit()
190 .await
191 .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
192 Ok(n)
193 }
194
195 async fn insert_auto_map(&self, records: &[Value]) -> Result<usize, FaucetError> {
201 if records.is_empty() {
202 return Ok(0);
203 }
204
205 let mut tx = self
206 .pool
207 .begin()
208 .await
209 .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
210
211 let written = self.insert_auto_map_tx(&mut tx, records).await?;
212
213 tx.commit()
214 .await
215 .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
216
217 Ok(written)
218 }
219
220 async fn insert_auto_map_with_conflict_tx(
235 &self,
236 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
237 records: &[Value],
238 conflict_key: Option<&[String]>,
239 ) -> Result<usize, FaucetError> {
240 if records.is_empty() {
241 return Ok(0);
242 }
243
244 let columns: Vec<String> = sqlx::query(&format!(
247 "PRAGMA table_info({})",
248 quote_ident(&self.config.table_name)
249 ))
250 .fetch_all(&mut **tx)
251 .await
252 .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
253 .iter()
254 .map(|row| row.get::<String, _>("name"))
255 .collect();
256
257 if columns.is_empty() {
258 return Err(FaucetError::Sink(format!(
259 "table '{}' has no columns or does not exist",
260 self.config.table_name
261 )));
262 }
263
264 let mut matched_rows: Vec<Vec<(&String, &Value)>> = Vec::with_capacity(records.len());
271 let mut used: std::collections::HashSet<&str> = std::collections::HashSet::new();
272
273 for record in records {
274 let obj = record
275 .as_object()
276 .ok_or_else(|| FaucetError::Sink("AutoMap requires JSON object records".into()))?;
277
278 let matching: Vec<(&String, &Value)> = columns
279 .iter()
280 .filter_map(|col| obj.get(col).map(|v| (col, v)))
281 .collect();
282
283 if matching.is_empty() {
284 tracing::warn!(
285 record_keys = ?obj.keys().collect::<Vec<_>>(),
286 table_columns = ?columns,
287 "record has no keys matching table columns, skipping"
288 );
289 continue;
290 }
291
292 for (c, _) in &matching {
293 used.insert(c.as_str());
294 }
295 matched_rows.push(matching);
296 }
297
298 if matched_rows.is_empty() {
299 return Ok(0);
300 }
301
302 let insert_columns: Vec<String> = columns
304 .iter()
305 .filter(|c| used.contains(c.as_str()))
306 .cloned()
307 .collect();
308
309 let num_cols = insert_columns.len();
310 let num_rows = matched_rows.len();
311 let col_names: Vec<String> = insert_columns.iter().map(|c| quote_ident(c)).collect();
312
313 const MAX_SQLITE_VARS: usize = 32766;
319 let max_rows_per_insert = (MAX_SQLITE_VARS / num_cols).max(1);
320
321 for sub in matched_rows.chunks(max_rows_per_insert) {
322 let row_placeholder = format!("({})", vec!["?"; num_cols].join(", "));
324 let value_tuples: Vec<&str> =
325 (0..sub.len()).map(|_| row_placeholder.as_str()).collect();
326 let base_query = format!(
327 "INSERT INTO {} ({}) VALUES {}",
328 quote_ident(&self.config.table_name),
329 col_names.join(", "),
330 value_tuples.join(", ")
331 );
332 let query = match conflict_key {
333 Some(key) => format!("{base_query} {}", on_conflict_clause(key, &insert_columns)),
334 None => base_query,
335 };
336
337 let mut q = sqlx::query(&query);
338 for matched in sub {
339 for col in &insert_columns {
340 let val = matched.iter().find(|(c, _)| *c == col).map(|(_, v)| *v);
341 q = match val {
347 None | Some(Value::Null) => q.bind(None::<String>),
348 Some(Value::Bool(b)) => q.bind(*b),
349 Some(Value::Number(n)) => {
350 if let Some(i) = n.as_i64() {
351 q.bind(i)
352 } else if let Some(f) = n.as_f64() {
353 q.bind(f)
354 } else {
355 q.bind(n.to_string())
357 }
358 }
359 Some(Value::String(s)) => q.bind(s.clone()),
360 Some(v) => q.bind(v.to_string()),
363 };
364 }
365 }
366
367 q.execute(&mut **tx)
368 .await
369 .map_err(|e| FaucetError::Sink(format!("SQLite insert failed: {e}")))?;
370 }
371
372 Ok(num_rows)
373 }
374
375 async fn insert_auto_map_tx(
383 &self,
384 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
385 records: &[Value],
386 ) -> Result<usize, FaucetError> {
387 self.insert_auto_map_with_conflict_tx(tx, records, None)
388 .await
389 }
390
391 async fn delete_by_keys(
395 &self,
396 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
397 deletes: &[faucet_core::KeyTuple],
398 ) -> Result<usize, FaucetError> {
399 if deletes.is_empty() {
400 return Ok(0);
401 }
402 let key = &self.config.write.key;
403 let table_ref = quote_ident(&self.config.table_name);
404 let col_list = key
405 .iter()
406 .map(|k| quote_ident(k))
407 .collect::<Vec<_>>()
408 .join(", ");
409
410 const MAX_SQLITE_VARS: usize = 32766;
411 let per = (MAX_SQLITE_VARS / key.len().max(1)).max(1);
412 let mut total = 0usize;
413
414 for chunk in deletes.chunks(per) {
415 let tuples: Vec<String> = chunk
416 .iter()
417 .map(|_| format!("({})", vec!["?"; key.len()].join(", ")))
418 .collect();
419 let sql = format!(
420 "DELETE FROM {table_ref} WHERE ({col_list}) IN ({})",
421 tuples.join(", ")
422 );
423 let mut q = sqlx::query(&sql);
424 for kt in chunk {
425 for (_, v) in &kt.0 {
426 q = match v {
428 Value::Null => q.bind(None::<String>),
429 Value::Bool(b) => q.bind(*b),
430 Value::Number(n) => {
431 if let Some(i) = n.as_i64() {
432 q.bind(i)
433 } else if let Some(f) = n.as_f64() {
434 q.bind(f)
435 } else {
436 q.bind(n.to_string())
437 }
438 }
439 Value::String(s) => q.bind(s.clone()),
440 other => q.bind(other.to_string()),
441 };
442 }
443 }
444 let res = q
445 .execute(&mut **tx)
446 .await
447 .map_err(|e| FaucetError::Sink(format!("SQLite delete failed: {e}")))?;
448 total += res.rows_affected() as usize;
449 }
450 Ok(total)
451 }
452
453 async fn apply_plan(&self, plan: &faucet_core::WritePlan) -> Result<usize, FaucetError> {
457 let mut tx = self
458 .pool
459 .begin()
460 .await
461 .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
462
463 let mut affected = 0usize;
464 if !plan.upserts.is_empty() {
465 affected += self
466 .insert_auto_map_with_conflict_tx(
467 &mut tx,
468 &plan.upserts,
469 Some(&self.config.write.key),
470 )
471 .await?;
472 }
473 if !plan.deletes.is_empty() {
474 affected += self.delete_by_keys(&mut tx, &plan.deletes).await?;
475 }
476
477 tx.commit()
478 .await
479 .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
480 Ok(affected)
481 }
482
483 async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
485 let sql = format!(
486 "CREATE TABLE IF NOT EXISTS {t} ({s} TEXT PRIMARY KEY, {k} TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')))",
487 t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
488 s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
489 k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
490 );
491 sqlx::query(&sql)
492 .execute(&self.pool)
493 .await
494 .map_err(|e| FaucetError::Sink(format!("SQLite commit-table create failed: {e}")))?;
495 Ok(())
496 }
497}
498
499#[async_trait]
500impl faucet_core::Sink for SqliteSink {
501 fn config_schema(&self) -> serde_json::Value {
502 serde_json::to_value(faucet_core::schema_for!(SqliteSinkConfig))
503 .expect("schema serialization")
504 }
505
506 fn dataset_uri(&self) -> String {
507 let path = self
508 .config
509 .database_url
510 .trim_start_matches("sqlite://")
511 .trim_start_matches("sqlite:");
512 format!("sqlite://{}?table={}", path, self.config.table_name)
513 }
514
515 async fn check(
521 &self,
522 ctx: &faucet_core::check::CheckContext,
523 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
524 use faucet_core::check::{CheckReport, Probe};
525
526 let started = std::time::Instant::now();
527 let probe =
528 match tokio::time::timeout(ctx.timeout, sqlx::query("SELECT 1").execute(&self.pool))
529 .await
530 {
531 Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
532 Ok(Err(e)) => Probe::fail_hint(
533 "auth",
534 started.elapsed(),
535 e.to_string(),
536 "check database_url / that the database file is reachable and openable",
537 ),
538 Err(_) => Probe::fail_hint(
539 "auth",
540 started.elapsed(),
541 "timed out",
542 "check database_url / that the database file is reachable and openable",
543 ),
544 };
545 Ok(CheckReport::single(probe))
546 }
547
548 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
549 &[
550 faucet_core::WriteMode::Append,
551 faucet_core::WriteMode::Upsert,
552 faucet_core::WriteMode::Delete,
553 ]
554 }
555
556 fn dedups_by_key(&self) -> bool {
557 self.config.write.dedups_by_key()
558 }
559
560 fn supports_schema_evolution(&self) -> bool {
561 true
562 }
563
564 async fn current_schema(&self) -> Result<Option<serde_json::Value>, FaucetError> {
575 let rows = sqlx::query(&format!(
576 "PRAGMA table_info({})",
577 quote_ident(&self.config.table_name)
578 ))
579 .fetch_all(&self.pool)
580 .await
581 .map_err(|e| FaucetError::Sink(format!("sqlite current_schema query failed: {e}")))?;
582
583 if rows.is_empty() {
584 return Ok(None); }
586
587 let mut props = serde_json::Map::new();
588 for row in &rows {
589 let name: String = row.get("name");
590 let declared: String = row.get("type");
591 let notnull: i64 = row.get("notnull");
592 props.insert(
593 name,
594 sqlite_affinity_to_json_schema(&declared, notnull == 0),
595 );
596 }
597 Ok(Some(
598 serde_json::json!({ "type": "object", "properties": props }),
599 ))
600 }
601
602 async fn evolve_schema(&self, evolution: &SchemaEvolution) -> Result<(), FaucetError> {
614 let existing: std::collections::HashSet<String> = sqlx::query(&format!(
617 "PRAGMA table_info({})",
618 quote_ident(&self.config.table_name)
619 ))
620 .fetch_all(&self.pool)
621 .await
622 .map_err(|e| FaucetError::Sink(format!("sqlite evolve table_info failed: {e}")))?
623 .iter()
624 .map(|row| row.get::<String, _>("name"))
625 .collect();
626
627 for c in &evolution.additions {
628 if existing.contains(&c.name) {
629 continue; }
631 let t = json_schema_base_type(&c.to).unwrap_or(SqlBaseType::Text);
632 sqlx::query(&build_add_column_sql(&self.config.table_name, &c.name, t))
633 .execute(&self.pool)
634 .await
635 .map_err(|e| {
636 FaucetError::Sink(format!("sqlite ADD COLUMN {} failed: {e}", c.name))
637 })?;
638 }
639
640 if !evolution.widenings.is_empty() {
641 tracing::debug!("sqlite: type widening is a no-op under dynamic typing");
642 }
643 for col in &evolution.relax_nullability {
644 tracing::debug!("sqlite cannot relax NOT NULL in place; column {col} left as-is");
645 }
646
647 Ok(())
648 }
649
650 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
651 if records.is_empty() {
652 return Ok(0);
653 }
654
655 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
657 let plan = faucet_core::plan_writes(records, &self.config.write);
658 if let Some((idx, msg)) = plan.failed.first() {
659 return Err(FaucetError::Sink(format!(
660 "sqlite {}: row {idx}: {msg}",
661 self.config.write.write_mode.as_str()
662 )));
663 }
664 return self.apply_plan(&plan).await;
665 }
666
667 let effective_chunk = if self.config.batch_size == 0 {
673 records.len()
674 } else {
675 self.config.batch_size
676 };
677
678 let mut total = 0;
679 for chunk in records.chunks(effective_chunk) {
680 total += match &self.config.column_mapping {
681 SqliteColumnMapping::Json { column } => self.insert_json(chunk, column).await?,
682 SqliteColumnMapping::AutoMap => self.insert_auto_map(chunk).await?,
683 };
684 }
685
686 tracing::info!(
687 table = %self.config.table_name,
688 rows = total,
689 "SQLite write complete"
690 );
691 Ok(total)
692 }
693
694 async fn write_batch_partial(
703 &self,
704 records: &[Value],
705 ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
706 if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
707 self.write_batch(records).await?;
708 return Ok(records.iter().map(|_| Ok(())).collect());
709 }
710
711 let plan = faucet_core::plan_writes(records, &self.config.write);
712 self.apply_plan(&plan).await?;
713
714 let mut outcomes: Vec<faucet_core::RowOutcome> = records.iter().map(|_| Ok(())).collect();
715 for (idx, msg) in &plan.failed {
716 outcomes[*idx] = Err(FaucetError::Sink(format!(
717 "sqlite {}: {msg}",
718 self.config.write.write_mode.as_str()
719 )));
720 }
721 Ok(outcomes)
722 }
723
724 fn supports_idempotent_writes(&self) -> bool {
725 true
726 }
727
728 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
729 self.ensure_commit_table().await?;
730 let sql = format!(
731 "SELECT {k} FROM {t} WHERE {s} = ?",
732 t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
733 k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
734 s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
735 );
736 let row = sqlx::query(&sql)
737 .bind(scope)
738 .fetch_optional(&self.pool)
739 .await
740 .map_err(|e| FaucetError::Sink(format!("SQLite token read failed: {e}")))?;
741 Ok(row.map(|r| r.get::<String, _>(0)))
742 }
743
744 async fn write_batch_idempotent(
745 &self,
746 records: &[Value],
747 scope: &str,
748 token: &str,
749 ) -> Result<usize, FaucetError> {
750 self.ensure_commit_table().await?;
751
752 let plan = if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
755 None
756 } else {
757 let plan = faucet_core::plan_writes(records, &self.config.write);
758 if let Some((idx, msg)) = plan.failed.first() {
759 return Err(FaucetError::Sink(format!(
760 "sqlite {}: row {idx}: {msg}",
761 self.config.write.write_mode.as_str()
762 )));
763 }
764 Some(plan)
765 };
766
767 let mut tx = self
768 .pool
769 .begin()
770 .await
771 .map_err(|e| FaucetError::Sink(format!("SQLite transaction begin failed: {e}")))?;
772
773 let written = match &plan {
780 Some(plan) => {
781 let mut affected = 0usize;
782 if !plan.upserts.is_empty() {
783 affected += self
784 .insert_auto_map_with_conflict_tx(
785 &mut tx,
786 &plan.upserts,
787 Some(&self.config.write.key),
788 )
789 .await?;
790 }
791 if !plan.deletes.is_empty() {
792 affected += self.delete_by_keys(&mut tx, &plan.deletes).await?;
793 }
794 affected
795 }
796 None => match &self.config.column_mapping {
797 SqliteColumnMapping::Json { column } => {
798 self.insert_json_tx(&mut tx, records, column).await?
799 }
800 SqliteColumnMapping::AutoMap => self.insert_auto_map_tx(&mut tx, records).await?,
801 },
802 };
803
804 let upsert = format!(
805 "INSERT INTO {t} ({s}, {k}) VALUES (?, ?) ON CONFLICT({s}) DO UPDATE SET {k} = excluded.{k}, updated_at = datetime('now')",
806 t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
807 s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
808 k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
809 );
810 sqlx::query(&upsert)
811 .bind(scope)
812 .bind(token)
813 .execute(&mut *tx)
814 .await
815 .map_err(|e| FaucetError::Sink(format!("SQLite token upsert failed: {e}")))?;
816
817 tx.commit()
818 .await
819 .map_err(|e| FaucetError::Sink(format!("SQLite transaction commit failed: {e}")))?;
820 Ok(written)
821 }
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827 use crate::config::SqliteSinkConfig;
828 use faucet_core::Sink as _;
829
830 #[tokio::test]
831 async fn dataset_uri_strips_sqlite_prefix_and_includes_table() {
832 let config = SqliteSinkConfig::new("sqlite:///tmp/test.db", "events");
833 let sink = SqliteSink::new(config).await.unwrap();
834 assert_eq!(sink.dataset_uri(), "sqlite:///tmp/test.db?table=events");
835 }
836
837 #[tokio::test]
838 async fn dataset_uri_with_memory_db() {
839 let config = SqliteSinkConfig::new("sqlite::memory:", "logs");
840 let sink = SqliteSink::new(config).await.unwrap();
841 assert_eq!(sink.dataset_uri(), "sqlite://:memory:?table=logs");
842 }
843
844 #[test]
845 fn sqlite_on_conflict_clause() {
846 let clause =
847 on_conflict_clause(&["id".to_string()], &["id".to_string(), "name".to_string()]);
848 assert_eq!(
849 clause,
850 r#"ON CONFLICT("id") DO UPDATE SET "name" = excluded."name""#
851 );
852 }
853
854 #[test]
855 fn sqlite_on_conflict_all_keys_does_nothing() {
856 let clause = on_conflict_clause(&["id".to_string()], &["id".to_string()]);
857 assert_eq!(clause, r#"ON CONFLICT("id") DO NOTHING"#);
858 }
859
860 #[test]
861 fn sqlite_on_conflict_composite_key() {
862 let clause = on_conflict_clause(
863 &["a".to_string(), "b".to_string()],
864 &["a".to_string(), "b".to_string(), "v".to_string()],
865 );
866 assert_eq!(
867 clause,
868 r#"ON CONFLICT("a", "b") DO UPDATE SET "v" = excluded."v""#
869 );
870 }
871
872 #[test]
873 fn sqlite_add_column_ddl() {
874 assert_eq!(
875 build_add_column_sql("t", "email", SqlBaseType::Text),
876 r#"ALTER TABLE "t" ADD COLUMN "email" TEXT"#
877 );
878 assert_eq!(
879 build_add_column_sql("t", "age", SqlBaseType::Integer),
880 r#"ALTER TABLE "t" ADD COLUMN "age" INTEGER"#
881 );
882 assert_eq!(
883 build_add_column_sql("t", "score", SqlBaseType::Double),
884 r#"ALTER TABLE "t" ADD COLUMN "score" REAL"#
885 );
886 assert_eq!(
888 build_add_column_sql("t", "ok", SqlBaseType::Boolean),
889 r#"ALTER TABLE "t" ADD COLUMN "ok" INTEGER"#
890 );
891 assert_eq!(
892 build_add_column_sql("t", "meta", SqlBaseType::Json),
893 r#"ALTER TABLE "t" ADD COLUMN "meta" TEXT"#
894 );
895 }
896
897 #[test]
898 fn sqlite_keyword_mapping() {
899 assert_eq!(sqlite_keyword(SqlBaseType::Integer), "INTEGER");
900 assert_eq!(sqlite_keyword(SqlBaseType::Double), "REAL");
901 assert_eq!(sqlite_keyword(SqlBaseType::Boolean), "INTEGER");
902 assert_eq!(sqlite_keyword(SqlBaseType::Text), "TEXT");
903 assert_eq!(sqlite_keyword(SqlBaseType::Json), "TEXT");
904 }
905
906 #[test]
907 fn sqlite_affinity_round_trips_to_json_schema() {
908 use serde_json::json;
909 assert_eq!(
911 sqlite_affinity_to_json_schema("INTEGER", false),
912 json!({"type":"integer"})
913 );
914 assert_eq!(
915 sqlite_affinity_to_json_schema("BIGINT", false),
916 json!({"type":"integer"})
917 );
918 assert_eq!(
919 sqlite_affinity_to_json_schema("REAL", false),
920 json!({"type":"number"})
921 );
922 assert_eq!(
923 sqlite_affinity_to_json_schema("DOUBLE PRECISION", false),
924 json!({"type":"number"})
925 );
926 assert_eq!(
927 sqlite_affinity_to_json_schema("DECIMAL(10,2)", false),
928 json!({"type":"number"})
929 );
930 assert_eq!(
931 sqlite_affinity_to_json_schema("TEXT", false),
932 json!({"type":"string"})
933 );
934 assert_eq!(
935 sqlite_affinity_to_json_schema("VARCHAR(255)", false),
936 json!({"type":"string"})
937 );
938 assert_eq!(
940 sqlite_affinity_to_json_schema("BLOB", false),
941 json!({"type":"string"})
942 );
943 assert_eq!(
944 sqlite_affinity_to_json_schema("", false),
945 json!({"type":"string"})
946 );
947 assert_eq!(
949 sqlite_affinity_to_json_schema("integer", true),
950 json!({"type":["integer","null"]})
951 );
952 }
953}