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) -> 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                // `unwrap()` is sound: the classifier proves the predicate.
166                NumberBind::I64 => query.bind(n.as_i64().unwrap()),
167                // Above `i64::MAX`. SQLite's INTEGER is a signed 8-byte value, and
168                // `as i64` would *wrap* — storing a large id as a large negative
169                // number, or (when this binds an incremental bookmark) comparing
170                // against a negative bound and re-reading or skipping rows.
171                // Avoiding an `f64` cast's precision loss was the right instinct;
172                // bit-reinterpretation is not the way to get it. Refuse (#462).
173                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
187/// One flattened `pragma_table_info` row used by [`discover`].
188///
189/// (table, column, declared_type, is_nullable)
190type CatalogRow = (String, String, String, bool);
191
192/// Group flattened catalog rows (ordered by table name, column id) into one
193/// [`DatasetDescriptor`] per table. Pure — unit-testable without a database.
194///
195/// SQLite keeps no cheap row-count statistic (`COUNT(*)` is a full scan and
196/// discovery must never scan data), so descriptors never carry
197/// `estimated_rows`.
198fn 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        // A typeless column (`CREATE TABLE t(x)`) has an empty declared type;
224        // `sql_type_to_json_schema` maps unknown/empty to the safe `string`.
225        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
237/// Convert a single `SqliteRow` into a JSON object whose keys are the row's
238/// column names.
239fn 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    /// Stream rows from the underlying sqlx cursor without buffering the full
274    /// result set. Each emitted [`StreamPage`] holds up to
275    /// [`SqliteSourceConfig::batch_size`] rows.
276    ///
277    /// The trait-level `batch_size` argument is ignored in favour of the
278    /// config field — the config is the user-facing knob the README
279    /// documents, and routing the pipeline-supplied hint through it would
280    /// silently override an explicit config value.
281    ///
282    /// `batch_size = 0` drains the entire cursor into a single page. SQLite
283    /// is an in-process engine with no server-side cursor concept, so this
284    /// streams rows page-by-page off the local file rather than across a
285    /// network wire. The sqlite query source has no incremental-replication
286    /// mode today, so every emitted page carries `bookmark: None`.
287    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    /// Enumerate every user table in `sqlite_master` (internal `sqlite_*`
354    /// tables excluded), with column types and nullability from
355    /// `pragma_table_info` — catalog metadata only, no data scan. SQLite has
356    /// no cheap row-count statistic, so `estimated_rows` is always `None`.
357    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            // `pragma_table_info(?)` is the table-valued-function form of
373            // `PRAGMA table_info` — it takes a bound parameter, so the table
374            // name is never spliced into the SQL text. `notnull` is
375            // backtick-quoted because NOTNULL is a SQLite operator keyword
376            // (and double quotes are a misfeature here — see
377            // `quote_ident_sqlite`).
378            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    /// Shardable when a [`ShardConfig`](crate::config::ShardConfig) is set.
410    fn is_shardable(&self) -> bool {
411        self.config.shard.is_some()
412    }
413
414    /// Enumerate contiguous primary-key range shards by computing the `key`
415    /// column's `MIN`/`MAX` over the (unsharded) base query and splitting that
416    /// range into ~`target` slices. Returns a single whole-dataset shard when no
417    /// `shard` config is set or the result set is empty.
418    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            &quote_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    /// Narrow this source to a single PK-range shard. The whole-dataset shard
449    /// clears any applied range (streams the full query).
450    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        // Create a table and insert data.
478        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        // Reuse the same pool by creating a new source pointing to same in-memory db.
490        // For in-memory DBs, each connection gets its own DB, so we query through the existing pool.
491        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        // Regression for #78/#43: a BLOB column must become base64, not Null.
505        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        // X'00FF' = bytes [0x00, 0xFF] — non-UTF8 so it can't be read as text.
512        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        // Value is bound as a parameter, not interpolated — no injection possible
568        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    // dataset_uri is a pure-config method — test the logic without needing a
586    // live file path by exercising the trim logic directly.
587    #[test]
588    fn dataset_uri_strips_sqlite_scheme_logic() {
589        // Verify the trim_start_matches chain that dataset_uri() uses.
590        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    // ── F38: numeric bind classification (precision-safe) ───────────────────
610
611    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        // 2^53 + 1 must NOT be bound as f64 (which would round it). It is a
634        // valid i64, so it must classify as I64.
635        let v = 9_007_199_254_740_993i64; // 2^53 + 1
636        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    /// End-to-end proof through the real bind path: a 64-bit id above 2^53
670    /// bound as a context param must match the stored row exactly (an f64 bind
671    /// would round it and the WHERE clause would miss).
672    #[tokio::test]
673    async fn large_int_param_binds_without_precision_loss() {
674        let big = 9_007_199_254_740_993i64; // 2^53 + 1
675        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        // The bound value must come back exactly — not rounded to 2^53.
685        assert_eq!(records[0]["id"].as_i64().unwrap(), big);
686    }
687
688    #[tokio::test]
689    async fn dataset_uri_memory_db() {
690        // :memory: is a valid SQLite URL that can be opened without a real file.
691        let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 42 AS n");
692        let source = SqliteSource::new(config).await.unwrap();
693        // ":memory:" has no sqlite:// prefix to strip; it passes through as-is.
694        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    // ── discover: pure catalog-row grouping (#211) ───────────────────────────
700
701    #[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        // `CREATE TABLE t(x)` — a column with no declared type.
767        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    /// End-to-end through the real `discover()` I/O path against an in-memory
782    /// database (`max_connections(1)` keeps every query on the one connection
783    /// that owns the `:memory:` DB).
784    #[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        // No user tables yet → empty catalog (sqlite_master exists but is
791        // internal).
792        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    // ── PK-range sharding (Mode B, #262) ─────────────────────────────────────
822
823    /// Build a single-connection in-memory source so every query sees the same
824    /// database (each pooled connection normally gets its own `:memory:` DB).
825    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    /// The core Mode B correctness guarantee, end-to-end on a real database:
832    /// enumerating into N shards and reading each shard yields every row —
833    /// including a NULL-key row invisible to MIN/MAX (F37) — exactly once.
834    #[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        // A NULL-key row: MIN/MAX can't see it, but exactly one shard must.
850        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    /// Applying the whole-dataset shard clears the range — full query again.
882    #[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    /// Enumeration over an empty result set degrades to one whole shard, and a
907    /// config without `shard:` is not shardable.
908    #[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    /// Error paths a coordinator must handle: a bad shard key errors at
929    /// enumeration; a malformed descriptor is rejected by apply_shard.
930    #[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    /// #462: SQLite's INTEGER is signed 8-byte; `as i64` would wrap a large
950    /// unsigned id to a negative. Refuse instead.
951    #[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}