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
181type CatalogRow = (String, String, String, bool);
185
186fn descriptors_from_catalog(rows: Vec<CatalogRow>) -> Vec<faucet_core::DatasetDescriptor> {
193 let mut out: Vec<faucet_core::DatasetDescriptor> = Vec::new();
194 let mut current: Option<(String, Vec<(String, Value)>)> = None;
195
196 let flush = |cur: Option<(String, Vec<(String, Value)>)>,
197 out: &mut Vec<faucet_core::DatasetDescriptor>| {
198 if let Some((table, cols)) = cur {
199 let query = format!("SELECT * FROM {}", quote_ident_sqlite(&table));
200 out.push(
201 faucet_core::DatasetDescriptor::new(
202 table,
203 "table",
204 serde_json::json!({ "query": query }),
205 )
206 .with_schema(faucet_core::columns_to_schema(cols)),
207 );
208 }
209 };
210
211 for (table, column, data_type, is_nullable) in rows {
212 let same = current.as_ref().is_some_and(|(t, _)| *t == table);
213 if !same {
214 flush(current.take(), &mut out);
215 current = Some((table, Vec::new()));
216 }
217 let mut fragment = faucet_core::sql_type_to_json_schema(&data_type);
220 if is_nullable {
221 fragment = faucet_core::nullable_type(fragment);
222 }
223 if let Some((_, cols)) = current.as_mut() {
224 cols.push((column, fragment));
225 }
226 }
227 flush(current, &mut out);
228 out
229}
230
231fn row_to_json(row: &sqlx::sqlite::SqliteRow) -> Value {
234 let mut map = serde_json::Map::new();
235 for col in row.columns() {
236 let name = col.name().to_string();
237 let value = sqlite_value_to_json(row, &name);
238 map.insert(name, value);
239 }
240 Value::Object(map)
241}
242
243#[async_trait]
244impl faucet_core::Source for SqliteSource {
245 async fn fetch_with_context(
246 &self,
247 context: &std::collections::HashMap<String, serde_json::Value>,
248 ) -> Result<Vec<Value>, FaucetError> {
249 let (query_str, bind_values) = resolve_query(&self.config, context);
250 let query_str = self.shard_wrap(query_str);
251 let query = bind_params(sqlx::query(&query_str), &bind_values);
252
253 let rows = query
254 .fetch_all(&self.pool)
255 .await
256 .map_err(|e| FaucetError::Config(format!("SQLite query failed: {e}")))?;
257
258 let records: Vec<Value> = rows.iter().map(row_to_json).collect();
259 tracing::info!(
260 rows = records.len(),
261 query = %self.config.query,
262 "SQLite source fetch complete"
263 );
264 Ok(records)
265 }
266
267 fn stream_pages<'a>(
282 &'a self,
283 context: &'a std::collections::HashMap<String, Value>,
284 _batch_size: usize,
285 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
286 let batch_size = self.config.batch_size;
287
288 Box::pin(async_stream::try_stream! {
289 let (query_str, bind_values) = resolve_query(&self.config, context);
290 let query_str = self.shard_wrap(query_str);
291 let query = bind_params(sqlx::query(&query_str), &bind_values);
292
293 let mut rows = query.fetch(&self.pool);
294 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
295 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
296 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
297 let mut total = 0usize;
298
299 while let Some(row) = rows
300 .try_next()
301 .await
302 .map_err(|e| FaucetError::Config(format!("SQLite query failed: {e}")))?
303 {
304 buffer.push(row_to_json(&row));
305 if buffer.len() >= chunk {
306 let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
307 total += page.len();
308 yield StreamPage { records: page, bookmark: None };
309 }
310 }
311 if !buffer.is_empty() {
312 total += buffer.len();
313 yield StreamPage { records: buffer, bookmark: None };
314 }
315
316 tracing::info!(
317 rows = total,
318 batch_size,
319 query = %self.config.query,
320 "SQLite source stream complete",
321 );
322 })
323 }
324
325 fn config_schema(&self) -> serde_json::Value {
326 serde_json::to_value(faucet_core::schema_for!(SqliteSourceConfig))
327 .expect("schema serialization")
328 }
329
330 fn dataset_uri(&self) -> String {
331 let path = self
332 .config
333 .database_url
334 .trim_start_matches("sqlite://")
335 .trim_start_matches("sqlite:");
336 format!("sqlite://{}?query={}", path, self.config.query)
337 }
338
339 fn supports_discover(&self) -> bool {
340 true
341 }
342
343 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
348 let tables = sqlx::query(
349 "SELECT name FROM sqlite_master \
350 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
351 ORDER BY name",
352 )
353 .fetch_all(&self.pool)
354 .await
355 .map_err(|e| FaucetError::Source(format!("sqlite: catalog discovery failed: {e}")))?;
356
357 let mut catalog: Vec<CatalogRow> = Vec::new();
358 for table_row in &tables {
359 let table: String = table_row.try_get("name").map_err(|e| {
360 FaucetError::Source(format!("sqlite: catalog decode failed (name): {e}"))
361 })?;
362 let columns =
369 sqlx::query("SELECT name, type, `notnull` FROM pragma_table_info(?) ORDER BY cid")
370 .bind(&table)
371 .fetch_all(&self.pool)
372 .await
373 .map_err(|e| {
374 FaucetError::Source(format!(
375 "sqlite: catalog discovery failed (table_info for {table}): {e}"
376 ))
377 })?;
378 for col in &columns {
379 let decode = |c: &str| -> Result<String, FaucetError> {
380 col.try_get::<String, _>(c).map_err(|e| {
381 FaucetError::Source(format!("sqlite: catalog decode failed ({c}): {e}"))
382 })
383 };
384 let notnull: i64 = col.try_get("notnull").map_err(|e| {
385 FaucetError::Source(format!("sqlite: catalog decode failed (notnull): {e}"))
386 })?;
387 catalog.push((
388 table.clone(),
389 decode("name")?,
390 decode("type")?,
391 notnull == 0,
392 ));
393 }
394 }
395
396 Ok(descriptors_from_catalog(catalog))
397 }
398
399 fn is_shardable(&self) -> bool {
401 self.config.shard.is_some()
402 }
403
404 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
409 let Some(shard_cfg) = &self.config.shard else {
410 return Ok(vec![ShardSpec::whole()]);
411 };
412
413 let bounds_sql = pk_bounds_query(
414 &self.config.query,
415 "e_ident_sqlite(&shard_cfg.key),
416 "INTEGER",
417 );
418 let row = sqlx::query(&bounds_sql)
419 .fetch_one(&self.pool)
420 .await
421 .map_err(|e| {
422 FaucetError::Source(format!(
423 "sqlite: failed to compute shard bounds for key {:?} \
424 (it must be an integer-typed column): {e}",
425 shard_cfg.key
426 ))
427 })?;
428
429 let lo: Option<i64> = row
430 .try_get("lo")
431 .map_err(|e| FaucetError::Source(format!("sqlite: shard bounds decode failed: {e}")))?;
432 let hi: Option<i64> = row
433 .try_get("hi")
434 .map_err(|e| FaucetError::Source(format!("sqlite: shard bounds decode failed: {e}")))?;
435 Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
436 }
437
438 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
441 *self.applied_shard.lock().expect("shard mutex poisoned") =
442 parse_pk_shard(shard, "sqlite")?;
443 Ok(())
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use faucet_core::Source;
451
452 #[tokio::test]
453 async fn fetch_from_memory_db() {
454 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1 AS val, 'hello' AS msg");
455 let source = SqliteSource::new(config).await.unwrap();
456 let records = source.fetch_all().await.unwrap();
457 assert_eq!(records.len(), 1);
458 assert_eq!(records[0]["val"], 1);
459 assert_eq!(records[0]["msg"], "hello");
460 }
461
462 #[tokio::test]
463 async fn fetch_from_table() {
464 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
465 let source = SqliteSource::new(config).await.unwrap();
466
467 sqlx::query("CREATE TABLE test_items (id INTEGER PRIMARY KEY, name TEXT, score REAL)")
469 .execute(&source.pool)
470 .await
471 .unwrap();
472 sqlx::query(
473 "INSERT INTO test_items (id, name, score) VALUES (1, 'Alice', 95.5), (2, 'Bob', 87.0)",
474 )
475 .execute(&source.pool)
476 .await
477 .unwrap();
478
479 let rows = sqlx::query("SELECT * FROM test_items ORDER BY id")
482 .fetch_all(&source.pool)
483 .await
484 .unwrap();
485
486 assert_eq!(rows.len(), 2);
487 let row0 = &rows[0];
488 assert_eq!(row0.try_get::<i64, _>("id").unwrap(), 1);
489 assert_eq!(row0.try_get::<String, _>("name").unwrap(), "Alice");
490 }
491
492 #[tokio::test]
493 async fn blob_column_decodes_to_base64() {
494 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
496 let source = SqliteSource::new(config).await.unwrap();
497 sqlx::query("CREATE TABLE b (id INTEGER, data BLOB)")
498 .execute(&source.pool)
499 .await
500 .unwrap();
501 sqlx::query("INSERT INTO b (id, data) VALUES (1, X'00FF')")
503 .execute(&source.pool)
504 .await
505 .unwrap();
506 let rows = sqlx::query("SELECT data FROM b")
507 .fetch_all(&source.pool)
508 .await
509 .unwrap();
510 let v = sqlite_value_to_json(&rows[0], "data");
511 assert_eq!(v, Value::String("AP8=".to_string()), "BLOB must be base64");
512 }
513
514 #[tokio::test]
515 async fn empty_result() {
516 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1 AS x WHERE 1 = 0");
517 let source = SqliteSource::new(config).await.unwrap();
518 let records = source.fetch_all().await.unwrap();
519 assert!(records.is_empty());
520 }
521
522 #[tokio::test]
523 async fn invalid_query_returns_error() {
524 let config = SqliteSourceConfig::new("sqlite::memory:", "INVALID SQL");
525 let source = SqliteSource::new(config).await.unwrap();
526 let result = source.fetch_all().await;
527 assert!(result.is_err());
528 }
529
530 #[tokio::test]
531 async fn fetch_with_context_substitutes_query_placeholders() {
532 let config =
533 SqliteSourceConfig::new("sqlite::memory:", "SELECT {val} AS result, {name} AS name");
534 let source = SqliteSource::new(config).await.unwrap();
535
536 let mut context = std::collections::HashMap::new();
537 context.insert("val".to_string(), serde_json::json!(42));
538 context.insert("name".to_string(), serde_json::json!("hello"));
539
540 let records = source.fetch_with_context(&context).await.unwrap();
541 assert_eq!(records.len(), 1);
542 assert_eq!(records[0]["result"], 42);
543 assert_eq!(records[0]["name"], "hello");
544 }
545
546 #[tokio::test]
547 async fn fetch_with_context_prevents_sql_injection() {
548 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT {val} AS result");
549 let source = SqliteSource::new(config).await.unwrap();
550
551 let mut context = std::collections::HashMap::new();
552 context.insert(
553 "val".to_string(),
554 serde_json::json!("1; DROP TABLE test; --"),
555 );
556
557 let records = source.fetch_with_context(&context).await.unwrap();
559 assert_eq!(records.len(), 1);
560 assert_eq!(records[0]["result"], "1; DROP TABLE test; --");
561 }
562
563 #[tokio::test]
564 async fn new_rejects_out_of_range_batch_size() {
565 let mut config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
566 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
567 match SqliteSource::new(config).await {
568 Err(faucet_core::FaucetError::Config(m)) => {
569 assert!(m.contains("batch_size"), "got: {m}")
570 }
571 _ => panic!("expected a batch_size Config error"),
572 }
573 }
574
575 #[test]
578 fn dataset_uri_strips_sqlite_scheme_logic() {
579 let url1 = "sqlite:///var/db/app.db";
581 let path1 = url1
582 .trim_start_matches("sqlite://")
583 .trim_start_matches("sqlite:");
584 assert_eq!(
585 format!("sqlite://{}?query=SELECT 1", path1),
586 "sqlite:///var/db/app.db?query=SELECT 1"
587 );
588
589 let url2 = "sqlite:/tmp/data.db";
590 let path2 = url2
591 .trim_start_matches("sqlite://")
592 .trim_start_matches("sqlite:");
593 assert_eq!(
594 format!("sqlite://{}?query=SELECT 1", path2),
595 "sqlite:///tmp/data.db?query=SELECT 1"
596 );
597 }
598
599 fn num(v: serde_json::Value) -> serde_json::Number {
602 match v {
603 serde_json::Value::Number(n) => n,
604 _ => panic!("not a number"),
605 }
606 }
607
608 #[test]
609 fn classify_small_int_is_i64() {
610 assert_eq!(
611 classify_number(&num(serde_json::json!(42))),
612 NumberBind::I64
613 );
614 assert_eq!(
615 classify_number(&num(serde_json::json!(-7))),
616 NumberBind::I64
617 );
618 assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
619 }
620
621 #[test]
622 fn classify_above_2_pow_53_stays_i64_not_f64() {
623 let v = 9_007_199_254_740_993i64; assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
627 }
628
629 #[test]
630 fn classify_i64_boundaries_are_i64() {
631 assert_eq!(
632 classify_number(&num(serde_json::json!(i64::MAX))),
633 NumberBind::I64
634 );
635 assert_eq!(
636 classify_number(&num(serde_json::json!(i64::MIN))),
637 NumberBind::I64
638 );
639 }
640
641 #[test]
642 fn classify_above_i64_max_is_u64() {
643 let v: u64 = i64::MAX as u64 + 1;
644 assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
645 assert_eq!(
646 classify_number(&num(serde_json::json!(u64::MAX))),
647 NumberBind::U64
648 );
649 }
650
651 #[test]
652 fn classify_float_is_f64() {
653 assert_eq!(
654 classify_number(&num(serde_json::json!(3.5))),
655 NumberBind::F64
656 );
657 }
658
659 #[tokio::test]
663 async fn large_int_param_binds_without_precision_loss() {
664 let big = 9_007_199_254_740_993i64; let config =
666 SqliteSourceConfig::new("sqlite::memory:", "SELECT {id} AS id, 'hit' AS marker");
667 let source = SqliteSource::new(config).await.unwrap();
668
669 let mut context = std::collections::HashMap::new();
670 context.insert("id".to_string(), serde_json::json!(big));
671
672 let records = source.fetch_with_context(&context).await.unwrap();
673 assert_eq!(records.len(), 1);
674 assert_eq!(records[0]["id"].as_i64().unwrap(), big);
676 }
677
678 #[tokio::test]
679 async fn dataset_uri_memory_db() {
680 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 42 AS n");
682 let source = SqliteSource::new(config).await.unwrap();
683 let uri = source.dataset_uri();
685 assert!(uri.contains("SELECT 42 AS n"), "got: {uri}");
686 assert!(uri.starts_with("sqlite://"), "got: {uri}");
687 }
688
689 #[test]
692 fn descriptors_group_catalog_rows_per_table() {
693 let rows: Vec<CatalogRow> = vec![
694 (
695 "orders".to_string(),
696 "id".to_string(),
697 "INTEGER".to_string(),
698 false,
699 ),
700 (
701 "orders".to_string(),
702 "note".to_string(),
703 "TEXT".to_string(),
704 true,
705 ),
706 (
707 "users".to_string(),
708 "total".to_string(),
709 "REAL".to_string(),
710 false,
711 ),
712 ];
713 let ds = descriptors_from_catalog(rows);
714 assert_eq!(ds.len(), 2, "rows group into one descriptor per table");
715
716 assert_eq!(ds[0].name, "orders");
717 assert_eq!(ds[0].kind, "table");
718 assert_eq!(
719 ds[0].estimated_rows, None,
720 "SQLite has no cheap estimate — discovery never scans"
721 );
722 assert_eq!(ds[0].config_patch["query"], "SELECT * FROM `orders`");
723 let schema = ds[0].schema.as_ref().unwrap();
724 assert_eq!(schema["type"], "object");
725 assert_eq!(schema["properties"]["id"]["type"], "integer");
726 assert_eq!(
727 schema["properties"]["note"]["type"],
728 serde_json::json!(["string", "null"]),
729 "nullable column"
730 );
731
732 assert_eq!(ds[1].name, "users");
733 assert_eq!(
734 ds[1].schema.as_ref().unwrap()["properties"]["total"]["type"],
735 "number"
736 );
737 }
738
739 #[test]
740 fn descriptors_quote_hostile_identifiers() {
741 let rows: Vec<CatalogRow> = vec![(
742 "we`ird".to_string(),
743 "id".to_string(),
744 "INTEGER".to_string(),
745 false,
746 )];
747 let ds = descriptors_from_catalog(rows);
748 assert_eq!(
749 ds[0].config_patch["query"], "SELECT * FROM `we``ird`",
750 "embedded backticks are doubled"
751 );
752 }
753
754 #[test]
755 fn descriptors_typeless_column_maps_to_string() {
756 let rows: Vec<CatalogRow> = vec![("t".to_string(), "x".to_string(), String::new(), true)];
758 let ds = descriptors_from_catalog(rows);
759 assert_eq!(
760 ds[0].schema.as_ref().unwrap()["properties"]["x"]["type"],
761 serde_json::json!(["string", "null"]),
762 "empty declared type falls back to the safe string"
763 );
764 }
765
766 #[test]
767 fn descriptors_empty_catalog_is_empty() {
768 assert!(descriptors_from_catalog(Vec::new()).is_empty());
769 }
770
771 #[tokio::test]
775 async fn discover_enumerates_memory_tables() {
776 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1").with_max_connections(1);
777 let source = SqliteSource::new(config).await.unwrap();
778 assert!(source.supports_discover());
779
780 assert!(source.discover().await.unwrap().is_empty());
783
784 sqlx::query("CREATE TABLE zebra (id INTEGER NOT NULL, note TEXT)")
785 .execute(&source.pool)
786 .await
787 .unwrap();
788 sqlx::query("CREATE TABLE apple (v REAL NOT NULL)")
789 .execute(&source.pool)
790 .await
791 .unwrap();
792
793 let ds = source.discover().await.unwrap();
794 assert_eq!(ds.len(), 2);
795 assert_eq!(ds[0].name, "apple", "tables ordered by name");
796 assert_eq!(ds[1].name, "zebra");
797 assert_eq!(ds[0].config_patch["query"], "SELECT * FROM `apple`");
798 assert_eq!(
799 ds[0].schema.as_ref().unwrap()["properties"]["v"]["type"],
800 "number"
801 );
802 let zebra = ds[1].schema.as_ref().unwrap();
803 assert_eq!(zebra["properties"]["id"]["type"], "integer");
804 assert_eq!(
805 zebra["properties"]["note"]["type"],
806 serde_json::json!(["string", "null"])
807 );
808 assert_eq!(ds[1].estimated_rows, None);
809 }
810
811 async fn sharded_memory_source(query: &str, key: &str) -> SqliteSource {
816 let mut config = SqliteSourceConfig::new("sqlite::memory:", query).with_max_connections(1);
817 config.shard = Some(crate::config::ShardConfig { key: key.into() });
818 SqliteSource::new(config).await.unwrap()
819 }
820
821 #[tokio::test]
825 async fn shards_partition_rows_disjointly_and_completely() {
826 let source = sharded_memory_source("SELECT k, label FROM items", "k").await;
827 sqlx::query("CREATE TABLE items (k INTEGER, label TEXT)")
828 .execute(&source.pool)
829 .await
830 .unwrap();
831 for i in 1..=100i64 {
832 sqlx::query("INSERT INTO items (k, label) VALUES (?, ?)")
833 .bind(i)
834 .bind(format!("row-{i}"))
835 .execute(&source.pool)
836 .await
837 .unwrap();
838 }
839 sqlx::query("INSERT INTO items (k, label) VALUES (NULL, 'null-row')")
841 .execute(&source.pool)
842 .await
843 .unwrap();
844
845 assert!(source.is_shardable());
846 let shards = source.enumerate_shards(4).await.expect("enumerate");
847 assert!(
848 (2..=4).contains(&shards.len()),
849 "expected 2..=4 shards, got {}",
850 shards.len()
851 );
852
853 let mut labels: Vec<String> = Vec::new();
854 for shard in &shards {
855 source.apply_shard(shard).await.expect("apply_shard");
856 for rec in source.fetch_all().await.expect("fetch shard") {
857 labels.push(rec["label"].as_str().unwrap().to_string());
858 }
859 }
860
861 labels.sort();
862 let mut expected: Vec<String> = (1..=100i64).map(|i| format!("row-{i}")).collect();
863 expected.push("null-row".to_string());
864 expected.sort();
865 assert_eq!(
866 labels, expected,
867 "shards must union to all rows exactly once (no dup, no loss)"
868 );
869 }
870
871 #[tokio::test]
873 async fn whole_shard_restores_full_query() {
874 let source = sharded_memory_source("SELECT k FROM items", "k").await;
875 sqlx::query("CREATE TABLE items (k INTEGER)")
876 .execute(&source.pool)
877 .await
878 .unwrap();
879 sqlx::query("INSERT INTO items (k) VALUES (1), (2), (3)")
880 .execute(&source.pool)
881 .await
882 .unwrap();
883
884 let shards = source.enumerate_shards(2).await.unwrap();
885 source.apply_shard(&shards[0]).await.unwrap();
886 let narrowed = source.fetch_all().await.unwrap().len();
887 assert!(narrowed < 3, "a real shard narrows the result set");
888
889 source
890 .apply_shard(&faucet_core::ShardSpec::whole())
891 .await
892 .unwrap();
893 assert_eq!(source.fetch_all().await.unwrap().len(), 3);
894 }
895
896 #[tokio::test]
899 async fn empty_result_and_unsharded_config_yield_whole_shard() {
900 let source = sharded_memory_source("SELECT k FROM items", "k").await;
901 sqlx::query("CREATE TABLE items (k INTEGER)")
902 .execute(&source.pool)
903 .await
904 .unwrap();
905 let shards = source.enumerate_shards(4).await.unwrap();
906 assert_eq!(shards.len(), 1);
907 assert!(shards[0].is_whole());
908
909 let plain = SqliteSource::new(SqliteSourceConfig::new("sqlite::memory:", "SELECT 1"))
910 .await
911 .unwrap();
912 assert!(!plain.is_shardable());
913 let shards = plain.enumerate_shards(4).await.unwrap();
914 assert_eq!(shards.len(), 1);
915 assert!(shards[0].is_whole());
916 }
917
918 #[tokio::test]
921 async fn shard_error_paths() {
922 let source = sharded_memory_source("SELECT k FROM items", "no_such_column").await;
923 sqlx::query("CREATE TABLE items (k INTEGER)")
924 .execute(&source.pool)
925 .await
926 .unwrap();
927 assert!(source.enumerate_shards(4).await.is_err());
928
929 let bad = faucet_core::ShardSpec::new("0", serde_json::json!({ "key": "k" }));
930 assert!(source.apply_shard(&bad).await.is_err());
931 }
932}