1pub mod error;
57pub mod ingest;
58pub mod notebook;
66pub mod store;
67pub mod summary;
68
69#[cfg(feature = "kernel")]
77pub mod kernel;
78
79#[cfg(feature = "pyo3")]
80pub mod pyo3_module;
81
82pub use error::{DataError, Result};
83pub use store::sqlite::SqliteBackend;
84pub use store::{
85 ColumnDtype, ColumnInfo, ColumnSummary, DataStore, IngestReport, NumericDescribe, QueryResult,
86 TableInfo, TableSummary,
87};
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use std::io::Write;
93 use tempfile::NamedTempFile;
94
95 const EPS: f64 = 1e-9;
96
97 const FIXTURE_CSV: &str = "id,label,score\n\
102 1,alpha,1.0\n\
103 2,bravo,2.0\n\
104 3,charlie,3.0\n\
105 4,delta,4.0\n\
106 5,echo,\n";
107
108 fn csv_file(contents: &str) -> NamedTempFile {
111 let mut f = NamedTempFile::new().unwrap();
112 f.write_all(contents.as_bytes()).unwrap();
113 f.flush().unwrap();
114 f
115 }
116
117 fn ingested() -> (SqliteBackend, NamedTempFile, IngestReport) {
118 let store = SqliteBackend::open_in_memory().unwrap();
119 let f = csv_file(FIXTURE_CSV);
120 let report = store.ingest_csv(f.path(), "metrics").unwrap();
121 (store, f, report)
122 }
123
124 #[test]
125 fn ingest_infers_dtypes_and_counts_nulls() {
126 let (_store, _f, report) = ingested();
127 assert_eq!(report.table, "metrics");
128 assert_eq!(report.row_count, 5);
129 assert_eq!(report.columns.len(), 3);
130
131 let id = &report.columns[0];
132 assert_eq!(id.name, "id");
133 assert_eq!(id.dtype, ColumnDtype::Integer);
134 assert_eq!(id.null_count, 0);
135
136 let label = &report.columns[1];
137 assert_eq!(label.name, "label");
138 assert_eq!(label.dtype, ColumnDtype::Text);
139 assert_eq!(label.null_count, 0);
140
141 let score = &report.columns[2];
142 assert_eq!(score.name, "score");
143 assert_eq!(score.dtype, ColumnDtype::Real);
144 assert_eq!(score.null_count, 1);
146
147 assert!(report.source.is_some());
148 }
149
150 #[test]
151 fn summarize_describe_matches_pandas() {
152 let (store, _f, _report) = ingested();
153 let summary = store.summarize("metrics").unwrap();
154 assert_eq!(summary.table, "metrics");
155 assert_eq!(summary.row_count, 5);
156
157 let score = summary.columns.iter().find(|c| c.name == "score").unwrap();
158 assert_eq!(score.dtype, ColumnDtype::Real);
159 assert_eq!(score.null_count, 1);
160 assert_eq!(score.distinct_count, 4);
162
163 let d = score.numeric.expect("score is numeric");
164 assert_eq!(d.count, 4);
166 assert!((d.mean - 2.5).abs() < EPS);
167 assert!((d.std - 1.290_994_448_735_805_6).abs() < EPS);
168 assert!((d.min - 1.0).abs() < EPS);
169 assert!((d.q25 - 1.75).abs() < EPS);
170 assert!((d.q50 - 2.5).abs() < EPS);
171 assert!((d.q75 - 3.25).abs() < EPS);
172 assert!((d.max - 4.0).abs() < EPS);
173
174 let id = summary.columns.iter().find(|c| c.name == "id").unwrap();
176 assert!(id.numeric.is_some());
177 let label = summary.columns.iter().find(|c| c.name == "label").unwrap();
178 assert!(label.numeric.is_none());
179 assert_eq!(label.distinct_count, 5);
180 }
181
182 #[test]
183 fn summarize_missing_table_errors() {
184 let store = SqliteBackend::open_in_memory().unwrap();
185 assert!(matches!(
186 store.summarize("nope"),
187 Err(DataError::NoSuchTable(_))
188 ));
189 }
190
191 #[test]
192 fn query_truncation_is_honest() {
193 let (store, _f, _report) = ingested();
194 let capped = store.query("SELECT * FROM metrics", 2).unwrap();
196 assert_eq!(capped.returned, 2);
197 assert_eq!(capped.rows.len(), 2);
198 assert!(capped.truncated);
199 assert_eq!(capped.columns, vec!["id", "label", "score"]);
200
201 let full = store.query("SELECT * FROM metrics", 100).unwrap();
203 assert_eq!(full.returned, 5);
204 assert!(!full.truncated);
205
206 let zero = store.query("SELECT * FROM metrics", 0).unwrap();
208 assert_eq!(zero.returned, 0);
209 assert!(zero.truncated);
210
211 let empty = store
213 .query("SELECT * FROM metrics WHERE id < 0", 0)
214 .unwrap();
215 assert_eq!(empty.returned, 0);
216 assert!(!empty.truncated);
217 }
218
219 #[test]
220 fn query_maps_cell_types_to_json() {
221 let (store, _f, _report) = ingested();
222 let r = store
223 .query("SELECT id, label, score FROM metrics ORDER BY id", 10)
224 .unwrap();
225 assert_eq!(r.rows[0][0], serde_json::json!(1));
227 assert_eq!(r.rows[0][1], serde_json::json!("alpha"));
228 assert_eq!(r.rows[0][2], serde_json::json!(1.0));
229 assert_eq!(r.rows[4][2], serde_json::Value::Null);
231 }
232
233 #[test]
242 fn query_non_finite_real_cell_is_null() {
243 let csv = "v\n\
246 1.0\n\
247 inf\n\
248 -inf\n\
249 nan\n\
250 1e9999\n";
251 let store = SqliteBackend::open_in_memory().unwrap();
252 let f = csv_file(csv);
253 let report = store.ingest_csv(f.path(), "edge").unwrap();
254 assert_eq!(report.row_count, 5);
255 assert_eq!(report.columns[0].dtype, ColumnDtype::Real);
257 assert_eq!(report.columns[0].null_count, 0);
258
259 let r = store
260 .query("SELECT v FROM edge ORDER BY rowid", 10)
261 .unwrap();
262 assert_eq!(r.rows[0][0], serde_json::json!(1.0));
264 for row in &r.rows[1..] {
266 assert_eq!(
267 row[0],
268 serde_json::Value::Null,
269 "non-finite real cell should query back as JSON null"
270 );
271 }
272 }
273
274 #[test]
275 fn query_non_row_statement_returns_empty() {
276 let (store, _f, _report) = ingested();
277 let r = store.query("DELETE FROM metrics WHERE id = 5", 10).unwrap();
278 assert_eq!(r.returned, 0);
279 assert!(!r.truncated);
280 assert!(r.rows.is_empty());
281 let after = store.query("SELECT * FROM metrics", 100).unwrap();
283 assert_eq!(after.returned, 4);
284 }
285
286 #[test]
287 fn identifier_safety_round_trips_weird_headers() {
288 let csv = "id,weird \"name,has space\n\
290 1,x,y\n\
291 2,z,w\n";
292 let store = SqliteBackend::open_in_memory().unwrap();
293 let f = csv_file(csv);
294 let report = store.ingest_csv(f.path(), "quirky").unwrap();
295 assert_eq!(report.row_count, 2);
296
297 let names: Vec<&str> = report.columns.iter().map(|c| c.name.as_str()).collect();
298 assert_eq!(names, vec!["id", "weird \"name", "has space"]);
299
300 let r = store
302 .query(
303 "SELECT \"weird \"\"name\", \"has space\" FROM quirky ORDER BY id",
304 10,
305 )
306 .unwrap();
307 assert_eq!(r.returned, 2);
308 assert_eq!(r.rows[0][0], serde_json::json!("x"));
309 assert_eq!(r.rows[0][1], serde_json::json!("y"));
310
311 let summary = store.summarize("quirky").unwrap();
313 let weird = summary
314 .columns
315 .iter()
316 .find(|c| c.name == "weird \"name")
317 .unwrap();
318 assert_eq!(weird.dtype, ColumnDtype::Text);
319 }
320
321 #[test]
322 fn ingest_rejects_table_name_with_double_quote() {
323 let store = SqliteBackend::open_in_memory().unwrap();
324 let f = csv_file(FIXTURE_CSV);
325 let err = store.ingest_csv(f.path(), "bad\"name").unwrap_err();
326 assert!(matches!(err, DataError::InvalidIdentifier(_)));
327 }
328
329 #[test]
330 fn ingest_empty_csv_errors() {
331 let store = SqliteBackend::open_in_memory().unwrap();
332 let f = csv_file("");
334 let err = store.ingest_csv(f.path(), "empty").unwrap_err();
335 assert!(matches!(err, DataError::EmptyCsv));
336 }
337
338 #[test]
339 fn list_tables_reports_rows_and_source() {
340 let (store, f, _report) = ingested();
341 let tables = store.list_tables().unwrap();
342 assert_eq!(tables.len(), 1);
343 let t = &tables[0];
344 assert_eq!(t.table, "metrics");
345 assert_eq!(t.row_count, 5);
346 assert_eq!(
348 t.source.as_deref(),
349 Some(f.path().display().to_string().as_str())
350 );
351 }
352
353 #[test]
354 fn list_tables_excludes_metadata_table() {
355 let store = SqliteBackend::open_in_memory().unwrap();
356 assert!(store.list_tables().unwrap().is_empty());
358 }
359
360 #[test]
361 fn reingest_replaces_table() {
362 let store = SqliteBackend::open_in_memory().unwrap();
363 let f1 = csv_file(FIXTURE_CSV);
364 store.ingest_csv(f1.path(), "metrics").unwrap();
365
366 let f2 = csv_file("id\n1\n2\n");
368 let report = store.ingest_csv(f2.path(), "metrics").unwrap();
369 assert_eq!(report.row_count, 2);
370 assert_eq!(report.columns.len(), 1);
371
372 let tables = store.list_tables().unwrap();
373 assert_eq!(tables.len(), 1);
374 assert_eq!(tables[0].row_count, 2);
375 }
376
377 #[test]
378 fn backend_name_is_sqlite() {
379 let store = SqliteBackend::open_in_memory().unwrap();
380 assert_eq!(store.backend_name(), "sqlite");
381 }
382
383 #[test]
384 fn datastore_is_object_safe() {
385 let backend = SqliteBackend::open_in_memory().unwrap();
386 let _: &dyn DataStore = &backend;
387 }
388
389 #[test]
390 fn open_on_disk_round_trips() {
391 let dir = tempfile::tempdir().unwrap();
392 let path = SqliteBackend::default_db_path(dir.path());
393 let store = SqliteBackend::open(&path).unwrap();
394 let f = csv_file(FIXTURE_CSV);
395 store.ingest_csv(f.path(), "metrics").unwrap();
396 drop(store);
397
398 let reopened = SqliteBackend::open(&path).unwrap();
400 let tables = reopened.list_tables().unwrap();
401 assert_eq!(tables.len(), 1);
402 assert_eq!(tables[0].row_count, 5);
403 }
404
405 #[test]
406 fn types_serialize_round_trip() {
407 let (store, _f, report) = ingested();
409 let json = serde_json::to_string(&report).unwrap();
410 let back: IngestReport = serde_json::from_str(&json).unwrap();
411 assert_eq!(report, back);
412
413 let summary = store.summarize("metrics").unwrap();
414 let json = serde_json::to_string(&summary).unwrap();
415 let back: TableSummary = serde_json::from_str(&json).unwrap();
416 assert_eq!(summary, back);
417 }
418}