newt_data/store/
sqlite.rs1use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15
16use rusqlite::types::ValueRef;
17use rusqlite::Connection;
18
19use crate::error::Result;
20use crate::ingest::quote_ident;
21use crate::store::{DataStore, IngestReport, QueryResult, TableInfo, TableSummary};
22use crate::{ingest, summary};
23
24const DATA_DIR: &str = ".newt-data";
28
29const DB_FILE: &str = "data.db";
31
32#[derive(Debug, Clone)]
37pub struct SqliteBackend {
38 conn: Arc<Mutex<Connection>>,
39}
40
41impl SqliteBackend {
42 pub fn open(path: &Path) -> Result<Self> {
50 if let Some(parent) = path.parent() {
51 std::fs::create_dir_all(parent)?;
52 }
53 let conn = Connection::open(path)?;
54 apply_journal_mode(&conn)?;
55 ensure_metadata_table(&conn)?;
56 Ok(Self {
57 conn: Arc::new(Mutex::new(conn)),
58 })
59 }
60
61 pub fn open_in_memory() -> Result<Self> {
64 let conn = Connection::open_in_memory()?;
65 ensure_metadata_table(&conn)?;
66 Ok(Self {
67 conn: Arc::new(Mutex::new(conn)),
68 })
69 }
70
71 pub fn default_db_path(workspace: &Path) -> PathBuf {
74 workspace.join(DATA_DIR).join(DB_FILE)
75 }
76
77 fn lock_conn(&self) -> std::sync::MutexGuard<'_, Connection> {
81 self.conn
82 .lock()
83 .unwrap_or_else(std::sync::PoisonError::into_inner)
84 }
85}
86
87impl DataStore for SqliteBackend {
88 fn ingest_csv(&self, path: &Path, table: &str) -> Result<IngestReport> {
89 let conn = self.lock_conn();
90 ingest::ingest_csv(&conn, path, table)
91 }
92
93 fn query(&self, sql: &str, row_cap: usize) -> Result<QueryResult> {
94 let conn = self.lock_conn();
95 let mut stmt = conn.prepare(sql)?;
96 let columns: Vec<String> = stmt.column_names().iter().map(|c| c.to_string()).collect();
97
98 if stmt.column_count() == 0 {
101 stmt.raw_execute()?;
102 return Ok(QueryResult {
103 columns,
104 rows: Vec::new(),
105 returned: 0,
106 truncated: false,
107 });
108 }
109
110 let mut sql_rows = stmt.query([])?;
111 let mut rows: Vec<Vec<serde_json::Value>> = Vec::new();
112 let mut truncated = false;
113 while let Some(row) = sql_rows.next()? {
114 if rows.len() >= row_cap {
115 truncated = true;
117 break;
118 }
119 let mut out_row = Vec::with_capacity(columns.len());
120 for i in 0..columns.len() {
121 out_row.push(value_ref_to_json(row.get_ref(i)?));
122 }
123 rows.push(out_row);
124 }
125
126 let returned = rows.len();
127 Ok(QueryResult {
128 columns,
129 rows,
130 returned,
131 truncated,
132 })
133 }
134
135 fn summarize(&self, table: &str) -> Result<TableSummary> {
136 let conn = self.lock_conn();
137 summary::summarize(&conn, table)
138 }
139
140 fn list_tables(&self) -> Result<Vec<TableInfo>> {
141 let conn = self.lock_conn();
142 let names: Vec<String> = {
143 let mut stmt = conn.prepare(
144 "SELECT name FROM sqlite_master
145 WHERE type = 'table'
146 AND name NOT LIKE 'sqlite_%'
147 AND name != '__datasets'
148 ORDER BY name",
149 )?;
150 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
151 rows.collect::<rusqlite::Result<Vec<_>>>()?
152 };
153
154 let mut infos = Vec::with_capacity(names.len());
155 for name in names {
156 let quoted = quote_ident(&name);
157 let row_count: u64 =
158 conn.query_row(&format!("SELECT COUNT(*) FROM {quoted}"), [], |row| {
159 row.get::<_, i64>(0).map(|n| n.max(0) as u64)
160 })?;
161 let source: Option<String> = conn
162 .query_row(
163 "SELECT source FROM __datasets WHERE table_name = ?1",
164 [&name],
165 |row| row.get::<_, Option<String>>(0),
166 )
167 .ok()
168 .flatten();
169 infos.push(TableInfo {
170 table: name,
171 row_count,
172 source,
173 });
174 }
175 Ok(infos)
176 }
177
178 fn backend_name(&self) -> &'static str {
179 "sqlite"
180 }
181}
182
183fn apply_journal_mode(conn: &Connection) -> Result<()> {
188 let wal: rusqlite::Result<String> =
189 conn.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0));
190 match wal {
191 Ok(mode) if mode.eq_ignore_ascii_case("wal") => {
194 conn.pragma_update(None, "synchronous", "NORMAL")?;
195 Ok(())
196 }
197 Ok(mode) => {
198 tracing::warn!(%mode, "journal_mode=WAL did not take; keeping synchronous=FULL");
199 Ok(())
200 }
201 Err(e) if wal_fallback_eligible(&e.to_string()) => {
202 tracing::warn!(
203 error = %e,
204 "SQLite refused WAL (network filesystem?); data.db is running on the \
205 slower journal_mode=DELETE fallback"
206 );
207 conn.pragma_update(None, "journal_mode", "DELETE")?;
208 Ok(())
209 }
210 Err(e) => Err(e.into()),
211 }
212}
213
214fn wal_fallback_eligible(error_text: &str) -> bool {
219 let lower = error_text.to_lowercase();
220 lower.contains("locking protocol") || lower.contains("disk i/o error")
221}
222
223fn ensure_metadata_table(conn: &Connection) -> Result<()> {
226 conn.execute(
227 "CREATE TABLE IF NOT EXISTS __datasets (
228 table_name TEXT PRIMARY KEY,
229 source TEXT,
230 row_count INTEGER NOT NULL,
231 ingested_at INTEGER NOT NULL
232 )",
233 [],
234 )?;
235 Ok(())
236}
237
238fn value_ref_to_json(value: ValueRef<'_>) -> serde_json::Value {
259 use serde_json::Value;
260 match value {
261 ValueRef::Null => Value::Null,
262 ValueRef::Integer(i) => Value::Number(i.into()),
263 ValueRef::Real(f) => serde_json::Number::from_f64(f)
264 .map(Value::Number)
265 .unwrap_or(Value::Null),
266 ValueRef::Text(bytes) => Value::String(String::from_utf8_lossy(bytes).into_owned()),
267 ValueRef::Blob(bytes) => Value::String(base64_encode(bytes)),
268 }
269}
270
271fn base64_encode(bytes: &[u8]) -> String {
278 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
279 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
280 for chunk in bytes.chunks(3) {
281 let b0 = chunk[0];
282 let b1 = chunk.get(1).copied().unwrap_or(0);
283 let b2 = chunk.get(2).copied().unwrap_or(0);
284 out.push(ALPHABET[(b0 >> 2) as usize] as char);
285 out.push(ALPHABET[(((b0 & 0b11) << 4) | (b1 >> 4)) as usize] as char);
286 if chunk.len() > 1 {
287 out.push(ALPHABET[(((b1 & 0b1111) << 2) | (b2 >> 6)) as usize] as char);
288 } else {
289 out.push('=');
290 }
291 if chunk.len() > 2 {
292 out.push(ALPHABET[(b2 & 0b111111) as usize] as char);
293 } else {
294 out.push('=');
295 }
296 }
297 out
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn base64_matches_known_vectors() {
306 assert_eq!(base64_encode(b""), "");
308 assert_eq!(base64_encode(b"f"), "Zg==");
309 assert_eq!(base64_encode(b"fo"), "Zm8=");
310 assert_eq!(base64_encode(b"foo"), "Zm9v");
311 assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
312 assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
313 assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
314 }
315
316 #[test]
317 fn default_db_path_is_under_newt_data() {
318 let p = SqliteBackend::default_db_path(Path::new("/tmp/ws"));
319 assert_eq!(p, Path::new("/tmp/ws/.newt-data/data.db"));
320 }
321
322 #[test]
323 fn wal_fallback_predicate() {
324 assert!(wal_fallback_eligible("disk I/O error"));
325 assert!(wal_fallback_eligible("locking protocol"));
326 assert!(!wal_fallback_eligible("syntax error"));
327 }
328
329 #[test]
330 fn open_creates_parent_and_metadata() {
331 let dir = tempfile::tempdir().unwrap();
332 let path = SqliteBackend::default_db_path(dir.path());
333 let backend = SqliteBackend::open(&path).unwrap();
334 assert!(path.exists());
335 assert_eq!(backend.list_tables().unwrap().len(), 0);
337 }
338
339 #[test]
340 fn value_ref_blob_becomes_base64() {
341 assert_eq!(
342 value_ref_to_json(ValueRef::Blob(b"foobar")),
343 serde_json::Value::String("Zm9vYmFy".to_string())
344 );
345 assert_eq!(value_ref_to_json(ValueRef::Null), serde_json::Value::Null);
346 }
347
348 #[test]
349 fn value_ref_real_finite_becomes_number() {
350 assert_eq!(
351 value_ref_to_json(ValueRef::Real(1.5)),
352 serde_json::json!(1.5)
353 );
354 }
355
356 #[test]
362 fn value_ref_real_non_finite_becomes_null() {
363 for f in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
364 assert_eq!(
365 value_ref_to_json(ValueRef::Real(f)),
366 serde_json::Value::Null,
367 "non-finite real {f} should degrade to JSON null"
368 );
369 }
370 }
371}