use std::collections::BTreeSet;
use std::io::{BufRead, BufReader};
use std::path::Path;
use crate::application::ports::RuntimeEntityPort;
use crate::application::CreateDocumentInput;
use crate::runtime::RedDBRuntime;
use crate::storage::import::{CsvConfig, CsvImporter};
pub const POSITIONAL_ALIAS: &str = "t";
#[derive(Debug, Clone)]
pub struct EphemeralTable {
pub collection: String,
pub alias: String,
pub rows_imported: usize,
}
struct DataFileSpec {
display: String,
base_collection: String,
format: EphemeralFormat,
}
#[derive(Debug)]
pub enum EphemeralError {
NotAFile { path: String },
UnsupportedExtension { path: String, ext: String },
EmptyStem { path: String },
Import { path: String, source: String },
Json { path: String, source: String },
JsonShape { path: String, source: String },
}
impl std::fmt::Display for EphemeralError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EphemeralError::NotAFile { path } => {
write!(f, "cannot read data file '{path}': no such file")
}
EphemeralError::UnsupportedExtension { path, ext } => write!(
f,
"unsupported data file '{path}': '.{ext}' is not a supported ephemeral data file \
(expected .csv, .tsv, .tab, .json, .jsonl, or .ndjson)"
),
EphemeralError::EmptyStem { path } => write!(
f,
"cannot derive a table name from '{path}': the file stem is empty"
),
EphemeralError::Import { path, source } => {
write!(f, "failed to load '{path}': {source}")
}
EphemeralError::Json { path, source } => {
write!(f, "failed to parse '{path}': {source}")
}
EphemeralError::JsonShape { path, source } => {
write!(f, "failed to load '{path}': {source}")
}
}
}
}
impl std::error::Error for EphemeralError {}
#[must_use]
pub fn sanitize_stem(stem: &str) -> Option<String> {
let mut out = String::with_capacity(stem.len());
let mut prev_underscore = false;
for ch in stem.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_underscore = false;
} else if !prev_underscore {
out.push('_');
prev_underscore = true;
}
}
let trimmed = out.trim_matches('_');
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
Some(format!("_{trimmed}"))
} else {
Some(trimmed.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EphemeralFormat {
Delimited(u8),
JsonArray,
Ndjson,
}
fn format_for_extension(ext: &str) -> Option<EphemeralFormat> {
match ext {
"csv" => Some(EphemeralFormat::Delimited(b',')),
"tsv" | "tab" => Some(EphemeralFormat::Delimited(b'\t')),
"json" => Some(EphemeralFormat::JsonArray),
"jsonl" | "ndjson" => Some(EphemeralFormat::Ndjson),
_ => None,
}
}
impl RedDBRuntime {
pub fn materialize_data_file(&self, path: &Path) -> Result<EphemeralTable, EphemeralError> {
let mut tables = self.materialize_data_files(&[path])?;
Ok(tables.remove(0))
}
pub fn materialize_data_files(
&self,
paths: &[&Path],
) -> Result<Vec<EphemeralTable>, EphemeralError> {
let specs = paths
.iter()
.map(|path| data_file_spec(path))
.collect::<Result<Vec<_>, _>>()?;
let mut used_collections = BTreeSet::new();
let mut tables = Vec::with_capacity(specs.len());
for (index, spec) in specs.iter().enumerate() {
let collection = unique_collection_name(&spec.base_collection, &mut used_collections);
let alias = positional_alias(index, specs.len());
let rows_imported = self.import_data_file(paths[index], &collection, spec)?;
if alias != collection {
self.import_data_file(paths[index], &alias, spec)?;
}
tables.push(EphemeralTable {
collection,
alias,
rows_imported,
});
}
Ok(tables)
}
fn import_data_file(
&self,
path: &Path,
collection: &str,
spec: &DataFileSpec,
) -> Result<usize, EphemeralError> {
match spec.format {
EphemeralFormat::Delimited(delimiter) => {
self.import_csv_into(path, collection, delimiter, &spec.display)
}
EphemeralFormat::JsonArray => {
self.import_json_array_into(path, collection, &spec.display)
}
EphemeralFormat::Ndjson => self.import_ndjson_into(path, collection, &spec.display),
}
}
fn import_csv_into(
&self,
path: &Path,
collection: &str,
delimiter: u8,
display: &str,
) -> Result<usize, EphemeralError> {
let importer = CsvImporter::new(CsvConfig {
collection: collection.to_string(),
has_header: true,
delimiter,
skip_errors: false,
..CsvConfig::default()
});
let store = self.inner.db.store();
let _ = store.get_or_create_collection(collection);
let stats =
importer
.import_file(path, store.as_ref())
.map_err(|e| EphemeralError::Import {
path: display.to_string(),
source: e.to_string(),
})?;
self.note_table_write(collection);
Ok(stats.records_imported)
}
fn import_json_array_into(
&self,
path: &Path,
collection: &str,
display: &str,
) -> Result<usize, EphemeralError> {
let raw = std::fs::read_to_string(path).map_err(|e| EphemeralError::Json {
path: display.to_string(),
source: e.to_string(),
})?;
let parsed: crate::serde_json::Value =
crate::serde_json::from_str(&raw).map_err(|e| EphemeralError::Json {
path: display.to_string(),
source: e.to_string(),
})?;
let crate::serde_json::Value::Array(values) = parsed else {
return Err(EphemeralError::JsonShape {
path: display.to_string(),
source: "top-level JSON value must be an array of document objects".to_string(),
});
};
for (idx, value) in values.iter().enumerate() {
if !matches!(value, crate::serde_json::Value::Object(_)) {
return Err(EphemeralError::JsonShape {
path: display.to_string(),
source: format!("element {} is not a JSON object", idx + 1),
});
}
}
self.insert_documents(collection, values, display)
}
fn import_ndjson_into(
&self,
path: &Path,
collection: &str,
display: &str,
) -> Result<usize, EphemeralError> {
let file = std::fs::File::open(path).map_err(|e| EphemeralError::Json {
path: display.to_string(),
source: e.to_string(),
})?;
let mut values = Vec::new();
for (idx, line) in BufReader::new(file).lines().enumerate() {
let line_number = idx + 1;
let line = line.map_err(|e| EphemeralError::Json {
path: display.to_string(),
source: format!("line {line_number}: {e}"),
})?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let value: crate::serde_json::Value =
crate::serde_json::from_str(trimmed).map_err(|e| EphemeralError::Json {
path: display.to_string(),
source: format!("line {line_number}: {e}"),
})?;
if !matches!(value, crate::serde_json::Value::Object(_)) {
return Err(EphemeralError::JsonShape {
path: display.to_string(),
source: format!("line {line_number} is not a JSON object"),
});
}
values.push(value);
}
self.insert_documents(collection, values, display)
}
fn insert_documents(
&self,
collection: &str,
values: Vec<crate::serde_json::Value>,
display: &str,
) -> Result<usize, EphemeralError> {
let rows_imported = values.len();
self.execute_query(&format!("CREATE DOCUMENT {collection}"))
.map_err(|e| EphemeralError::Import {
path: display.to_string(),
source: e.to_string(),
})?;
for value in values {
self.create_document(CreateDocumentInput {
collection: collection.to_string(),
body: value,
metadata: Vec::new(),
node_links: Vec::new(),
vector_links: Vec::new(),
})
.map_err(|e| EphemeralError::Import {
path: display.to_string(),
source: e.to_string(),
})?;
}
Ok(rows_imported)
}
}
fn data_file_spec(path: &Path) -> Result<DataFileSpec, EphemeralError> {
let display = path.display().to_string();
if !path.is_file() {
return Err(EphemeralError::NotAFile { path: display });
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let format =
format_for_extension(&ext).ok_or_else(|| EphemeralError::UnsupportedExtension {
path: display.clone(),
ext: ext.clone(),
})?;
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default();
let collection = sanitize_stem(stem).ok_or_else(|| EphemeralError::EmptyStem {
path: display.clone(),
})?;
Ok(DataFileSpec {
display,
base_collection: collection,
format,
})
}
fn positional_alias(index: usize, total: usize) -> String {
if total == 1 {
POSITIONAL_ALIAS.to_string()
} else {
format!("t{}", index + 1)
}
}
fn unique_collection_name(base: &str, used: &mut BTreeSet<String>) -> String {
if used.insert(base.to_string()) {
return base.to_string();
}
for suffix in 2usize.. {
let candidate = format!("{base}_{suffix}");
if used.insert(candidate.clone()) {
return candidate;
}
}
unreachable!("unbounded suffix search must eventually find an unused collection name")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_stem_basic() {
assert_eq!(sanitize_stem("data").as_deref(), Some("data"));
assert_eq!(sanitize_stem("Users").as_deref(), Some("users"));
}
#[test]
fn sanitize_stem_collapses_and_trims() {
assert_eq!(
sanitize_stem("vendas-2026 (v2)").as_deref(),
Some("vendas_2026_v2")
);
assert_eq!(
sanitize_stem("__weird__name__").as_deref(),
Some("weird_name")
);
}
#[test]
fn sanitize_stem_leading_digit_prefixed() {
assert_eq!(sanitize_stem("2026sales").as_deref(), Some("_2026sales"));
}
#[test]
fn sanitize_stem_all_punctuation_is_none() {
assert_eq!(sanitize_stem("---"), None);
assert_eq!(sanitize_stem(""), None);
}
#[test]
fn delimiter_inference() {
assert_eq!(
format_for_extension("csv"),
Some(EphemeralFormat::Delimited(b','))
);
assert_eq!(
format_for_extension("tsv"),
Some(EphemeralFormat::Delimited(b'\t'))
);
assert_eq!(
format_for_extension("tab"),
Some(EphemeralFormat::Delimited(b'\t'))
);
assert_eq!(
format_for_extension("json"),
Some(EphemeralFormat::JsonArray)
);
assert_eq!(format_for_extension("jsonl"), Some(EphemeralFormat::Ndjson));
assert_eq!(
format_for_extension("ndjson"),
Some(EphemeralFormat::Ndjson)
);
assert_eq!(format_for_extension("txt"), None);
}
}