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 connector_name(&self) -> &'static str {
332 "sqlite"
333 }
334
335 fn config_schema(&self) -> serde_json::Value {
336 serde_json::to_value(faucet_core::schema_for!(SqliteSourceConfig))
337 .expect("schema serialization")
338 }
339
340 fn dataset_uri(&self) -> String {
341 let path = self
342 .config
343 .database_url
344 .trim_start_matches("sqlite://")
345 .trim_start_matches("sqlite:");
346 format!("sqlite://{}?query={}", path, self.config.query)
347 }
348
349 fn supports_discover(&self) -> bool {
350 true
351 }
352
353 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
358 let tables = sqlx::query(
359 "SELECT name FROM sqlite_master \
360 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
361 ORDER BY name",
362 )
363 .fetch_all(&self.pool)
364 .await
365 .map_err(|e| FaucetError::Source(format!("sqlite: catalog discovery failed: {e}")))?;
366
367 let mut catalog: Vec<CatalogRow> = Vec::new();
368 for table_row in &tables {
369 let table: String = table_row.try_get("name").map_err(|e| {
370 FaucetError::Source(format!("sqlite: catalog decode failed (name): {e}"))
371 })?;
372 let columns =
379 sqlx::query("SELECT name, type, `notnull` FROM pragma_table_info(?) ORDER BY cid")
380 .bind(&table)
381 .fetch_all(&self.pool)
382 .await
383 .map_err(|e| {
384 FaucetError::Source(format!(
385 "sqlite: catalog discovery failed (table_info for {table}): {e}"
386 ))
387 })?;
388 for col in &columns {
389 let decode = |c: &str| -> Result<String, FaucetError> {
390 col.try_get::<String, _>(c).map_err(|e| {
391 FaucetError::Source(format!("sqlite: catalog decode failed ({c}): {e}"))
392 })
393 };
394 let notnull: i64 = col.try_get("notnull").map_err(|e| {
395 FaucetError::Source(format!("sqlite: catalog decode failed (notnull): {e}"))
396 })?;
397 catalog.push((
398 table.clone(),
399 decode("name")?,
400 decode("type")?,
401 notnull == 0,
402 ));
403 }
404 }
405
406 Ok(descriptors_from_catalog(catalog))
407 }
408
409 fn is_shardable(&self) -> bool {
411 self.config.shard.is_some()
412 }
413
414 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
419 let Some(shard_cfg) = &self.config.shard else {
420 return Ok(vec![ShardSpec::whole()]);
421 };
422
423 let bounds_sql = pk_bounds_query(
424 &self.config.query,
425 "e_ident_sqlite(&shard_cfg.key),
426 "INTEGER",
427 );
428 let row = sqlx::query(&bounds_sql)
429 .fetch_one(&self.pool)
430 .await
431 .map_err(|e| {
432 FaucetError::Source(format!(
433 "sqlite: failed to compute shard bounds for key {:?} \
434 (it must be an integer-typed column): {e}",
435 shard_cfg.key
436 ))
437 })?;
438
439 let lo: Option<i64> = row
440 .try_get("lo")
441 .map_err(|e| FaucetError::Source(format!("sqlite: shard bounds decode failed: {e}")))?;
442 let hi: Option<i64> = row
443 .try_get("hi")
444 .map_err(|e| FaucetError::Source(format!("sqlite: shard bounds decode failed: {e}")))?;
445 Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
446 }
447
448 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
451 *self.applied_shard.lock().expect("shard mutex poisoned") =
452 parse_pk_shard(shard, "sqlite")?;
453 Ok(())
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use faucet_core::Source;
461
462 #[tokio::test]
463 async fn fetch_from_memory_db() {
464 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1 AS val, 'hello' AS msg");
465 let source = SqliteSource::new(config).await.unwrap();
466 let records = source.fetch_all().await.unwrap();
467 assert_eq!(records.len(), 1);
468 assert_eq!(records[0]["val"], 1);
469 assert_eq!(records[0]["msg"], "hello");
470 }
471
472 #[tokio::test]
473 async fn fetch_from_table() {
474 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
475 let source = SqliteSource::new(config).await.unwrap();
476
477 sqlx::query("CREATE TABLE test_items (id INTEGER PRIMARY KEY, name TEXT, score REAL)")
479 .execute(&source.pool)
480 .await
481 .unwrap();
482 sqlx::query(
483 "INSERT INTO test_items (id, name, score) VALUES (1, 'Alice', 95.5), (2, 'Bob', 87.0)",
484 )
485 .execute(&source.pool)
486 .await
487 .unwrap();
488
489 let rows = sqlx::query("SELECT * FROM test_items ORDER BY id")
492 .fetch_all(&source.pool)
493 .await
494 .unwrap();
495
496 assert_eq!(rows.len(), 2);
497 let row0 = &rows[0];
498 assert_eq!(row0.try_get::<i64, _>("id").unwrap(), 1);
499 assert_eq!(row0.try_get::<String, _>("name").unwrap(), "Alice");
500 }
501
502 #[tokio::test]
503 async fn blob_column_decodes_to_base64() {
504 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
506 let source = SqliteSource::new(config).await.unwrap();
507 sqlx::query("CREATE TABLE b (id INTEGER, data BLOB)")
508 .execute(&source.pool)
509 .await
510 .unwrap();
511 sqlx::query("INSERT INTO b (id, data) VALUES (1, X'00FF')")
513 .execute(&source.pool)
514 .await
515 .unwrap();
516 let rows = sqlx::query("SELECT data FROM b")
517 .fetch_all(&source.pool)
518 .await
519 .unwrap();
520 let v = sqlite_value_to_json(&rows[0], "data");
521 assert_eq!(v, Value::String("AP8=".to_string()), "BLOB must be base64");
522 }
523
524 #[tokio::test]
525 async fn empty_result() {
526 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1 AS x WHERE 1 = 0");
527 let source = SqliteSource::new(config).await.unwrap();
528 let records = source.fetch_all().await.unwrap();
529 assert!(records.is_empty());
530 }
531
532 #[tokio::test]
533 async fn invalid_query_returns_error() {
534 let config = SqliteSourceConfig::new("sqlite::memory:", "INVALID SQL");
535 let source = SqliteSource::new(config).await.unwrap();
536 let result = source.fetch_all().await;
537 assert!(result.is_err());
538 }
539
540 #[tokio::test]
541 async fn fetch_with_context_substitutes_query_placeholders() {
542 let config =
543 SqliteSourceConfig::new("sqlite::memory:", "SELECT {val} AS result, {name} AS name");
544 let source = SqliteSource::new(config).await.unwrap();
545
546 let mut context = std::collections::HashMap::new();
547 context.insert("val".to_string(), serde_json::json!(42));
548 context.insert("name".to_string(), serde_json::json!("hello"));
549
550 let records = source.fetch_with_context(&context).await.unwrap();
551 assert_eq!(records.len(), 1);
552 assert_eq!(records[0]["result"], 42);
553 assert_eq!(records[0]["name"], "hello");
554 }
555
556 #[tokio::test]
557 async fn fetch_with_context_prevents_sql_injection() {
558 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT {val} AS result");
559 let source = SqliteSource::new(config).await.unwrap();
560
561 let mut context = std::collections::HashMap::new();
562 context.insert(
563 "val".to_string(),
564 serde_json::json!("1; DROP TABLE test; --"),
565 );
566
567 let records = source.fetch_with_context(&context).await.unwrap();
569 assert_eq!(records.len(), 1);
570 assert_eq!(records[0]["result"], "1; DROP TABLE test; --");
571 }
572
573 #[tokio::test]
574 async fn new_rejects_out_of_range_batch_size() {
575 let mut config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
576 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
577 match SqliteSource::new(config).await {
578 Err(faucet_core::FaucetError::Config(m)) => {
579 assert!(m.contains("batch_size"), "got: {m}")
580 }
581 _ => panic!("expected a batch_size Config error"),
582 }
583 }
584
585 #[test]
588 fn dataset_uri_strips_sqlite_scheme_logic() {
589 let url1 = "sqlite:///var/db/app.db";
591 let path1 = url1
592 .trim_start_matches("sqlite://")
593 .trim_start_matches("sqlite:");
594 assert_eq!(
595 format!("sqlite://{}?query=SELECT 1", path1),
596 "sqlite:///var/db/app.db?query=SELECT 1"
597 );
598
599 let url2 = "sqlite:/tmp/data.db";
600 let path2 = url2
601 .trim_start_matches("sqlite://")
602 .trim_start_matches("sqlite:");
603 assert_eq!(
604 format!("sqlite://{}?query=SELECT 1", path2),
605 "sqlite:///tmp/data.db?query=SELECT 1"
606 );
607 }
608
609 fn num(v: serde_json::Value) -> serde_json::Number {
612 match v {
613 serde_json::Value::Number(n) => n,
614 _ => panic!("not a number"),
615 }
616 }
617
618 #[test]
619 fn classify_small_int_is_i64() {
620 assert_eq!(
621 classify_number(&num(serde_json::json!(42))),
622 NumberBind::I64
623 );
624 assert_eq!(
625 classify_number(&num(serde_json::json!(-7))),
626 NumberBind::I64
627 );
628 assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
629 }
630
631 #[test]
632 fn classify_above_2_pow_53_stays_i64_not_f64() {
633 let v = 9_007_199_254_740_993i64; assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
637 }
638
639 #[test]
640 fn classify_i64_boundaries_are_i64() {
641 assert_eq!(
642 classify_number(&num(serde_json::json!(i64::MAX))),
643 NumberBind::I64
644 );
645 assert_eq!(
646 classify_number(&num(serde_json::json!(i64::MIN))),
647 NumberBind::I64
648 );
649 }
650
651 #[test]
652 fn classify_above_i64_max_is_u64() {
653 let v: u64 = i64::MAX as u64 + 1;
654 assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
655 assert_eq!(
656 classify_number(&num(serde_json::json!(u64::MAX))),
657 NumberBind::U64
658 );
659 }
660
661 #[test]
662 fn classify_float_is_f64() {
663 assert_eq!(
664 classify_number(&num(serde_json::json!(3.5))),
665 NumberBind::F64
666 );
667 }
668
669 #[tokio::test]
673 async fn large_int_param_binds_without_precision_loss() {
674 let big = 9_007_199_254_740_993i64; let config =
676 SqliteSourceConfig::new("sqlite::memory:", "SELECT {id} AS id, 'hit' AS marker");
677 let source = SqliteSource::new(config).await.unwrap();
678
679 let mut context = std::collections::HashMap::new();
680 context.insert("id".to_string(), serde_json::json!(big));
681
682 let records = source.fetch_with_context(&context).await.unwrap();
683 assert_eq!(records.len(), 1);
684 assert_eq!(records[0]["id"].as_i64().unwrap(), big);
686 }
687
688 #[tokio::test]
689 async fn dataset_uri_memory_db() {
690 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 42 AS n");
692 let source = SqliteSource::new(config).await.unwrap();
693 let uri = source.dataset_uri();
695 assert!(uri.contains("SELECT 42 AS n"), "got: {uri}");
696 assert!(uri.starts_with("sqlite://"), "got: {uri}");
697 }
698
699 #[test]
702 fn descriptors_group_catalog_rows_per_table() {
703 let rows: Vec<CatalogRow> = vec![
704 (
705 "orders".to_string(),
706 "id".to_string(),
707 "INTEGER".to_string(),
708 false,
709 ),
710 (
711 "orders".to_string(),
712 "note".to_string(),
713 "TEXT".to_string(),
714 true,
715 ),
716 (
717 "users".to_string(),
718 "total".to_string(),
719 "REAL".to_string(),
720 false,
721 ),
722 ];
723 let ds = descriptors_from_catalog(rows);
724 assert_eq!(ds.len(), 2, "rows group into one descriptor per table");
725
726 assert_eq!(ds[0].name, "orders");
727 assert_eq!(ds[0].kind, "table");
728 assert_eq!(
729 ds[0].estimated_rows, None,
730 "SQLite has no cheap estimate — discovery never scans"
731 );
732 assert_eq!(ds[0].config_patch["query"], "SELECT * FROM `orders`");
733 let schema = ds[0].schema.as_ref().unwrap();
734 assert_eq!(schema["type"], "object");
735 assert_eq!(schema["properties"]["id"]["type"], "integer");
736 assert_eq!(
737 schema["properties"]["note"]["type"],
738 serde_json::json!(["string", "null"]),
739 "nullable column"
740 );
741
742 assert_eq!(ds[1].name, "users");
743 assert_eq!(
744 ds[1].schema.as_ref().unwrap()["properties"]["total"]["type"],
745 "number"
746 );
747 }
748
749 #[test]
750 fn descriptors_quote_hostile_identifiers() {
751 let rows: Vec<CatalogRow> = vec![(
752 "we`ird".to_string(),
753 "id".to_string(),
754 "INTEGER".to_string(),
755 false,
756 )];
757 let ds = descriptors_from_catalog(rows);
758 assert_eq!(
759 ds[0].config_patch["query"], "SELECT * FROM `we``ird`",
760 "embedded backticks are doubled"
761 );
762 }
763
764 #[test]
765 fn descriptors_typeless_column_maps_to_string() {
766 let rows: Vec<CatalogRow> = vec![("t".to_string(), "x".to_string(), String::new(), true)];
768 let ds = descriptors_from_catalog(rows);
769 assert_eq!(
770 ds[0].schema.as_ref().unwrap()["properties"]["x"]["type"],
771 serde_json::json!(["string", "null"]),
772 "empty declared type falls back to the safe string"
773 );
774 }
775
776 #[test]
777 fn descriptors_empty_catalog_is_empty() {
778 assert!(descriptors_from_catalog(Vec::new()).is_empty());
779 }
780
781 #[tokio::test]
785 async fn discover_enumerates_memory_tables() {
786 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1").with_max_connections(1);
787 let source = SqliteSource::new(config).await.unwrap();
788 assert!(source.supports_discover());
789
790 assert!(source.discover().await.unwrap().is_empty());
793
794 sqlx::query("CREATE TABLE zebra (id INTEGER NOT NULL, note TEXT)")
795 .execute(&source.pool)
796 .await
797 .unwrap();
798 sqlx::query("CREATE TABLE apple (v REAL NOT NULL)")
799 .execute(&source.pool)
800 .await
801 .unwrap();
802
803 let ds = source.discover().await.unwrap();
804 assert_eq!(ds.len(), 2);
805 assert_eq!(ds[0].name, "apple", "tables ordered by name");
806 assert_eq!(ds[1].name, "zebra");
807 assert_eq!(ds[0].config_patch["query"], "SELECT * FROM `apple`");
808 assert_eq!(
809 ds[0].schema.as_ref().unwrap()["properties"]["v"]["type"],
810 "number"
811 );
812 let zebra = ds[1].schema.as_ref().unwrap();
813 assert_eq!(zebra["properties"]["id"]["type"], "integer");
814 assert_eq!(
815 zebra["properties"]["note"]["type"],
816 serde_json::json!(["string", "null"])
817 );
818 assert_eq!(ds[1].estimated_rows, None);
819 }
820
821 async fn sharded_memory_source(query: &str, key: &str) -> SqliteSource {
826 let mut config = SqliteSourceConfig::new("sqlite::memory:", query).with_max_connections(1);
827 config.shard = Some(crate::config::ShardConfig { key: key.into() });
828 SqliteSource::new(config).await.unwrap()
829 }
830
831 #[tokio::test]
835 async fn shards_partition_rows_disjointly_and_completely() {
836 let source = sharded_memory_source("SELECT k, label FROM items", "k").await;
837 sqlx::query("CREATE TABLE items (k INTEGER, label TEXT)")
838 .execute(&source.pool)
839 .await
840 .unwrap();
841 for i in 1..=100i64 {
842 sqlx::query("INSERT INTO items (k, label) VALUES (?, ?)")
843 .bind(i)
844 .bind(format!("row-{i}"))
845 .execute(&source.pool)
846 .await
847 .unwrap();
848 }
849 sqlx::query("INSERT INTO items (k, label) VALUES (NULL, 'null-row')")
851 .execute(&source.pool)
852 .await
853 .unwrap();
854
855 assert!(source.is_shardable());
856 let shards = source.enumerate_shards(4).await.expect("enumerate");
857 assert!(
858 (2..=4).contains(&shards.len()),
859 "expected 2..=4 shards, got {}",
860 shards.len()
861 );
862
863 let mut labels: Vec<String> = Vec::new();
864 for shard in &shards {
865 source.apply_shard(shard).await.expect("apply_shard");
866 for rec in source.fetch_all().await.expect("fetch shard") {
867 labels.push(rec["label"].as_str().unwrap().to_string());
868 }
869 }
870
871 labels.sort();
872 let mut expected: Vec<String> = (1..=100i64).map(|i| format!("row-{i}")).collect();
873 expected.push("null-row".to_string());
874 expected.sort();
875 assert_eq!(
876 labels, expected,
877 "shards must union to all rows exactly once (no dup, no loss)"
878 );
879 }
880
881 #[tokio::test]
883 async fn whole_shard_restores_full_query() {
884 let source = sharded_memory_source("SELECT k FROM items", "k").await;
885 sqlx::query("CREATE TABLE items (k INTEGER)")
886 .execute(&source.pool)
887 .await
888 .unwrap();
889 sqlx::query("INSERT INTO items (k) VALUES (1), (2), (3)")
890 .execute(&source.pool)
891 .await
892 .unwrap();
893
894 let shards = source.enumerate_shards(2).await.unwrap();
895 source.apply_shard(&shards[0]).await.unwrap();
896 let narrowed = source.fetch_all().await.unwrap().len();
897 assert!(narrowed < 3, "a real shard narrows the result set");
898
899 source
900 .apply_shard(&faucet_core::ShardSpec::whole())
901 .await
902 .unwrap();
903 assert_eq!(source.fetch_all().await.unwrap().len(), 3);
904 }
905
906 #[tokio::test]
909 async fn empty_result_and_unsharded_config_yield_whole_shard() {
910 let source = sharded_memory_source("SELECT k FROM items", "k").await;
911 sqlx::query("CREATE TABLE items (k INTEGER)")
912 .execute(&source.pool)
913 .await
914 .unwrap();
915 let shards = source.enumerate_shards(4).await.unwrap();
916 assert_eq!(shards.len(), 1);
917 assert!(shards[0].is_whole());
918
919 let plain = SqliteSource::new(SqliteSourceConfig::new("sqlite::memory:", "SELECT 1"))
920 .await
921 .unwrap();
922 assert!(!plain.is_shardable());
923 let shards = plain.enumerate_shards(4).await.unwrap();
924 assert_eq!(shards.len(), 1);
925 assert!(shards[0].is_whole());
926 }
927
928 #[tokio::test]
931 async fn shard_error_paths() {
932 let source = sharded_memory_source("SELECT k FROM items", "no_such_column").await;
933 sqlx::query("CREATE TABLE items (k INTEGER)")
934 .execute(&source.pool)
935 .await
936 .unwrap();
937 assert!(source.enumerate_shards(4).await.is_err());
938
939 let bad = faucet_core::ShardSpec::new("0", serde_json::json!({ "key": "k" }));
940 assert!(source.apply_shard(&bad).await.is_err());
941 }
942}
943
944#[cfg(test)]
945mod bind_overflow_tests {
946 use super::*;
947 use serde_json::json;
948
949 #[test]
952 fn u64_above_i64_max_is_refused_not_wrapped() {
953 let err = match bind_params(sqlx::query("SELECT 1"), &[json!(u64::MAX)]) {
954 Err(e) => e.to_string(),
955 Ok(_) => panic!("u64::MAX must not bind"),
956 };
957 assert!(err.contains(&u64::MAX.to_string()), "{err}");
958 assert!(
959 !err.contains("-9223372036854775808"),
960 "must not show the wrap: {err}"
961 );
962 }
963
964 #[test]
965 fn values_a_signed_column_can_hold_still_bind() {
966 for v in [json!(0), json!(-1), json!(i64::MAX), json!(i64::MAX as u64)] {
967 assert!(
968 bind_params(sqlx::query("SELECT 1"), std::slice::from_ref(&v)).is_ok(),
969 "{v} must still bind"
970 );
971 }
972 }
973}