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