Skip to main content

faucet_source_sqlite/
stream.rs

1//! SQLite source implementation.
2
3use 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
16/// A source that executes a SQL query against SQLite and returns rows as JSON.
17pub struct SqliteSource {
18    config: SqliteSourceConfig,
19    pool: SqlitePool,
20    /// Shard applied by the cluster coordinator (Mode B), if any. `None` (or the
21    /// whole-dataset shard) means the full query is streamed. Stored behind a
22    /// `Mutex` so `apply_shard(&self, …)` can record it before streaming.
23    applied_shard: Mutex<Option<PkShardBounds>>,
24}
25
26/// Quote a SQLite identifier with backticks.
27///
28/// Deliberately NOT ANSI double quotes: SQLite's double-quoted-string
29/// misfeature silently reinterprets a double-quoted identifier that does not
30/// resolve to a column as a **string literal**, so a typo'd shard key would
31/// make `MIN("typo")` return the literal string (→ bounds of 0) instead of
32/// erroring. Backtick-quoted identifiers are always identifiers — an unknown
33/// column surfaces as a proper "no such column" error. Embedded backticks are
34/// doubled, preventing identifier injection.
35fn quote_ident_sqlite(name: &str) -> String {
36    format!("`{}`", name.replace('`', "``"))
37}
38
39impl SqliteSource {
40    /// Create a new SQLite source. Establishes a connection pool.
41    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    /// Apply the currently-set shard (if any) to a resolved query string.
58    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
66/// Convert a SQLite row column value to a `serde_json::Value`.
67///
68/// SQLite has dynamic typing — values are stored as INTEGER, REAL, TEXT,
69/// BLOB, or NULL. We try each type in order of specificity.
70fn sqlite_value_to_json(row: &sqlx::sqlite::SqliteRow, col_name: &str) -> Value {
71    // Try JSON first (TEXT that parses as JSON)
72    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    // BLOB → base64 so binary survives the JSON round-trip instead of decoding
94    // to Null (#78/#43). SQLite has no native datetime/uuid/decimal types —
95    // those are stored as TEXT/INTEGER/REAL and handled by the arms above.
96    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
104/// Build the effective SQL query and ordered context-bind values for a given
105/// parent context. Returns the literal query when there is no context.
106///
107/// SQLite uses positional `?` placeholders (not the `$N` form used by
108/// PostgreSQL), so the bind-marker formatter ignores the index.
109fn 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/// How a numeric bind value should be bound onto a sqlx query.
123///
124/// Classifying *before* binding keeps the integer/float decision in one pure,
125/// unit-testable place and — critically — binds any integer in
126/// `[i64::MIN, i64::MAX]` as an exact `i64` rather than an `f64`. Binding an
127/// integer above `2^53` as `f64` silently rounds it (audit F38), so a large
128/// 64-bit id threaded into `WHERE id = ?` would compare against the *wrong*
129/// value and return wrong rows.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131enum NumberBind {
132    /// Exact `i64` — covers every integer in `[i64::MIN, i64::MAX]`.
133    I64,
134    /// Value above `i64::MAX`; bind the `u64` reinterpreted as `i64` (SQLite
135    /// stores INTEGER as a signed 8-byte value and has no unsigned type).
136    U64,
137    /// Genuine floating-point value — bind as `f64`.
138    F64,
139}
140
141/// Classify a JSON number into the bind category to use.
142///
143/// `is_i64()` losslessly covers `[i64::MIN, i64::MAX]` (including the
144/// `(2^53, i64::MAX]` range that `f64` would round); `is_u64()` covers values
145/// above `i64::MAX`; everything else is a real float.
146fn 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
156/// Apply context-derived bind values onto a sqlx query.
157fn 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                // `unwrap()` is sound: the classifier proves the predicate.
166                NumberBind::I64 => query.bind(n.as_i64().unwrap()),
167                // SQLite has no unsigned integer type; reinterpret the bits so
168                // the value round-trips through its signed 8-byte INTEGER
169                // without the precision loss an `f64` cast would introduce.
170                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
181/// Convert a single `SqliteRow` into a JSON object whose keys are the row's
182/// column names.
183fn 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    /// Stream rows from the underlying sqlx cursor without buffering the full
218    /// result set. Each emitted [`StreamPage`] holds up to
219    /// [`SqliteSourceConfig::batch_size`] rows.
220    ///
221    /// The trait-level `batch_size` argument is ignored in favour of the
222    /// config field — the config is the user-facing knob the README
223    /// documents, and routing the pipeline-supplied hint through it would
224    /// silently override an explicit config value.
225    ///
226    /// `batch_size = 0` drains the entire cursor into a single page. SQLite
227    /// is an in-process engine with no server-side cursor concept, so this
228    /// streams rows page-by-page off the local file rather than across a
229    /// network wire. The sqlite query source has no incremental-replication
230    /// mode today, so every emitted page carries `bookmark: None`.
231    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    /// Shardable when a [`ShardConfig`](crate::config::ShardConfig) is set.
290    fn is_shardable(&self) -> bool {
291        self.config.shard.is_some()
292    }
293
294    /// Enumerate contiguous primary-key range shards by computing the `key`
295    /// column's `MIN`/`MAX` over the (unsharded) base query and splitting that
296    /// range into ~`target` slices. Returns a single whole-dataset shard when no
297    /// `shard` config is set or the result set is empty.
298    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            &quote_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    /// Narrow this source to a single PK-range shard. The whole-dataset shard
329    /// clears any applied range (streams the full query).
330    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        // Create a table and insert data.
358        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        // Reuse the same pool by creating a new source pointing to same in-memory db.
370        // For in-memory DBs, each connection gets its own DB, so we query through the existing pool.
371        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        // Regression for #78/#43: a BLOB column must become base64, not Null.
385        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        // X'00FF' = bytes [0x00, 0xFF] — non-UTF8 so it can't be read as text.
392        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        // Value is bound as a parameter, not interpolated — no injection possible
448        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    // dataset_uri is a pure-config method — test the logic without needing a
466    // live file path by exercising the trim logic directly.
467    #[test]
468    fn dataset_uri_strips_sqlite_scheme_logic() {
469        // Verify the trim_start_matches chain that dataset_uri() uses.
470        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    // ── F38: numeric bind classification (precision-safe) ───────────────────
490
491    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        // 2^53 + 1 must NOT be bound as f64 (which would round it). It is a
514        // valid i64, so it must classify as I64.
515        let v = 9_007_199_254_740_993i64; // 2^53 + 1
516        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    /// End-to-end proof through the real bind path: a 64-bit id above 2^53
550    /// bound as a context param must match the stored row exactly (an f64 bind
551    /// would round it and the WHERE clause would miss).
552    #[tokio::test]
553    async fn large_int_param_binds_without_precision_loss() {
554        let big = 9_007_199_254_740_993i64; // 2^53 + 1
555        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        // The bound value must come back exactly — not rounded to 2^53.
565        assert_eq!(records[0]["id"].as_i64().unwrap(), big);
566    }
567
568    #[tokio::test]
569    async fn dataset_uri_memory_db() {
570        // :memory: is a valid SQLite URL that can be opened without a real file.
571        let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 42 AS n");
572        let source = SqliteSource::new(config).await.unwrap();
573        // ":memory:" has no sqlite:// prefix to strip; it passes through as-is.
574        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    // ── PK-range sharding (Mode B, #262) ─────────────────────────────────────
580
581    /// Build a single-connection in-memory source so every query sees the same
582    /// database (each pooled connection normally gets its own `:memory:` DB).
583    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    /// The core Mode B correctness guarantee, end-to-end on a real database:
590    /// enumerating into N shards and reading each shard yields every row —
591    /// including a NULL-key row invisible to MIN/MAX (F37) — exactly once.
592    #[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        // A NULL-key row: MIN/MAX can't see it, but exactly one shard must.
608        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    /// Applying the whole-dataset shard clears the range — full query again.
640    #[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    /// Enumeration over an empty result set degrades to one whole shard, and a
665    /// config without `shard:` is not shardable.
666    #[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    /// Error paths a coordinator must handle: a bad shard key errors at
687    /// enumeration; a malformed descriptor is rejected by apply_shard.
688    #[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}