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
89fn bind_params<'q>(
91 mut query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
92 bind_values: &'q [Value],
93) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> {
94 for value in bind_values {
95 query = match value {
96 Value::String(s) => query.bind(s.clone()),
97 Value::Number(n) if n.is_i64() => query.bind(n.as_i64().unwrap()),
98 Value::Number(n) => query.bind(n.as_f64().unwrap_or(0.0)),
99 Value::Bool(b) => query.bind(*b),
100 Value::Null => query.bind(None::<String>),
101 _ => query.bind(value.to_string()),
102 };
103 }
104 query
105}
106
107fn row_to_json(row: &sqlx::sqlite::SqliteRow) -> Value {
110 let mut map = serde_json::Map::new();
111 for col in row.columns() {
112 let name = col.name().to_string();
113 let value = sqlite_value_to_json(row, &name);
114 map.insert(name, value);
115 }
116 Value::Object(map)
117}
118
119#[async_trait]
120impl faucet_core::Source for SqliteSource {
121 async fn fetch_with_context(
122 &self,
123 context: &std::collections::HashMap<String, serde_json::Value>,
124 ) -> Result<Vec<Value>, FaucetError> {
125 let (query_str, bind_values) = resolve_query(&self.config, context);
126 let query = bind_params(sqlx::query(&query_str), &bind_values);
127
128 let rows = query
129 .fetch_all(&self.pool)
130 .await
131 .map_err(|e| FaucetError::Config(format!("SQLite query failed: {e}")))?;
132
133 let records: Vec<Value> = rows.iter().map(row_to_json).collect();
134 tracing::info!(
135 rows = records.len(),
136 query = %self.config.query,
137 "SQLite source fetch complete"
138 );
139 Ok(records)
140 }
141
142 fn stream_pages<'a>(
157 &'a self,
158 context: &'a std::collections::HashMap<String, Value>,
159 _batch_size: usize,
160 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
161 let batch_size = self.config.batch_size;
162
163 Box::pin(async_stream::try_stream! {
164 let (query_str, bind_values) = resolve_query(&self.config, context);
165 let query = bind_params(sqlx::query(&query_str), &bind_values);
166
167 let mut rows = query.fetch(&self.pool);
168 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
169 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
170 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
171 let mut total = 0usize;
172
173 while let Some(row) = rows
174 .try_next()
175 .await
176 .map_err(|e| FaucetError::Config(format!("SQLite query failed: {e}")))?
177 {
178 buffer.push(row_to_json(&row));
179 if buffer.len() >= chunk {
180 let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
181 total += page.len();
182 yield StreamPage { records: page, bookmark: None };
183 }
184 }
185 if !buffer.is_empty() {
186 total += buffer.len();
187 yield StreamPage { records: buffer, bookmark: None };
188 }
189
190 tracing::info!(
191 rows = total,
192 batch_size,
193 query = %self.config.query,
194 "SQLite source stream complete",
195 );
196 })
197 }
198
199 fn config_schema(&self) -> serde_json::Value {
200 serde_json::to_value(faucet_core::schema_for!(SqliteSourceConfig))
201 .expect("schema serialization")
202 }
203
204 fn dataset_uri(&self) -> String {
205 let path = self
206 .config
207 .database_url
208 .trim_start_matches("sqlite://")
209 .trim_start_matches("sqlite:");
210 format!("sqlite://{}?query={}", path, self.config.query)
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use faucet_core::Source;
218
219 #[tokio::test]
220 async fn fetch_from_memory_db() {
221 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1 AS val, 'hello' AS msg");
222 let source = SqliteSource::new(config).await.unwrap();
223 let records = source.fetch_all().await.unwrap();
224 assert_eq!(records.len(), 1);
225 assert_eq!(records[0]["val"], 1);
226 assert_eq!(records[0]["msg"], "hello");
227 }
228
229 #[tokio::test]
230 async fn fetch_from_table() {
231 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
232 let source = SqliteSource::new(config).await.unwrap();
233
234 sqlx::query("CREATE TABLE test_items (id INTEGER PRIMARY KEY, name TEXT, score REAL)")
236 .execute(&source.pool)
237 .await
238 .unwrap();
239 sqlx::query(
240 "INSERT INTO test_items (id, name, score) VALUES (1, 'Alice', 95.5), (2, 'Bob', 87.0)",
241 )
242 .execute(&source.pool)
243 .await
244 .unwrap();
245
246 let rows = sqlx::query("SELECT * FROM test_items ORDER BY id")
249 .fetch_all(&source.pool)
250 .await
251 .unwrap();
252
253 assert_eq!(rows.len(), 2);
254 let row0 = &rows[0];
255 assert_eq!(row0.try_get::<i64, _>("id").unwrap(), 1);
256 assert_eq!(row0.try_get::<String, _>("name").unwrap(), "Alice");
257 }
258
259 #[tokio::test]
260 async fn blob_column_decodes_to_base64() {
261 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
263 let source = SqliteSource::new(config).await.unwrap();
264 sqlx::query("CREATE TABLE b (id INTEGER, data BLOB)")
265 .execute(&source.pool)
266 .await
267 .unwrap();
268 sqlx::query("INSERT INTO b (id, data) VALUES (1, X'00FF')")
270 .execute(&source.pool)
271 .await
272 .unwrap();
273 let rows = sqlx::query("SELECT data FROM b")
274 .fetch_all(&source.pool)
275 .await
276 .unwrap();
277 let v = sqlite_value_to_json(&rows[0], "data");
278 assert_eq!(v, Value::String("AP8=".to_string()), "BLOB must be base64");
279 }
280
281 #[tokio::test]
282 async fn empty_result() {
283 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1 AS x WHERE 1 = 0");
284 let source = SqliteSource::new(config).await.unwrap();
285 let records = source.fetch_all().await.unwrap();
286 assert!(records.is_empty());
287 }
288
289 #[tokio::test]
290 async fn invalid_query_returns_error() {
291 let config = SqliteSourceConfig::new("sqlite::memory:", "INVALID SQL");
292 let source = SqliteSource::new(config).await.unwrap();
293 let result = source.fetch_all().await;
294 assert!(result.is_err());
295 }
296
297 #[tokio::test]
298 async fn fetch_with_context_substitutes_query_placeholders() {
299 let config =
300 SqliteSourceConfig::new("sqlite::memory:", "SELECT {val} AS result, {name} AS name");
301 let source = SqliteSource::new(config).await.unwrap();
302
303 let mut context = std::collections::HashMap::new();
304 context.insert("val".to_string(), serde_json::json!(42));
305 context.insert("name".to_string(), serde_json::json!("hello"));
306
307 let records = source.fetch_with_context(&context).await.unwrap();
308 assert_eq!(records.len(), 1);
309 assert_eq!(records[0]["result"], 42);
310 assert_eq!(records[0]["name"], "hello");
311 }
312
313 #[tokio::test]
314 async fn fetch_with_context_prevents_sql_injection() {
315 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT {val} AS result");
316 let source = SqliteSource::new(config).await.unwrap();
317
318 let mut context = std::collections::HashMap::new();
319 context.insert(
320 "val".to_string(),
321 serde_json::json!("1; DROP TABLE test; --"),
322 );
323
324 let records = source.fetch_with_context(&context).await.unwrap();
326 assert_eq!(records.len(), 1);
327 assert_eq!(records[0]["result"], "1; DROP TABLE test; --");
328 }
329
330 #[tokio::test]
331 async fn new_rejects_out_of_range_batch_size() {
332 let mut config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 1");
333 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
334 match SqliteSource::new(config).await {
335 Err(faucet_core::FaucetError::Config(m)) => {
336 assert!(m.contains("batch_size"), "got: {m}")
337 }
338 _ => panic!("expected a batch_size Config error"),
339 }
340 }
341
342 #[test]
345 fn dataset_uri_strips_sqlite_scheme_logic() {
346 let url1 = "sqlite:///var/db/app.db";
348 let path1 = url1
349 .trim_start_matches("sqlite://")
350 .trim_start_matches("sqlite:");
351 assert_eq!(
352 format!("sqlite://{}?query=SELECT 1", path1),
353 "sqlite:///var/db/app.db?query=SELECT 1"
354 );
355
356 let url2 = "sqlite:/tmp/data.db";
357 let path2 = url2
358 .trim_start_matches("sqlite://")
359 .trim_start_matches("sqlite:");
360 assert_eq!(
361 format!("sqlite://{}?query=SELECT 1", path2),
362 "sqlite:///tmp/data.db?query=SELECT 1"
363 );
364 }
365
366 #[tokio::test]
367 async fn dataset_uri_memory_db() {
368 let config = SqliteSourceConfig::new("sqlite::memory:", "SELECT 42 AS n");
370 let source = SqliteSource::new(config).await.unwrap();
371 let uri = source.dataset_uri();
373 assert!(uri.contains("SELECT 42 AS n"), "got: {uri}");
374 assert!(uri.starts_with("sqlite://"), "got: {uri}");
375 }
376}