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::{FaucetError, Stream, StreamPage};
6use futures::TryStreamExt;
7use serde_json::Value;
8use sqlx::sqlite::SqlitePoolOptions;
9use sqlx::{Column, Row, SqlitePool};
10use std::pin::Pin;
11
12/// A source that executes a SQL query against SQLite and returns rows as JSON.
13pub struct SqliteSource {
14    config: SqliteSourceConfig,
15    pool: SqlitePool,
16}
17
18impl SqliteSource {
19    /// Create a new SQLite source. Establishes a connection pool.
20    pub async fn new(config: SqliteSourceConfig) -> Result<Self, FaucetError> {
21        faucet_core::validate_batch_size(config.batch_size)?;
22
23        let pool = SqlitePoolOptions::new()
24            .max_connections(config.max_connections)
25            .connect(&config.database_url)
26            .await
27            .map_err(|e| FaucetError::Config(format!("SQLite connection failed: {e}")))?;
28
29        Ok(Self { config, pool })
30    }
31}
32
33/// Convert a SQLite row column value to a `serde_json::Value`.
34///
35/// SQLite has dynamic typing — values are stored as INTEGER, REAL, TEXT,
36/// BLOB, or NULL. We try each type in order of specificity.
37fn sqlite_value_to_json(row: &sqlx::sqlite::SqliteRow, col_name: &str) -> Value {
38    // Try JSON first (TEXT that parses as JSON)
39    if let Ok(v) = row.try_get::<Value, _>(col_name) {
40        return v;
41    }
42
43    if let Ok(v) = row.try_get::<String, _>(col_name) {
44        return Value::String(v);
45    }
46    if let Ok(v) = row.try_get::<i64, _>(col_name) {
47        return Value::Number(v.into());
48    }
49    if let Ok(v) = row.try_get::<i32, _>(col_name) {
50        return Value::Number(v.into());
51    }
52    if let Ok(v) = row.try_get::<f64, _>(col_name) {
53        return serde_json::Number::from_f64(v)
54            .map(Value::Number)
55            .unwrap_or(Value::Null);
56    }
57    if let Ok(v) = row.try_get::<bool, _>(col_name) {
58        return Value::Bool(v);
59    }
60    // BLOB → base64 so binary survives the JSON round-trip instead of decoding
61    // to Null (#78/#43). SQLite has no native datetime/uuid/decimal types —
62    // those are stored as TEXT/INTEGER/REAL and handled by the arms above.
63    if let Ok(v) = row.try_get::<Vec<u8>, _>(col_name) {
64        use base64::Engine as _;
65        return Value::String(base64::engine::general_purpose::STANDARD.encode(v));
66    }
67
68    Value::Null
69}
70
71/// Build the effective SQL query and ordered context-bind values for a given
72/// parent context. Returns the literal query when there is no context.
73///
74/// SQLite uses positional `?` placeholders (not the `$N` form used by
75/// PostgreSQL), so the bind-marker formatter ignores the index.
76fn resolve_query(
77    config: &SqliteSourceConfig,
78    context: &std::collections::HashMap<String, Value>,
79) -> (String, Vec<Value>) {
80    if context.is_empty() {
81        (config.query.clone(), Vec::new())
82    } else {
83        faucet_core::util::substitute_context_bind_params(&config.query, context, 1, |_| {
84            "?".to_string()
85        })
86    }
87}
88
89/// How a numeric bind value should be bound onto a sqlx query.
90///
91/// Classifying *before* binding keeps the integer/float decision in one pure,
92/// unit-testable place and — critically — binds any integer in
93/// `[i64::MIN, i64::MAX]` as an exact `i64` rather than an `f64`. Binding an
94/// integer above `2^53` as `f64` silently rounds it (audit F38), so a large
95/// 64-bit id threaded into `WHERE id = ?` would compare against the *wrong*
96/// value and return wrong rows.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98enum NumberBind {
99    /// Exact `i64` — covers every integer in `[i64::MIN, i64::MAX]`.
100    I64,
101    /// Value above `i64::MAX`; bind the `u64` reinterpreted as `i64` (SQLite
102    /// stores INTEGER as a signed 8-byte value and has no unsigned type).
103    U64,
104    /// Genuine floating-point value — bind as `f64`.
105    F64,
106}
107
108/// Classify a JSON number into the bind category to use.
109///
110/// `is_i64()` losslessly covers `[i64::MIN, i64::MAX]` (including the
111/// `(2^53, i64::MAX]` range that `f64` would round); `is_u64()` covers values
112/// above `i64::MAX`; everything else is a real float.
113fn classify_number(n: &serde_json::Number) -> NumberBind {
114    if n.is_i64() {
115        NumberBind::I64
116    } else if n.is_u64() {
117        NumberBind::U64
118    } else {
119        NumberBind::F64
120    }
121}
122
123/// Apply context-derived bind values onto a sqlx query.
124fn bind_params<'q>(
125    mut query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
126    bind_values: &'q [Value],
127) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> {
128    for value in bind_values {
129        query = match value {
130            Value::String(s) => query.bind(s.clone()),
131            Value::Number(n) => match classify_number(n) {
132                // `unwrap()` is sound: the classifier proves the predicate.
133                NumberBind::I64 => query.bind(n.as_i64().unwrap()),
134                // SQLite has no unsigned integer type; reinterpret the bits so
135                // the value round-trips through its signed 8-byte INTEGER
136                // without the precision loss an `f64` cast would introduce.
137                NumberBind::U64 => query.bind(n.as_u64().unwrap() as i64),
138                NumberBind::F64 => query.bind(n.as_f64().unwrap_or(0.0)),
139            },
140            Value::Bool(b) => query.bind(*b),
141            Value::Null => query.bind(None::<String>),
142            _ => query.bind(value.to_string()),
143        };
144    }
145    query
146}
147
148/// Convert a single `SqliteRow` into a JSON object whose keys are the row's
149/// column names.
150fn row_to_json(row: &sqlx::sqlite::SqliteRow) -> Value {
151    let mut map = serde_json::Map::new();
152    for col in row.columns() {
153        let name = col.name().to_string();
154        let value = sqlite_value_to_json(row, &name);
155        map.insert(name, value);
156    }
157    Value::Object(map)
158}
159
160#[async_trait]
161impl faucet_core::Source for SqliteSource {
162    async fn fetch_with_context(
163        &self,
164        context: &std::collections::HashMap<String, serde_json::Value>,
165    ) -> Result<Vec<Value>, FaucetError> {
166        let (query_str, bind_values) = resolve_query(&self.config, context);
167        let query = bind_params(sqlx::query(&query_str), &bind_values);
168
169        let rows = query
170            .fetch_all(&self.pool)
171            .await
172            .map_err(|e| FaucetError::Config(format!("SQLite query failed: {e}")))?;
173
174        let records: Vec<Value> = rows.iter().map(row_to_json).collect();
175        tracing::info!(
176            rows = records.len(),
177            query = %self.config.query,
178            "SQLite source fetch complete"
179        );
180        Ok(records)
181    }
182
183    /// Stream rows from the underlying sqlx cursor without buffering the full
184    /// result set. Each emitted [`StreamPage`] holds up to
185    /// [`SqliteSourceConfig::batch_size`] rows.
186    ///
187    /// The trait-level `batch_size` argument is ignored in favour of the
188    /// config field — the config is the user-facing knob the README
189    /// documents, and routing the pipeline-supplied hint through it would
190    /// silently override an explicit config value.
191    ///
192    /// `batch_size = 0` drains the entire cursor into a single page. SQLite
193    /// is an in-process engine with no server-side cursor concept, so this
194    /// streams rows page-by-page off the local file rather than across a
195    /// network wire. The sqlite query source has no incremental-replication
196    /// mode today, so every emitted page carries `bookmark: None`.
197    fn stream_pages<'a>(
198        &'a self,
199        context: &'a std::collections::HashMap<String, Value>,
200        _batch_size: usize,
201    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
202        let batch_size = self.config.batch_size;
203
204        Box::pin(async_stream::try_stream! {
205            let (query_str, bind_values) = resolve_query(&self.config, context);
206            let query = bind_params(sqlx::query(&query_str), &bind_values);
207
208            let mut rows = query.fetch(&self.pool);
209            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
210            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
211            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
212            let mut total = 0usize;
213
214            while let Some(row) = rows
215                .try_next()
216                .await
217                .map_err(|e| FaucetError::Config(format!("SQLite query failed: {e}")))?
218            {
219                buffer.push(row_to_json(&row));
220                if buffer.len() >= chunk {
221                    let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
222                    total += page.len();
223                    yield StreamPage { records: page, bookmark: None };
224                }
225            }
226            if !buffer.is_empty() {
227                total += buffer.len();
228                yield StreamPage { records: buffer, bookmark: None };
229            }
230
231            tracing::info!(
232                rows = total,
233                batch_size,
234                query = %self.config.query,
235                "SQLite source stream complete",
236            );
237        })
238    }
239
240    fn config_schema(&self) -> serde_json::Value {
241        serde_json::to_value(faucet_core::schema_for!(SqliteSourceConfig))
242            .expect("schema serialization")
243    }
244
245    fn dataset_uri(&self) -> String {
246        let path = self
247            .config
248            .database_url
249            .trim_start_matches("sqlite://")
250            .trim_start_matches("sqlite:");
251        format!("sqlite://{}?query={}", path, self.config.query)
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use faucet_core::Source;
259
260    #[tokio::test]
261    async fn fetch_from_memory_db() {
262        let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1 AS val, 'hello' AS msg");
263        let source = SqliteSource::new(config).await.unwrap();
264        let records = source.fetch_all().await.unwrap();
265        assert_eq!(records.len(), 1);
266        assert_eq!(records[0]["val"], 1);
267        assert_eq!(records[0]["msg"], "hello");
268    }
269
270    #[tokio::test]
271    async fn fetch_from_table() {
272        let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
273        let source = SqliteSource::new(config).await.unwrap();
274
275        // Create a table and insert data.
276        sqlx::query("CREATE TABLE test_items (id INTEGER PRIMARY KEY, name TEXT, score REAL)")
277            .execute(&source.pool)
278            .await
279            .unwrap();
280        sqlx::query(
281            "INSERT INTO test_items (id, name, score) VALUES (1, 'Alice', 95.5), (2, 'Bob', 87.0)",
282        )
283        .execute(&source.pool)
284        .await
285        .unwrap();
286
287        // Reuse the same pool by creating a new source pointing to same in-memory db.
288        // For in-memory DBs, each connection gets its own DB, so we query through the existing pool.
289        let rows = sqlx::query("SELECT * FROM test_items ORDER BY id")
290            .fetch_all(&source.pool)
291            .await
292            .unwrap();
293
294        assert_eq!(rows.len(), 2);
295        let row0 = &rows[0];
296        assert_eq!(row0.try_get::<i64, _>("id").unwrap(), 1);
297        assert_eq!(row0.try_get::<String, _>("name").unwrap(), "Alice");
298    }
299
300    #[tokio::test]
301    async fn blob_column_decodes_to_base64() {
302        // Regression for #78/#43: a BLOB column must become base64, not Null.
303        let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
304        let source = SqliteSource::new(config).await.unwrap();
305        sqlx::query("CREATE TABLE b (id INTEGER, data BLOB)")
306            .execute(&source.pool)
307            .await
308            .unwrap();
309        // X'00FF' = bytes [0x00, 0xFF] — non-UTF8 so it can't be read as text.
310        sqlx::query("INSERT INTO b (id, data) VALUES (1, X'00FF')")
311            .execute(&source.pool)
312            .await
313            .unwrap();
314        let rows = sqlx::query("SELECT data FROM b")
315            .fetch_all(&source.pool)
316            .await
317            .unwrap();
318        let v = sqlite_value_to_json(&rows[0], "data");
319        assert_eq!(v, Value::String("AP8=".to_string()), "BLOB must be base64");
320    }
321
322    #[tokio::test]
323    async fn empty_result() {
324        let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1 AS x WHERE 1 = 0");
325        let source = SqliteSource::new(config).await.unwrap();
326        let records = source.fetch_all().await.unwrap();
327        assert!(records.is_empty());
328    }
329
330    #[tokio::test]
331    async fn invalid_query_returns_error() {
332        let config = SqliteSourceConfig::new("sqlite::memory:", "INVALID SQL");
333        let source = SqliteSource::new(config).await.unwrap();
334        let result = source.fetch_all().await;
335        assert!(result.is_err());
336    }
337
338    #[tokio::test]
339    async fn fetch_with_context_substitutes_query_placeholders() {
340        let config =
341            SqliteSourceConfig::new("sqlite::memory:", "SELECT {val} AS result, {name} AS name");
342        let source = SqliteSource::new(config).await.unwrap();
343
344        let mut context = std::collections::HashMap::new();
345        context.insert("val".to_string(), serde_json::json!(42));
346        context.insert("name".to_string(), serde_json::json!("hello"));
347
348        let records = source.fetch_with_context(&context).await.unwrap();
349        assert_eq!(records.len(), 1);
350        assert_eq!(records[0]["result"], 42);
351        assert_eq!(records[0]["name"], "hello");
352    }
353
354    #[tokio::test]
355    async fn fetch_with_context_prevents_sql_injection() {
356        let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT {val} AS result");
357        let source = SqliteSource::new(config).await.unwrap();
358
359        let mut context = std::collections::HashMap::new();
360        context.insert(
361            "val".to_string(),
362            serde_json::json!("1; DROP TABLE test; --"),
363        );
364
365        // Value is bound as a parameter, not interpolated — no injection possible
366        let records = source.fetch_with_context(&context).await.unwrap();
367        assert_eq!(records.len(), 1);
368        assert_eq!(records[0]["result"], "1; DROP TABLE test; --");
369    }
370
371    #[tokio::test]
372    async fn new_rejects_out_of_range_batch_size() {
373        let mut config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
374        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
375        match SqliteSource::new(config).await {
376            Err(faucet_core::FaucetError::Config(m)) => {
377                assert!(m.contains("batch_size"), "got: {m}")
378            }
379            _ => panic!("expected a batch_size Config error"),
380        }
381    }
382
383    // dataset_uri is a pure-config method — test the logic without needing a
384    // live file path by exercising the trim logic directly.
385    #[test]
386    fn dataset_uri_strips_sqlite_scheme_logic() {
387        // Verify the trim_start_matches chain that dataset_uri() uses.
388        let url1 = "sqlite:///var/db/app.db";
389        let path1 = url1
390            .trim_start_matches("sqlite://")
391            .trim_start_matches("sqlite:");
392        assert_eq!(
393            format!("sqlite://{}?query=SELECT 1", path1),
394            "sqlite:///var/db/app.db?query=SELECT 1"
395        );
396
397        let url2 = "sqlite:/tmp/data.db";
398        let path2 = url2
399            .trim_start_matches("sqlite://")
400            .trim_start_matches("sqlite:");
401        assert_eq!(
402            format!("sqlite://{}?query=SELECT 1", path2),
403            "sqlite:///tmp/data.db?query=SELECT 1"
404        );
405    }
406
407    // ── F38: numeric bind classification (precision-safe) ───────────────────
408
409    fn num(v: serde_json::Value) -> serde_json::Number {
410        match v {
411            serde_json::Value::Number(n) => n,
412            _ => panic!("not a number"),
413        }
414    }
415
416    #[test]
417    fn classify_small_int_is_i64() {
418        assert_eq!(
419            classify_number(&num(serde_json::json!(42))),
420            NumberBind::I64
421        );
422        assert_eq!(
423            classify_number(&num(serde_json::json!(-7))),
424            NumberBind::I64
425        );
426        assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
427    }
428
429    #[test]
430    fn classify_above_2_pow_53_stays_i64_not_f64() {
431        // 2^53 + 1 must NOT be bound as f64 (which would round it). It is a
432        // valid i64, so it must classify as I64.
433        let v = 9_007_199_254_740_993i64; // 2^53 + 1
434        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
435    }
436
437    #[test]
438    fn classify_i64_boundaries_are_i64() {
439        assert_eq!(
440            classify_number(&num(serde_json::json!(i64::MAX))),
441            NumberBind::I64
442        );
443        assert_eq!(
444            classify_number(&num(serde_json::json!(i64::MIN))),
445            NumberBind::I64
446        );
447    }
448
449    #[test]
450    fn classify_above_i64_max_is_u64() {
451        let v: u64 = i64::MAX as u64 + 1;
452        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
453        assert_eq!(
454            classify_number(&num(serde_json::json!(u64::MAX))),
455            NumberBind::U64
456        );
457    }
458
459    #[test]
460    fn classify_float_is_f64() {
461        assert_eq!(
462            classify_number(&num(serde_json::json!(3.5))),
463            NumberBind::F64
464        );
465    }
466
467    /// End-to-end proof through the real bind path: a 64-bit id above 2^53
468    /// bound as a context param must match the stored row exactly (an f64 bind
469    /// would round it and the WHERE clause would miss).
470    #[tokio::test]
471    async fn large_int_param_binds_without_precision_loss() {
472        let big = 9_007_199_254_740_993i64; // 2^53 + 1
473        let config =
474            SqliteSourceConfig::new("sqlite::memory:", "SELECT {id} AS id, 'hit' AS marker");
475        let source = SqliteSource::new(config).await.unwrap();
476
477        let mut context = std::collections::HashMap::new();
478        context.insert("id".to_string(), serde_json::json!(big));
479
480        let records = source.fetch_with_context(&context).await.unwrap();
481        assert_eq!(records.len(), 1);
482        // The bound value must come back exactly — not rounded to 2^53.
483        assert_eq!(records[0]["id"].as_i64().unwrap(), big);
484    }
485
486    #[tokio::test]
487    async fn dataset_uri_memory_db() {
488        // :memory: is a valid SQLite URL that can be opened without a real file.
489        let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 42 AS n");
490        let source = SqliteSource::new(config).await.unwrap();
491        // ":memory:" has no sqlite:// prefix to strip; it passes through as-is.
492        let uri = source.dataset_uri();
493        assert!(uri.contains("SELECT 42 AS n"), "got: {uri}");
494        assert!(uri.starts_with("sqlite://"), "got: {uri}");
495    }
496}