1use 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
12pub struct SqliteSource {
14 config: SqliteSourceConfig,
15 pool: SqlitePool,
16}
17
18impl SqliteSource {
19 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
33fn sqlite_value_to_json(row: &sqlx::sqlite::SqliteRow, col_name: &str) -> Value {
38 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 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
71fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98enum NumberBind {
99 I64,
101 U64,
104 F64,
106}
107
108fn 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
123fn 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 NumberBind::I64 => query.bind(n.as_i64().unwrap()),
134 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
148fn 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 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 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 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 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 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 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 #[test]
386 fn dataset_uri_strips_sqlite_scheme_logic() {
387 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 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 let v = 9_007_199_254_740_993i64; 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 #[tokio::test]
471 async fn large_int_param_binds_without_precision_loss() {
472 let big = 9_007_199_254_740_993i64; 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 assert_eq!(records[0]["id"].as_i64().unwrap(), big);
484 }
485
486 #[tokio::test]
487 async fn dataset_uri_memory_db() {
488 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 42 AS n");
490 let source = SqliteSource::new(config).await.unwrap();
491 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}