1use crate::config::SqliteSourceConfig;
4use async_trait::async_trait;
5use faucet_core::shard::{
6 PkShardBounds, ShardSpec, parse_pk_shard, pk_bounds_query, pk_shards_from_bounds,
7};
8use faucet_core::{FaucetError, Stream, StreamPage};
9use futures::TryStreamExt;
10use serde_json::Value;
11use sqlx::sqlite::SqlitePoolOptions;
12use sqlx::{Column, Row, SqlitePool};
13use std::pin::Pin;
14use std::sync::Mutex;
15
16pub struct SqliteSource {
18 config: SqliteSourceConfig,
19 pool: SqlitePool,
20 applied_shard: Mutex<Option<PkShardBounds>>,
24}
25
26fn quote_ident_sqlite(name: &str) -> String {
36 format!("`{}`", name.replace('`', "``"))
37}
38
39impl SqliteSource {
40 pub async fn new(config: SqliteSourceConfig) -> Result<Self, FaucetError> {
42 faucet_core::validate_batch_size(config.batch_size)?;
43
44 let pool = SqlitePoolOptions::new()
45 .max_connections(config.max_connections)
46 .connect(&config.database_url)
47 .await
48 .map_err(|e| FaucetError::Config(format!("SQLite connection failed: {e}")))?;
49
50 Ok(Self {
51 config,
52 pool,
53 applied_shard: Mutex::new(None),
54 })
55 }
56
57 fn shard_wrap(&self, query: String) -> String {
59 match &*self.applied_shard.lock().expect("shard mutex poisoned") {
60 Some(bounds) => bounds.wrap(&query, quote_ident_sqlite),
61 None => query,
62 }
63 }
64}
65
66fn sqlite_value_to_json(row: &sqlx::sqlite::SqliteRow, col_name: &str) -> Value {
71 if let Ok(v) = row.try_get::<Value, _>(col_name) {
73 return v;
74 }
75
76 if let Ok(v) = row.try_get::<String, _>(col_name) {
77 return Value::String(v);
78 }
79 if let Ok(v) = row.try_get::<i64, _>(col_name) {
80 return Value::Number(v.into());
81 }
82 if let Ok(v) = row.try_get::<i32, _>(col_name) {
83 return Value::Number(v.into());
84 }
85 if let Ok(v) = row.try_get::<f64, _>(col_name) {
86 return serde_json::Number::from_f64(v)
87 .map(Value::Number)
88 .unwrap_or(Value::Null);
89 }
90 if let Ok(v) = row.try_get::<bool, _>(col_name) {
91 return Value::Bool(v);
92 }
93 if let Ok(v) = row.try_get::<Vec<u8>, _>(col_name) {
97 use base64::Engine as _;
98 return Value::String(base64::engine::general_purpose::STANDARD.encode(v));
99 }
100
101 Value::Null
102}
103
104fn resolve_query(
110 config: &SqliteSourceConfig,
111 context: &std::collections::HashMap<String, Value>,
112) -> (String, Vec<Value>) {
113 if context.is_empty() {
114 (config.query.clone(), Vec::new())
115 } else {
116 faucet_core::util::substitute_context_bind_params(&config.query, context, 1, |_| {
117 "?".to_string()
118 })
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131enum NumberBind {
132 I64,
134 U64,
137 F64,
139}
140
141fn classify_number(n: &serde_json::Number) -> NumberBind {
147 if n.is_i64() {
148 NumberBind::I64
149 } else if n.is_u64() {
150 NumberBind::U64
151 } else {
152 NumberBind::F64
153 }
154}
155
156fn bind_params<'q>(
158 mut query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
159 bind_values: &'q [Value],
160) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> {
161 for value in bind_values {
162 query = match value {
163 Value::String(s) => query.bind(s.clone()),
164 Value::Number(n) => match classify_number(n) {
165 NumberBind::I64 => query.bind(n.as_i64().unwrap()),
167 NumberBind::U64 => query.bind(n.as_u64().unwrap() as i64),
171 NumberBind::F64 => query.bind(n.as_f64().unwrap_or(0.0)),
172 },
173 Value::Bool(b) => query.bind(*b),
174 Value::Null => query.bind(None::<String>),
175 _ => query.bind(value.to_string()),
176 };
177 }
178 query
179}
180
181fn row_to_json(row: &sqlx::sqlite::SqliteRow) -> Value {
184 let mut map = serde_json::Map::new();
185 for col in row.columns() {
186 let name = col.name().to_string();
187 let value = sqlite_value_to_json(row, &name);
188 map.insert(name, value);
189 }
190 Value::Object(map)
191}
192
193#[async_trait]
194impl faucet_core::Source for SqliteSource {
195 async fn fetch_with_context(
196 &self,
197 context: &std::collections::HashMap<String, serde_json::Value>,
198 ) -> Result<Vec<Value>, FaucetError> {
199 let (query_str, bind_values) = resolve_query(&self.config, context);
200 let query_str = self.shard_wrap(query_str);
201 let query = bind_params(sqlx::query(&query_str), &bind_values);
202
203 let rows = query
204 .fetch_all(&self.pool)
205 .await
206 .map_err(|e| FaucetError::Config(format!("SQLite query failed: {e}")))?;
207
208 let records: Vec<Value> = rows.iter().map(row_to_json).collect();
209 tracing::info!(
210 rows = records.len(),
211 query = %self.config.query,
212 "SQLite source fetch complete"
213 );
214 Ok(records)
215 }
216
217 fn stream_pages<'a>(
232 &'a self,
233 context: &'a std::collections::HashMap<String, Value>,
234 _batch_size: usize,
235 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
236 let batch_size = self.config.batch_size;
237
238 Box::pin(async_stream::try_stream! {
239 let (query_str, bind_values) = resolve_query(&self.config, context);
240 let query_str = self.shard_wrap(query_str);
241 let query = bind_params(sqlx::query(&query_str), &bind_values);
242
243 let mut rows = query.fetch(&self.pool);
244 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
245 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
246 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
247 let mut total = 0usize;
248
249 while let Some(row) = rows
250 .try_next()
251 .await
252 .map_err(|e| FaucetError::Config(format!("SQLite query failed: {e}")))?
253 {
254 buffer.push(row_to_json(&row));
255 if buffer.len() >= chunk {
256 let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
257 total += page.len();
258 yield StreamPage { records: page, bookmark: None };
259 }
260 }
261 if !buffer.is_empty() {
262 total += buffer.len();
263 yield StreamPage { records: buffer, bookmark: None };
264 }
265
266 tracing::info!(
267 rows = total,
268 batch_size,
269 query = %self.config.query,
270 "SQLite source stream complete",
271 );
272 })
273 }
274
275 fn config_schema(&self) -> serde_json::Value {
276 serde_json::to_value(faucet_core::schema_for!(SqliteSourceConfig))
277 .expect("schema serialization")
278 }
279
280 fn dataset_uri(&self) -> String {
281 let path = self
282 .config
283 .database_url
284 .trim_start_matches("sqlite://")
285 .trim_start_matches("sqlite:");
286 format!("sqlite://{}?query={}", path, self.config.query)
287 }
288
289 fn is_shardable(&self) -> bool {
291 self.config.shard.is_some()
292 }
293
294 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
299 let Some(shard_cfg) = &self.config.shard else {
300 return Ok(vec![ShardSpec::whole()]);
301 };
302
303 let bounds_sql = pk_bounds_query(
304 &self.config.query,
305 "e_ident_sqlite(&shard_cfg.key),
306 "INTEGER",
307 );
308 let row = sqlx::query(&bounds_sql)
309 .fetch_one(&self.pool)
310 .await
311 .map_err(|e| {
312 FaucetError::Source(format!(
313 "sqlite: failed to compute shard bounds for key {:?} \
314 (it must be an integer-typed column): {e}",
315 shard_cfg.key
316 ))
317 })?;
318
319 let lo: Option<i64> = row
320 .try_get("lo")
321 .map_err(|e| FaucetError::Source(format!("sqlite: shard bounds decode failed: {e}")))?;
322 let hi: Option<i64> = row
323 .try_get("hi")
324 .map_err(|e| FaucetError::Source(format!("sqlite: shard bounds decode failed: {e}")))?;
325 Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
326 }
327
328 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
331 *self.applied_shard.lock().expect("shard mutex poisoned") =
332 parse_pk_shard(shard, "sqlite")?;
333 Ok(())
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use faucet_core::Source;
341
342 #[tokio::test]
343 async fn fetch_from_memory_db() {
344 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1 AS val, 'hello' AS msg");
345 let source = SqliteSource::new(config).await.unwrap();
346 let records = source.fetch_all().await.unwrap();
347 assert_eq!(records.len(), 1);
348 assert_eq!(records[0]["val"], 1);
349 assert_eq!(records[0]["msg"], "hello");
350 }
351
352 #[tokio::test]
353 async fn fetch_from_table() {
354 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
355 let source = SqliteSource::new(config).await.unwrap();
356
357 sqlx::query("CREATE TABLE test_items (id INTEGER PRIMARY KEY, name TEXT, score REAL)")
359 .execute(&source.pool)
360 .await
361 .unwrap();
362 sqlx::query(
363 "INSERT INTO test_items (id, name, score) VALUES (1, 'Alice', 95.5), (2, 'Bob', 87.0)",
364 )
365 .execute(&source.pool)
366 .await
367 .unwrap();
368
369 let rows = sqlx::query("SELECT * FROM test_items ORDER BY id")
372 .fetch_all(&source.pool)
373 .await
374 .unwrap();
375
376 assert_eq!(rows.len(), 2);
377 let row0 = &rows[0];
378 assert_eq!(row0.try_get::<i64, _>("id").unwrap(), 1);
379 assert_eq!(row0.try_get::<String, _>("name").unwrap(), "Alice");
380 }
381
382 #[tokio::test]
383 async fn blob_column_decodes_to_base64() {
384 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
386 let source = SqliteSource::new(config).await.unwrap();
387 sqlx::query("CREATE TABLE b (id INTEGER, data BLOB)")
388 .execute(&source.pool)
389 .await
390 .unwrap();
391 sqlx::query("INSERT INTO b (id, data) VALUES (1, X'00FF')")
393 .execute(&source.pool)
394 .await
395 .unwrap();
396 let rows = sqlx::query("SELECT data FROM b")
397 .fetch_all(&source.pool)
398 .await
399 .unwrap();
400 let v = sqlite_value_to_json(&rows[0], "data");
401 assert_eq!(v, Value::String("AP8=".to_string()), "BLOB must be base64");
402 }
403
404 #[tokio::test]
405 async fn empty_result() {
406 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1 AS x WHERE 1 = 0");
407 let source = SqliteSource::new(config).await.unwrap();
408 let records = source.fetch_all().await.unwrap();
409 assert!(records.is_empty());
410 }
411
412 #[tokio::test]
413 async fn invalid_query_returns_error() {
414 let config = SqliteSourceConfig::new("sqlite::memory:", "INVALID SQL");
415 let source = SqliteSource::new(config).await.unwrap();
416 let result = source.fetch_all().await;
417 assert!(result.is_err());
418 }
419
420 #[tokio::test]
421 async fn fetch_with_context_substitutes_query_placeholders() {
422 let config =
423 SqliteSourceConfig::new("sqlite::memory:", "SELECT {val} AS result, {name} AS name");
424 let source = SqliteSource::new(config).await.unwrap();
425
426 let mut context = std::collections::HashMap::new();
427 context.insert("val".to_string(), serde_json::json!(42));
428 context.insert("name".to_string(), serde_json::json!("hello"));
429
430 let records = source.fetch_with_context(&context).await.unwrap();
431 assert_eq!(records.len(), 1);
432 assert_eq!(records[0]["result"], 42);
433 assert_eq!(records[0]["name"], "hello");
434 }
435
436 #[tokio::test]
437 async fn fetch_with_context_prevents_sql_injection() {
438 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT {val} AS result");
439 let source = SqliteSource::new(config).await.unwrap();
440
441 let mut context = std::collections::HashMap::new();
442 context.insert(
443 "val".to_string(),
444 serde_json::json!("1; DROP TABLE test; --"),
445 );
446
447 let records = source.fetch_with_context(&context).await.unwrap();
449 assert_eq!(records.len(), 1);
450 assert_eq!(records[0]["result"], "1; DROP TABLE test; --");
451 }
452
453 #[tokio::test]
454 async fn new_rejects_out_of_range_batch_size() {
455 let mut config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
456 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
457 match SqliteSource::new(config).await {
458 Err(faucet_core::FaucetError::Config(m)) => {
459 assert!(m.contains("batch_size"), "got: {m}")
460 }
461 _ => panic!("expected a batch_size Config error"),
462 }
463 }
464
465 #[test]
468 fn dataset_uri_strips_sqlite_scheme_logic() {
469 let url1 = "sqlite:///var/db/app.db";
471 let path1 = url1
472 .trim_start_matches("sqlite://")
473 .trim_start_matches("sqlite:");
474 assert_eq!(
475 format!("sqlite://{}?query=SELECT 1", path1),
476 "sqlite:///var/db/app.db?query=SELECT 1"
477 );
478
479 let url2 = "sqlite:/tmp/data.db";
480 let path2 = url2
481 .trim_start_matches("sqlite://")
482 .trim_start_matches("sqlite:");
483 assert_eq!(
484 format!("sqlite://{}?query=SELECT 1", path2),
485 "sqlite:///tmp/data.db?query=SELECT 1"
486 );
487 }
488
489 fn num(v: serde_json::Value) -> serde_json::Number {
492 match v {
493 serde_json::Value::Number(n) => n,
494 _ => panic!("not a number"),
495 }
496 }
497
498 #[test]
499 fn classify_small_int_is_i64() {
500 assert_eq!(
501 classify_number(&num(serde_json::json!(42))),
502 NumberBind::I64
503 );
504 assert_eq!(
505 classify_number(&num(serde_json::json!(-7))),
506 NumberBind::I64
507 );
508 assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
509 }
510
511 #[test]
512 fn classify_above_2_pow_53_stays_i64_not_f64() {
513 let v = 9_007_199_254_740_993i64; assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
517 }
518
519 #[test]
520 fn classify_i64_boundaries_are_i64() {
521 assert_eq!(
522 classify_number(&num(serde_json::json!(i64::MAX))),
523 NumberBind::I64
524 );
525 assert_eq!(
526 classify_number(&num(serde_json::json!(i64::MIN))),
527 NumberBind::I64
528 );
529 }
530
531 #[test]
532 fn classify_above_i64_max_is_u64() {
533 let v: u64 = i64::MAX as u64 + 1;
534 assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
535 assert_eq!(
536 classify_number(&num(serde_json::json!(u64::MAX))),
537 NumberBind::U64
538 );
539 }
540
541 #[test]
542 fn classify_float_is_f64() {
543 assert_eq!(
544 classify_number(&num(serde_json::json!(3.5))),
545 NumberBind::F64
546 );
547 }
548
549 #[tokio::test]
553 async fn large_int_param_binds_without_precision_loss() {
554 let big = 9_007_199_254_740_993i64; let config =
556 SqliteSourceConfig::new("sqlite::memory:", "SELECT {id} AS id, 'hit' AS marker");
557 let source = SqliteSource::new(config).await.unwrap();
558
559 let mut context = std::collections::HashMap::new();
560 context.insert("id".to_string(), serde_json::json!(big));
561
562 let records = source.fetch_with_context(&context).await.unwrap();
563 assert_eq!(records.len(), 1);
564 assert_eq!(records[0]["id"].as_i64().unwrap(), big);
566 }
567
568 #[tokio::test]
569 async fn dataset_uri_memory_db() {
570 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 42 AS n");
572 let source = SqliteSource::new(config).await.unwrap();
573 let uri = source.dataset_uri();
575 assert!(uri.contains("SELECT 42 AS n"), "got: {uri}");
576 assert!(uri.starts_with("sqlite://"), "got: {uri}");
577 }
578
579 async fn sharded_memory_source(query: &str, key: &str) -> SqliteSource {
584 let mut config = SqliteSourceConfig::new("sqlite::memory:", query).with_max_connections(1);
585 config.shard = Some(crate::config::ShardConfig { key: key.into() });
586 SqliteSource::new(config).await.unwrap()
587 }
588
589 #[tokio::test]
593 async fn shards_partition_rows_disjointly_and_completely() {
594 let source = sharded_memory_source("SELECT k, label FROM items", "k").await;
595 sqlx::query("CREATE TABLE items (k INTEGER, label TEXT)")
596 .execute(&source.pool)
597 .await
598 .unwrap();
599 for i in 1..=100i64 {
600 sqlx::query("INSERT INTO items (k, label) VALUES (?, ?)")
601 .bind(i)
602 .bind(format!("row-{i}"))
603 .execute(&source.pool)
604 .await
605 .unwrap();
606 }
607 sqlx::query("INSERT INTO items (k, label) VALUES (NULL, 'null-row')")
609 .execute(&source.pool)
610 .await
611 .unwrap();
612
613 assert!(source.is_shardable());
614 let shards = source.enumerate_shards(4).await.expect("enumerate");
615 assert!(
616 (2..=4).contains(&shards.len()),
617 "expected 2..=4 shards, got {}",
618 shards.len()
619 );
620
621 let mut labels: Vec<String> = Vec::new();
622 for shard in &shards {
623 source.apply_shard(shard).await.expect("apply_shard");
624 for rec in source.fetch_all().await.expect("fetch shard") {
625 labels.push(rec["label"].as_str().unwrap().to_string());
626 }
627 }
628
629 labels.sort();
630 let mut expected: Vec<String> = (1..=100i64).map(|i| format!("row-{i}")).collect();
631 expected.push("null-row".to_string());
632 expected.sort();
633 assert_eq!(
634 labels, expected,
635 "shards must union to all rows exactly once (no dup, no loss)"
636 );
637 }
638
639 #[tokio::test]
641 async fn whole_shard_restores_full_query() {
642 let source = sharded_memory_source("SELECT k FROM items", "k").await;
643 sqlx::query("CREATE TABLE items (k INTEGER)")
644 .execute(&source.pool)
645 .await
646 .unwrap();
647 sqlx::query("INSERT INTO items (k) VALUES (1), (2), (3)")
648 .execute(&source.pool)
649 .await
650 .unwrap();
651
652 let shards = source.enumerate_shards(2).await.unwrap();
653 source.apply_shard(&shards[0]).await.unwrap();
654 let narrowed = source.fetch_all().await.unwrap().len();
655 assert!(narrowed < 3, "a real shard narrows the result set");
656
657 source
658 .apply_shard(&faucet_core::ShardSpec::whole())
659 .await
660 .unwrap();
661 assert_eq!(source.fetch_all().await.unwrap().len(), 3);
662 }
663
664 #[tokio::test]
667 async fn empty_result_and_unsharded_config_yield_whole_shard() {
668 let source = sharded_memory_source("SELECT k FROM items", "k").await;
669 sqlx::query("CREATE TABLE items (k INTEGER)")
670 .execute(&source.pool)
671 .await
672 .unwrap();
673 let shards = source.enumerate_shards(4).await.unwrap();
674 assert_eq!(shards.len(), 1);
675 assert!(shards[0].is_whole());
676
677 let plain = SqliteSource::new(SqliteSourceConfig::new("sqlite::memory:", "SELECT 1"))
678 .await
679 .unwrap();
680 assert!(!plain.is_shardable());
681 let shards = plain.enumerate_shards(4).await.unwrap();
682 assert_eq!(shards.len(), 1);
683 assert!(shards[0].is_whole());
684 }
685
686 #[tokio::test]
689 async fn shard_error_paths() {
690 let source = sharded_memory_source("SELECT k FROM items", "no_such_column").await;
691 sqlx::query("CREATE TABLE items (k INTEGER)")
692 .execute(&source.pool)
693 .await
694 .unwrap();
695 assert!(source.enumerate_shards(4).await.is_err());
696
697 let bad = faucet_core::ShardSpec::new("0", serde_json::json!({ "key": "k" }));
698 assert!(source.apply_shard(&bad).await.is_err());
699 }
700}