Skip to main content

reddb_server/runtime/
impl_ephemeral.rs

1//! Ephemeral store materialization (PRD #1785, issue #1786).
2//!
3//! The `red` binary can take a local CSV/TSV file plus an RQL query,
4//! materialize the file as a row table inside a throwaway in-memory
5//! embedded store, run the query, and discard the store — no server, no
6//! pre-existing store, nothing durable created.
7//!
8//! This module is the CSV/TSV tracer: the skeleton every other ephemeral
9//! slice (JSON/documents, multi-file, writes/`--save`) extends. It rides
10//! the existing CSV import path (the shared [`CsvImporter`]) so the file
11//! becomes a real row table with header-derived columns and inferred
12//! types.
13//!
14//! Loaded collections are named by their sanitized file stems and by
15//! positional file aliases. A single loaded file also gets the legacy
16//! alias [`POSITIONAL_ALIAS`] (`t`). Multi-file loads get `t1`, `t2`, …
17//! in argument order. When multiple stems sanitize to the same name, the
18//! first keeps the base name and later collisions receive `_2`, `_3`, …
19//! suffixes in argument order; the positional aliases are therefore the
20//! guaranteed-unambiguous handles.
21
22use std::collections::BTreeSet;
23use std::io::{BufRead, BufReader};
24use std::path::Path;
25
26use crate::application::ports::RuntimeEntityPort;
27use crate::application::CreateDocumentInput;
28use crate::runtime::RedDBRuntime;
29use crate::storage::import::{CsvConfig, CsvImporter};
30
31/// Positional alias for the single loaded file: `SELECT … FROM t`.
32pub const POSITIONAL_ALIAS: &str = "t";
33
34/// Outcome of materializing a data file into the ephemeral store.
35#[derive(Debug, Clone)]
36pub struct EphemeralTable {
37    /// Collection name derived from the sanitized file stem.
38    pub collection: String,
39    /// Positional alias (`t`) also addressing the collection.
40    pub alias: String,
41    /// Number of data rows imported (header excluded).
42    pub rows_imported: usize,
43}
44
45struct DataFileSpec {
46    display: String,
47    base_collection: String,
48    format: EphemeralFormat,
49}
50
51/// A didactic error explaining why a file could not be materialized.
52///
53/// Every variant renders to a human-readable, non-panicking message: a
54/// missing, unreadable, unsupported, or malformed file never aborts the
55/// process abnormally.
56#[derive(Debug)]
57pub enum EphemeralError {
58    /// The path does not point at a readable regular file.
59    NotAFile { path: String },
60    /// The extension is not one of the ephemeral data formats.
61    UnsupportedExtension { path: String, ext: String },
62    /// The file stem sanitized to an empty identifier.
63    EmptyStem { path: String },
64    /// The CSV import path rejected the file (I/O or parse failure).
65    Import { path: String, source: String },
66    /// The JSON or NDJSON document parser rejected the file.
67    Json { path: String, source: String },
68    /// A JSON file parsed successfully but was not an array of objects.
69    JsonShape { path: String, source: String },
70}
71
72impl std::fmt::Display for EphemeralError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            EphemeralError::NotAFile { path } => {
76                write!(f, "cannot read data file '{path}': no such file")
77            }
78            EphemeralError::UnsupportedExtension { path, ext } => write!(
79                f,
80                "unsupported data file '{path}': '.{ext}' is not a supported ephemeral data file \
81                 (expected .csv, .tsv, .tab, .json, .jsonl, or .ndjson)"
82            ),
83            EphemeralError::EmptyStem { path } => write!(
84                f,
85                "cannot derive a table name from '{path}': the file stem is empty"
86            ),
87            EphemeralError::Import { path, source } => {
88                write!(f, "failed to load '{path}': {source}")
89            }
90            EphemeralError::Json { path, source } => {
91                write!(f, "failed to parse '{path}': {source}")
92            }
93            EphemeralError::JsonShape { path, source } => {
94                write!(f, "failed to load '{path}': {source}")
95            }
96        }
97    }
98}
99
100impl std::error::Error for EphemeralError {}
101
102/// Sanitize a file stem into a safe collection identifier.
103///
104/// Non-alphanumeric characters collapse to a single `_`; leading/trailing
105/// underscores are trimmed; a leading digit is prefixed with `_` so the
106/// result is always a valid identifier. Returns `None` when nothing
107/// usable survives (e.g. a stem of only punctuation).
108#[must_use]
109pub fn sanitize_stem(stem: &str) -> Option<String> {
110    let mut out = String::with_capacity(stem.len());
111    let mut prev_underscore = false;
112    for ch in stem.chars() {
113        if ch.is_ascii_alphanumeric() {
114            out.push(ch.to_ascii_lowercase());
115            prev_underscore = false;
116        } else if !prev_underscore {
117            out.push('_');
118            prev_underscore = true;
119        }
120    }
121    let trimmed = out.trim_matches('_');
122    if trimmed.is_empty() {
123        return None;
124    }
125    // Identifiers cannot start with a digit.
126    if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
127        Some(format!("_{trimmed}"))
128    } else {
129        Some(trimmed.to_string())
130    }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134enum EphemeralFormat {
135    Delimited(u8),
136    JsonArray,
137    Ndjson,
138}
139
140/// Data format inferred from a data file's extension.
141fn format_for_extension(ext: &str) -> Option<EphemeralFormat> {
142    match ext {
143        "csv" => Some(EphemeralFormat::Delimited(b',')),
144        "tsv" | "tab" => Some(EphemeralFormat::Delimited(b'\t')),
145        "json" => Some(EphemeralFormat::JsonArray),
146        "jsonl" | "ndjson" => Some(EphemeralFormat::Ndjson),
147        _ => None,
148    }
149}
150
151impl RedDBRuntime {
152    /// Materialize a local CSV/TSV file as a row table in this runtime.
153    ///
154    /// The collection is auto-created from the sanitized file stem, and —
155    /// as the single loaded file — is also materialized under the
156    /// positional alias `t` ([`POSITIONAL_ALIAS`]) so it is addressable
157    /// both ways. Intended for the in-memory ephemeral store — nothing
158    /// durable is written beyond what this runtime already persists.
159    pub fn materialize_data_file(&self, path: &Path) -> Result<EphemeralTable, EphemeralError> {
160        let mut tables = self.materialize_data_files(&[path])?;
161        Ok(tables.remove(0))
162    }
163
164    /// Materialize local CSV/TSV files as row tables in this runtime.
165    ///
166    /// Each file is auto-created from its sanitized file stem and from
167    /// its positional alias. In the single-file case the alias is `t`.
168    /// In the multi-file case aliases are `t1`, `t2`, … in argument
169    /// order. Stem collisions are deterministic: the first file keeps
170    /// the sanitized base name, later collisions get `_2`, `_3`, …
171    /// suffixes in argument order.
172    pub fn materialize_data_files(
173        &self,
174        paths: &[&Path],
175    ) -> Result<Vec<EphemeralTable>, EphemeralError> {
176        let specs = paths
177            .iter()
178            .map(|path| data_file_spec(path))
179            .collect::<Result<Vec<_>, _>>()?;
180        let mut used_collections = BTreeSet::new();
181        let mut tables = Vec::with_capacity(specs.len());
182
183        for (index, spec) in specs.iter().enumerate() {
184            let collection = unique_collection_name(&spec.base_collection, &mut used_collections);
185            let alias = positional_alias(index, specs.len());
186            let rows_imported = self.import_data_file(paths[index], &collection, spec)?;
187
188            // Positional aliases are materialized as their own real
189            // collections rather than rewrite views — views leak on
190            // aggregates and other non-trivial shapes, so every query
191            // resolves identically through either name. Skipped when the
192            // stem already sanitized to the alias, which would collide.
193            if alias != collection {
194                self.import_data_file(paths[index], &alias, spec)?;
195            }
196
197            tables.push(EphemeralTable {
198                collection,
199                alias,
200                rows_imported,
201            });
202        }
203
204        Ok(tables)
205    }
206
207    /// Import `path` into `collection` using the importer for the spec's
208    /// inferred format.
209    fn import_data_file(
210        &self,
211        path: &Path,
212        collection: &str,
213        spec: &DataFileSpec,
214    ) -> Result<usize, EphemeralError> {
215        match spec.format {
216            EphemeralFormat::Delimited(delimiter) => {
217                self.import_csv_into(path, collection, delimiter, &spec.display)
218            }
219            EphemeralFormat::JsonArray => {
220                self.import_json_array_into(path, collection, &spec.display)
221            }
222            EphemeralFormat::Ndjson => self.import_ndjson_into(path, collection, &spec.display),
223        }
224    }
225
226    /// Import `path` into `collection` via the shared [`CsvImporter`],
227    /// returning the number of data rows written.
228    fn import_csv_into(
229        &self,
230        path: &Path,
231        collection: &str,
232        delimiter: u8,
233        display: &str,
234    ) -> Result<usize, EphemeralError> {
235        let importer = CsvImporter::new(CsvConfig {
236            collection: collection.to_string(),
237            has_header: true,
238            delimiter,
239            skip_errors: false,
240            ..CsvConfig::default()
241        });
242
243        let store = self.inner.db.store();
244        // The shared CsvImporter writes straight through `store.insert`,
245        // which does not auto-create the collection — provision it up
246        // front the same way the runtime's INSERT path does on first
247        // write.
248        let _ = store.get_or_create_collection(collection);
249        let stats =
250            importer
251                .import_file(path, store.as_ref())
252                .map_err(|e| EphemeralError::Import {
253                    path: display.to_string(),
254                    source: e.to_string(),
255                })?;
256
257        // The rows were written straight through the store, so nudge the
258        // planner/result cache exactly as the COPY path does.
259        self.note_table_write(collection);
260
261        Ok(stats.records_imported)
262    }
263
264    fn import_json_array_into(
265        &self,
266        path: &Path,
267        collection: &str,
268        display: &str,
269    ) -> Result<usize, EphemeralError> {
270        let raw = std::fs::read_to_string(path).map_err(|e| EphemeralError::Json {
271            path: display.to_string(),
272            source: e.to_string(),
273        })?;
274        let parsed: crate::serde_json::Value =
275            crate::serde_json::from_str(&raw).map_err(|e| EphemeralError::Json {
276                path: display.to_string(),
277                source: e.to_string(),
278            })?;
279        let crate::serde_json::Value::Array(values) = parsed else {
280            return Err(EphemeralError::JsonShape {
281                path: display.to_string(),
282                source: "top-level JSON value must be an array of document objects".to_string(),
283            });
284        };
285
286        for (idx, value) in values.iter().enumerate() {
287            if !matches!(value, crate::serde_json::Value::Object(_)) {
288                return Err(EphemeralError::JsonShape {
289                    path: display.to_string(),
290                    source: format!("element {} is not a JSON object", idx + 1),
291                });
292            }
293        }
294
295        self.insert_documents(collection, values, display)
296    }
297
298    fn import_ndjson_into(
299        &self,
300        path: &Path,
301        collection: &str,
302        display: &str,
303    ) -> Result<usize, EphemeralError> {
304        let file = std::fs::File::open(path).map_err(|e| EphemeralError::Json {
305            path: display.to_string(),
306            source: e.to_string(),
307        })?;
308        let mut values = Vec::new();
309        for (idx, line) in BufReader::new(file).lines().enumerate() {
310            let line_number = idx + 1;
311            let line = line.map_err(|e| EphemeralError::Json {
312                path: display.to_string(),
313                source: format!("line {line_number}: {e}"),
314            })?;
315            let trimmed = line.trim();
316            if trimmed.is_empty() {
317                continue;
318            }
319            let value: crate::serde_json::Value =
320                crate::serde_json::from_str(trimmed).map_err(|e| EphemeralError::Json {
321                    path: display.to_string(),
322                    source: format!("line {line_number}: {e}"),
323                })?;
324            if !matches!(value, crate::serde_json::Value::Object(_)) {
325                return Err(EphemeralError::JsonShape {
326                    path: display.to_string(),
327                    source: format!("line {line_number} is not a JSON object"),
328                });
329            }
330            values.push(value);
331        }
332
333        self.insert_documents(collection, values, display)
334    }
335
336    fn insert_documents(
337        &self,
338        collection: &str,
339        values: Vec<crate::serde_json::Value>,
340        display: &str,
341    ) -> Result<usize, EphemeralError> {
342        let rows_imported = values.len();
343        self.execute_query(&format!("CREATE DOCUMENT {collection}"))
344            .map_err(|e| EphemeralError::Import {
345                path: display.to_string(),
346                source: e.to_string(),
347            })?;
348
349        for value in values {
350            self.create_document(CreateDocumentInput {
351                collection: collection.to_string(),
352                body: value,
353                metadata: Vec::new(),
354                node_links: Vec::new(),
355                vector_links: Vec::new(),
356            })
357            .map_err(|e| EphemeralError::Import {
358                path: display.to_string(),
359                source: e.to_string(),
360            })?;
361        }
362
363        Ok(rows_imported)
364    }
365}
366
367fn data_file_spec(path: &Path) -> Result<DataFileSpec, EphemeralError> {
368    let display = path.display().to_string();
369
370    if !path.is_file() {
371        return Err(EphemeralError::NotAFile { path: display });
372    }
373
374    let ext = path
375        .extension()
376        .and_then(|e| e.to_str())
377        .unwrap_or("")
378        .to_ascii_lowercase();
379    let format =
380        format_for_extension(&ext).ok_or_else(|| EphemeralError::UnsupportedExtension {
381            path: display.clone(),
382            ext: ext.clone(),
383        })?;
384
385    let stem = path
386        .file_stem()
387        .and_then(|s| s.to_str())
388        .unwrap_or_default();
389    let collection = sanitize_stem(stem).ok_or_else(|| EphemeralError::EmptyStem {
390        path: display.clone(),
391    })?;
392
393    Ok(DataFileSpec {
394        display,
395        base_collection: collection,
396        format,
397    })
398}
399
400fn positional_alias(index: usize, total: usize) -> String {
401    if total == 1 {
402        POSITIONAL_ALIAS.to_string()
403    } else {
404        format!("t{}", index + 1)
405    }
406}
407
408fn unique_collection_name(base: &str, used: &mut BTreeSet<String>) -> String {
409    if used.insert(base.to_string()) {
410        return base.to_string();
411    }
412    for suffix in 2usize.. {
413        let candidate = format!("{base}_{suffix}");
414        if used.insert(candidate.clone()) {
415            return candidate;
416        }
417    }
418    unreachable!("unbounded suffix search must eventually find an unused collection name")
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn sanitize_stem_basic() {
427        assert_eq!(sanitize_stem("data").as_deref(), Some("data"));
428        assert_eq!(sanitize_stem("Users").as_deref(), Some("users"));
429    }
430
431    #[test]
432    fn sanitize_stem_collapses_and_trims() {
433        assert_eq!(
434            sanitize_stem("vendas-2026 (v2)").as_deref(),
435            Some("vendas_2026_v2")
436        );
437        assert_eq!(
438            sanitize_stem("__weird__name__").as_deref(),
439            Some("weird_name")
440        );
441    }
442
443    #[test]
444    fn sanitize_stem_leading_digit_prefixed() {
445        assert_eq!(sanitize_stem("2026sales").as_deref(), Some("_2026sales"));
446    }
447
448    #[test]
449    fn sanitize_stem_all_punctuation_is_none() {
450        assert_eq!(sanitize_stem("---"), None);
451        assert_eq!(sanitize_stem(""), None);
452    }
453
454    #[test]
455    fn delimiter_inference() {
456        assert_eq!(
457            format_for_extension("csv"),
458            Some(EphemeralFormat::Delimited(b','))
459        );
460        assert_eq!(
461            format_for_extension("tsv"),
462            Some(EphemeralFormat::Delimited(b'\t'))
463        );
464        assert_eq!(
465            format_for_extension("tab"),
466            Some(EphemeralFormat::Delimited(b'\t'))
467        );
468        assert_eq!(
469            format_for_extension("json"),
470            Some(EphemeralFormat::JsonArray)
471        );
472        assert_eq!(format_for_extension("jsonl"), Some(EphemeralFormat::Ndjson));
473        assert_eq!(
474            format_for_extension("ndjson"),
475            Some(EphemeralFormat::Ndjson)
476        );
477        assert_eq!(format_for_extension("txt"), None);
478    }
479}