use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::Connection;
use crate::error::{DataError, Result};
use crate::store::{ColumnDtype, ColumnInfo, IngestReport};
pub fn quote_ident(ident: &str) -> String {
format!("\"{}\"", ident.replace('"', "\"\""))
}
fn validate_table_name(table: &str) -> Result<()> {
if table.is_empty() || table.contains('"') || table.contains('\0') {
return Err(DataError::InvalidIdentifier(table.to_string()));
}
Ok(())
}
#[derive(Debug)]
struct ColumnInference {
all_int: bool,
all_real: bool,
null_count: u64,
}
impl ColumnInference {
fn new() -> Self {
Self {
all_int: true,
all_real: true,
null_count: 0,
}
}
fn observe(&mut self, cell: &str) {
if cell.is_empty() {
self.null_count += 1;
return;
}
if self.all_int && cell.parse::<i64>().is_err() {
self.all_int = false;
}
if self.all_real && cell.parse::<f64>().is_err() {
self.all_real = false;
}
}
fn dtype(&self) -> ColumnDtype {
if self.all_int {
ColumnDtype::Integer
} else if self.all_real {
ColumnDtype::Real
} else {
ColumnDtype::Text
}
}
}
pub fn ingest_csv(conn: &Connection, path: &Path, table: &str) -> Result<IngestReport> {
validate_table_name(table)?;
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.flexible(false)
.from_path(path)?;
let headers: Vec<String> = reader.headers()?.iter().map(|h| h.to_string()).collect();
if headers.is_empty() {
return Err(DataError::EmptyCsv);
}
let records: Vec<csv::StringRecord> = reader.records().collect::<csv::Result<Vec<_>>>()?;
let mut inference: Vec<ColumnInference> =
(0..headers.len()).map(|_| ColumnInference::new()).collect();
for record in &records {
for (col, inf) in inference.iter_mut().enumerate() {
inf.observe(record.get(col).unwrap_or(""));
}
}
let dtypes: Vec<ColumnDtype> = inference.iter().map(ColumnInference::dtype).collect();
let quoted_table = quote_ident(table);
let column_defs: Vec<String> = headers
.iter()
.zip(&dtypes)
.map(|(name, dtype)| format!("{} {}", quote_ident(name), dtype.sqlite_decl_type()))
.collect();
conn.execute(&format!("DROP TABLE IF EXISTS {quoted_table}"), [])?;
conn.execute(
&format!("CREATE TABLE {quoted_table} ({})", column_defs.join(", ")),
[],
)?;
let placeholders = (1..=headers.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let quoted_cols = headers
.iter()
.map(|h| quote_ident(h))
.collect::<Vec<_>>()
.join(", ");
let insert_sql = format!("INSERT INTO {quoted_table} ({quoted_cols}) VALUES ({placeholders})");
let tx = conn.unchecked_transaction()?;
{
let mut stmt = tx.prepare(&insert_sql)?;
for record in &records {
let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(headers.len());
for (col, dtype) in dtypes.iter().enumerate() {
let cell = record.get(col).unwrap_or("");
params.push(cell_to_value(cell, *dtype));
}
let param_refs: Vec<&dyn rusqlite::ToSql> =
params.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
stmt.execute(rusqlite::params_from_iter(param_refs))?;
}
}
let row_count = records.len() as u64;
let source = path.display().to_string();
let ingested_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
tx.execute(
"INSERT INTO __datasets (table_name, source, row_count, ingested_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(table_name) DO UPDATE SET
source = excluded.source,
row_count = excluded.row_count,
ingested_at = excluded.ingested_at",
rusqlite::params![table, source, row_count as i64, ingested_at],
)?;
tx.commit()?;
let columns = headers
.iter()
.zip(&dtypes)
.zip(&inference)
.map(|((name, dtype), inf)| ColumnInfo {
name: name.clone(),
dtype: *dtype,
null_count: inf.null_count,
})
.collect();
Ok(IngestReport {
table: table.to_string(),
row_count,
columns,
source: Some(source),
})
}
fn cell_to_value(cell: &str, dtype: ColumnDtype) -> rusqlite::types::Value {
use rusqlite::types::Value;
if cell.is_empty() {
return Value::Null;
}
match dtype {
ColumnDtype::Integer => match cell.parse::<i64>() {
Ok(v) => Value::Integer(v),
Err(e) => {
tracing::warn!(cell, error = %e, "integer cell failed to parse; storing NULL");
Value::Null
}
},
ColumnDtype::Real => match cell.parse::<f64>() {
Ok(v) => Value::Real(v),
Err(e) => {
tracing::warn!(cell, error = %e, "real cell failed to parse; storing NULL");
Value::Null
}
},
ColumnDtype::Text => Value::Text(cell.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_ident_doubles_embedded_quotes() {
assert_eq!(quote_ident("plain"), "\"plain\"");
assert_eq!(quote_ident("with space"), "\"with space\"");
assert_eq!(quote_ident("weird \"name"), "\"weird \"\"name\"");
assert_eq!(
quote_ident("x\"); DROP TABLE t;--"),
"\"x\"\"); DROP TABLE t;--\""
);
}
#[test]
fn validate_table_name_rejects_unsafe() {
assert!(validate_table_name("ok_table").is_ok());
assert!(matches!(
validate_table_name(""),
Err(DataError::InvalidIdentifier(_))
));
assert!(matches!(
validate_table_name("a\"b"),
Err(DataError::InvalidIdentifier(_))
));
assert!(matches!(
validate_table_name("a\0b"),
Err(DataError::InvalidIdentifier(_))
));
}
#[test]
fn inference_widens_int_to_real_to_text() {
let mut inf = ColumnInference::new();
inf.observe("1");
assert_eq!(inf.dtype(), ColumnDtype::Integer);
inf.observe("2.5");
assert_eq!(inf.dtype(), ColumnDtype::Real);
inf.observe("hello");
assert_eq!(inf.dtype(), ColumnDtype::Text);
}
#[test]
fn empty_cells_are_nulls_not_text() {
let mut inf = ColumnInference::new();
inf.observe("1");
inf.observe("");
inf.observe("2");
assert_eq!(inf.dtype(), ColumnDtype::Integer);
assert_eq!(inf.null_count, 1);
}
#[test]
fn cell_to_value_maps_types_and_nulls() {
use rusqlite::types::Value;
assert!(matches!(
cell_to_value("", ColumnDtype::Integer),
Value::Null
));
assert!(matches!(
cell_to_value("42", ColumnDtype::Integer),
Value::Integer(42)
));
assert!(matches!(
cell_to_value("1.5", ColumnDtype::Real),
Value::Real(_)
));
assert!(matches!(
cell_to_value("hi", ColumnDtype::Text),
Value::Text(_)
));
}
}