Skip to main content

cli/
import.rs

1//! `mushroomdb import`: bulk load of a nodes CSV and a relationships CSV.
2//!
3//! The header conventions are the widely used bulk-import ones:
4//!
5//! * nodes: `:ID` (the node key), `:LABEL`, then property columns;
6//! * relationships: `:START_ID`, `:END_ID`, `:TYPE`, then property columns;
7//! * a property column may carry a type suffix: `age:int`, `weight:float`,
8//!   `active:boolean`, `tags:string[]`. No suffix means a string.
9//!
10//! The data flows through the engine's ordinary batch path
11//! ([`GraphDb::ingest`] for nodes, [`GraphDb::write_batch`] for edges), one
12//! WAL frame and one fsync per batch, so everything the engine guarantees about
13//! a committed batch holds here. What the import adds is what it leaves out:
14//! no auto-declared foreign-key rules ([`AutoFk::Off`]), no per-row dedupe round
15//! trips beyond one key lookup, and a single snapshot at the end.
16//!
17//! # Rules
18//!
19//! The import never declares a rule. Rules the store already has fire on each
20//! batch exactly as they do for any other write, so the final store is the one
21//! incremental ingest of the same rows produces. The fast route is to import
22//! into a store with no rules and declare them afterwards: `create_rule`
23//! backfills in one pass, and the differential test in
24//! `crates/cli/tests/csv_import.rs` pins that the two routes derive the same
25//! edges.
26//!
27//! # Crash safety
28//!
29//! Every batch is one atomic WAL frame, so an import killed part-way leaves a
30//! store that opens, holding every batch that committed and none of the one in
31//! flight. Derived (rule-owned) edges are not in the WAL; they are rebuilt on
32//! open from the base data, so an interrupted import loses no base data and
33//! the derived edges are whatever the committed base data derives. The
34//! snapshot that ends a successful import is written last; an import that dies
35//! before it simply has a longer WAL to replay. The import is not
36//! all-or-nothing: batches committed before an error or a crash stay.
37
38use crate::CliError;
39use mushroomdb::{AutoFk, GraphDb, IngestOptions, RealFs, Value};
40
41type Db = GraphDb<RealFs>;
42use std::collections::{BTreeMap, HashMap, HashSet};
43use std::path::{Path, PathBuf};
44
45/// Default rows per committed batch.
46pub const DEFAULT_BATCH_SIZE: usize = 50_000;
47
48/// How many skipped-row messages a report keeps; the count is always exact.
49const MAX_REPORTED_ERRORS: usize = 100;
50
51/// Options for [`run_import`].
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ImportOptions {
54    /// Field delimiter of both files.
55    pub delimiter: u8,
56    /// Separator between the elements of an array cell (`tags:string[]`), and
57    /// the character that marks a multi-label `:LABEL` cell, which is refused.
58    pub array_delimiter: char,
59    /// Skip a bad row (counted and reported) instead of stopping at it.
60    pub ignore_errors: bool,
61    /// Rows per committed batch.
62    pub batch_size: usize,
63}
64
65impl Default for ImportOptions {
66    fn default() -> Self {
67        Self {
68            delimiter: b',',
69            array_delimiter: ';',
70            ignore_errors: false,
71            batch_size: DEFAULT_BATCH_SIZE,
72        }
73    }
74}
75
76/// What an import did.
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct ImportReport {
79    pub nodes_inserted: usize,
80    pub edges_inserted: usize,
81    /// Rows skipped under `--ignore-errors` (exact).
82    pub rows_skipped: usize,
83    /// The first skipped rows, each `file:line: reason`.
84    pub errors: Vec<String>,
85    /// Header warnings, each `file:line: reason`.
86    pub warnings: Vec<String>,
87    /// WAL batches committed.
88    pub batches: usize,
89    /// Rules created from a rules file.
90    pub rules_created: usize,
91    /// `edges.jsonl` lines marked `"derived": true`. A derived edge belongs to
92    /// the rule that makes it and is made again when the rule is created, so it
93    /// is not inserted.
94    pub derived_edges_skipped: usize,
95    /// CSV node rows with an empty `:LABEL` cell, queued as nodes with no
96    /// label (QL-NM). Reported so a forgotten label does not pass unseen.
97    pub unlabelled_nodes: usize,
98}
99
100/// One committed batch, reported to the callback of [`run_import_with_progress`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct BatchProgress {
103    /// Batches committed so far, this one included.
104    pub batches_committed: usize,
105    pub nodes_inserted: usize,
106    pub edges_inserted: usize,
107}
108
109/// The scalar type of a property column.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum ScalarType {
112    Str,
113    Int,
114    Float,
115    Bool,
116    /// A JSON document in the cell: a list, a map, or a value of any type, in
117    /// the wire forms of `docs/site/http.md` (a date is `{"$date": "…"}`).
118    Json,
119    /// The four temporal types and binary, in the text their tagged form
120    /// carries: `2024-01-15`, `2024-01-15T10:30:00`, `2024-01-15T10:30:00+05:30`,
121    /// `P1DT2H`, and base64.
122    Date,
123    LocalDateTime,
124    DateTime,
125    Duration,
126    Binary,
127}
128
129impl ScalarType {
130    /// The tag of the wire form this type's cell text is the payload of.
131    fn tag(self) -> Option<&'static str> {
132        match self {
133            Self::Date => Some("$date"),
134            Self::LocalDateTime => Some("$ldt"),
135            Self::DateTime => Some("$dt"),
136            Self::Duration => Some("$dur"),
137            Self::Binary => Some("$binary"),
138            _ => None,
139        }
140    }
141}
142
143/// A property column's declared type: a scalar, or an array of it.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct ColumnType {
146    pub scalar: ScalarType,
147    pub array: bool,
148}
149
150/// One property column of a header.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct PropColumn {
153    pub name: String,
154    pub index: usize,
155    pub ty: ColumnType,
156}
157
158/// A parsed nodes-file header.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct NodeSchema {
161    /// The property the node key is also stored under: the `:ID` column's name
162    /// (`personId:ID`), or `id` when the column has none.
163    pub key_field: String,
164    /// False for a `:KEY` column: the cell is the node key and is not also
165    /// stored as a property (an exported store whose nodes do not all carry
166    /// their key as `id` is written this way so that it re-imports exactly).
167    pub key_stored: bool,
168    pub id_index: usize,
169    pub label_index: usize,
170    pub props: Vec<PropColumn>,
171    pub width: usize,
172    /// Non-fatal header findings (an unknown type suffix), without file prefix.
173    pub warnings: Vec<String>,
174}
175
176/// A parsed relationships-file header.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct RelSchema {
179    pub start_index: usize,
180    pub end_index: usize,
181    pub type_index: usize,
182    pub props: Vec<PropColumn>,
183    pub width: usize,
184    pub warnings: Vec<String>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188enum Column {
189    Id(String),
190    Key,
191    Label,
192    StartId,
193    EndId,
194    RelType,
195    Ignore,
196    Prop(PropColumn),
197}
198
199/// Split one header cell into its column meaning.
200fn parse_column(cell: &str, index: usize, warnings: &mut Vec<String>) -> Result<Column, String> {
201    let cell = if index == 0 {
202        cell.trim_start_matches('\u{feff}')
203    } else {
204        cell
205    };
206    let Some(colon) = cell.rfind(':') else {
207        if cell.is_empty() {
208            return Err(format!("column {} has an empty name", index + 1));
209        }
210        return Ok(Column::Prop(PropColumn {
211            name: cell.to_string(),
212            index,
213            ty: ColumnType {
214                scalar: ScalarType::Str,
215                array: false,
216            },
217        }));
218    };
219    let name = &cell[..colon];
220    let suffix = &cell[colon + 1..];
221    let upper = suffix.to_ascii_uppercase();
222    let special = upper.split('(').next().unwrap_or("");
223    // `:ID(Space)` names an id space, so the same id can be a different node
224    // in each. Every id here lives in one key space; reading the column as a
225    // plain `:ID` would refuse the repeats as duplicates, with the wrong reason.
226    if matches!(special, "ID" | "START_ID" | "END_ID") && upper.contains('(') {
227        return Err(format!(
228            "column {} `{cell}`: id spaces are not supported in mushroomdb 0.10; \
229             use `{}` and keep ids unique across the whole file",
230            index + 1,
231            &cell[..colon + 1 + special.len()]
232        ));
233    }
234    match special {
235        "ID" => return Ok(Column::Id(name.to_string())),
236        "KEY" if name.is_empty() => return Ok(Column::Key),
237        "LABEL" if name.is_empty() => return Ok(Column::Label),
238        "START_ID" => return Ok(Column::StartId),
239        "END_ID" => return Ok(Column::EndId),
240        "TYPE" if name.is_empty() => return Ok(Column::RelType),
241        "IGNORE" => return Ok(Column::Ignore),
242        _ => {}
243    }
244    if name.is_empty() {
245        return Err(format!(
246            "column {} `{cell}` has no name before its type",
247            index + 1
248        ));
249    }
250    let (base, array) = match upper.strip_suffix("[]") {
251        Some(b) => (b.to_string(), true),
252        None => (upper.clone(), false),
253    };
254    let scalar = match base.as_str() {
255        "STRING" | "CHAR" | "" => ScalarType::Str,
256        "INT" | "INTEGER" | "LONG" | "SHORT" | "BYTE" => ScalarType::Int,
257        "FLOAT" | "DOUBLE" => ScalarType::Float,
258        "BOOLEAN" | "BOOL" => ScalarType::Bool,
259        "JSON" => ScalarType::Json,
260        "DATE" => ScalarType::Date,
261        "LOCALDATETIME" => ScalarType::LocalDateTime,
262        "DATETIME" => ScalarType::DateTime,
263        "DURATION" => ScalarType::Duration,
264        "BINARY" => ScalarType::Binary,
265        _ => {
266            warnings.push(format!(
267                "column {} `{cell}`: unknown type `{suffix}`, stored as a string",
268                index + 1
269            ));
270            ScalarType::Str
271        }
272    };
273    Ok(Column::Prop(PropColumn {
274        name: name.to_string(),
275        index,
276        ty: ColumnType { scalar, array },
277    }))
278}
279
280struct Columns {
281    id: Vec<(usize, String)>,
282    key: Vec<usize>,
283    label: Vec<usize>,
284    start: Vec<usize>,
285    end: Vec<usize>,
286    rel_type: Vec<usize>,
287    props: Vec<PropColumn>,
288    warnings: Vec<String>,
289}
290
291fn classify_header(record: &csv::StringRecord) -> Result<Columns, String> {
292    let mut cols = Columns {
293        id: Vec::new(),
294        key: Vec::new(),
295        label: Vec::new(),
296        start: Vec::new(),
297        end: Vec::new(),
298        rel_type: Vec::new(),
299        props: Vec::new(),
300        warnings: Vec::new(),
301    };
302    for (index, cell) in record.iter().enumerate() {
303        match parse_column(cell, index, &mut cols.warnings)? {
304            Column::Id(name) => cols.id.push((index, name)),
305            Column::Key => cols.key.push(index),
306            Column::Label => cols.label.push(index),
307            Column::StartId => cols.start.push(index),
308            Column::EndId => cols.end.push(index),
309            Column::RelType => cols.rel_type.push(index),
310            Column::Ignore => {}
311            Column::Prop(p) => cols.props.push(p),
312        }
313    }
314    for p in &cols.props {
315        if p.ty.array && p.ty.scalar == ScalarType::Json {
316            return Err(format!(
317                "property column `{}`: json cannot be an array column; a json cell holds the whole list",
318                p.name
319            ));
320        }
321    }
322    let mut seen = HashSet::new();
323    for p in &cols.props {
324        if !seen.insert(p.name.as_str()) {
325            return Err(format!("property column `{}` appears twice", p.name));
326        }
327    }
328    Ok(cols)
329}
330
331fn exactly_one(found: &[usize], what: &str) -> Result<usize, String> {
332    match found {
333        [one] => Ok(*one),
334        [] => Err(format!("the header has no {what} column")),
335        _ => Err(format!("the header has more than one {what} column")),
336    }
337}
338
339/// Parse a nodes-file header. Needs exactly one `:ID` and one `:LABEL`.
340pub fn parse_node_header(record: &csv::StringRecord) -> Result<NodeSchema, String> {
341    let cols = classify_header(record)?;
342    if !cols.start.is_empty() || !cols.end.is_empty() || !cols.rel_type.is_empty() {
343        return Err(
344            "a nodes file has a relationship column (:START_ID, :END_ID or :TYPE)".to_string(),
345        );
346    }
347    let id_indexes: Vec<usize> = cols
348        .id
349        .iter()
350        .map(|(i, _)| *i)
351        .chain(cols.key.iter().copied())
352        .collect();
353    let id_index = exactly_one(&id_indexes, ":ID")?;
354    let label_index = exactly_one(&cols.label, ":LABEL")?;
355    let key_stored = cols.key.is_empty();
356    let key_field = match cols.id.first().map(|(_, n)| n.as_str()) {
357        Some("") | None => "id".to_string(),
358        Some(name) => name.to_string(),
359    };
360    if key_stored && cols.props.iter().any(|p| p.name == key_field) {
361        return Err(format!(
362            "property column `{key_field}` has the same name as the :ID column"
363        ));
364    }
365    Ok(NodeSchema {
366        key_field,
367        key_stored,
368        id_index,
369        label_index,
370        props: cols.props,
371        width: record.len(),
372        warnings: cols.warnings,
373    })
374}
375
376/// Parse a relationships-file header. Needs exactly one each of `:START_ID`,
377/// `:END_ID` and `:TYPE`.
378pub fn parse_rel_header(record: &csv::StringRecord) -> Result<RelSchema, String> {
379    let cols = classify_header(record)?;
380    if !cols.id.is_empty() || !cols.key.is_empty() || !cols.label.is_empty() {
381        return Err("a relationships file has a node column (:ID or :LABEL)".to_string());
382    }
383    Ok(RelSchema {
384        start_index: exactly_one(&cols.start, ":START_ID")?,
385        end_index: exactly_one(&cols.end, ":END_ID")?,
386        type_index: exactly_one(&cols.rel_type, ":TYPE")?,
387        props: cols.props,
388        width: record.len(),
389        warnings: cols.warnings,
390    })
391}
392
393fn parse_scalar(scalar: ScalarType, text: &str) -> Result<Value, String> {
394    match scalar {
395        ScalarType::Str => Ok(Value::Str(text.to_string())),
396        ScalarType::Int => text
397            .trim()
398            .parse::<i64>()
399            .map(Value::Int)
400            .map_err(|_| format!("`{text}` is not an integer")),
401        ScalarType::Float => match text.trim().parse::<f64>() {
402            Ok(f) if f.is_finite() => Ok(Value::Float(f)),
403            _ => Err(format!("`{text}` is not a finite float")),
404        },
405        ScalarType::Bool => match text.trim().to_ascii_lowercase().as_str() {
406            "true" => Ok(Value::Bool(true)),
407            "false" => Ok(Value::Bool(false)),
408            _ => Err(format!("`{text}` is not a boolean (true or false)")),
409        },
410        ScalarType::Json => {
411            let json: serde_json::Value =
412                serde_json::from_str(text).map_err(|e| format!("`{text}` is not JSON: {e}"))?;
413            json_to_exact_value(json)
414                .and_then(|v| v.ok_or_else(|| "a JSON null is not a value".to_string()))
415        }
416        typed => {
417            let tag = typed.tag().unwrap_or("$date");
418            let mut tagged = serde_json::Map::with_capacity(1);
419            tagged.insert(tag.to_string(), serde_json::Value::String(text.to_string()));
420            mushroomdb::json_to_value(serde_json::Value::Object(tagged))?
421                .ok_or_else(|| format!("`{text}` is not a value"))
422        }
423    }
424}
425
426fn parse_cell(col: &PropColumn, text: &str, array_delimiter: char) -> Result<Value, String> {
427    let located = |e: String| format!("column `{}`: {e}", col.name);
428    if !col.ty.array {
429        return parse_scalar(col.ty.scalar, text).map_err(located);
430    }
431    let mut items = Vec::new();
432    for part in text.split(array_delimiter) {
433        items.push(parse_scalar(col.ty.scalar, part).map_err(located)?);
434    }
435    Ok(Value::List(items))
436}
437
438/// Parse one node row into its label and property map, key included under
439/// `schema.key_field`. An empty property cell is an absent property.
440pub fn parse_node_row(
441    schema: &NodeSchema,
442    record: &csv::StringRecord,
443    array_delimiter: char,
444) -> Result<(String, BTreeMap<String, Value>), String> {
445    if record.len() != schema.width {
446        return Err(format!(
447            "expected {} fields, found {}",
448            schema.width,
449            record.len()
450        ));
451    }
452    let id = &record[schema.id_index];
453    if id.is_empty() {
454        return Err("empty :ID".to_string());
455    }
456    // An empty `:LABEL` cell is a node with no label (QL-NM), which is how
457    // `export` writes one.
458    let label = &record[schema.label_index];
459    if label.contains(array_delimiter) || label.contains(';') {
460        return Err(format!(
461            "`{label}` has more than one label; mushroomdb stores one label per node"
462        ));
463    }
464    let mut row = BTreeMap::new();
465    if schema.key_stored {
466        row.insert(schema.key_field.clone(), Value::Str(id.to_string()));
467    }
468    for col in &schema.props {
469        let text = &record[col.index];
470        if text.is_empty() {
471            continue;
472        }
473        row.insert(col.name.clone(), parse_cell(col, text, array_delimiter)?);
474    }
475    Ok((label.to_string(), row))
476}
477
478/// Parse one relationship row into `(type, start, end)`.
479pub fn parse_rel_row(
480    schema: &RelSchema,
481    record: &csv::StringRecord,
482) -> Result<(String, String, String), String> {
483    if record.len() != schema.width {
484        return Err(format!(
485            "expected {} fields, found {}",
486            schema.width,
487            record.len()
488        ));
489    }
490    let start = &record[schema.start_index];
491    let end = &record[schema.end_index];
492    let edge_type = &record[schema.type_index];
493    if start.is_empty() {
494        return Err("empty :START_ID".to_string());
495    }
496    if end.is_empty() {
497        return Err("empty :END_ID".to_string());
498    }
499    if edge_type.is_empty() {
500        return Err("empty :TYPE".to_string());
501    }
502    Ok((edge_type.to_string(), start.to_string(), end.to_string()))
503}
504
505#[derive(Clone, Copy, PartialEq, Eq)]
506enum Quote {
507    FieldStart,
508    Unquoted,
509    Quoted,
510    Closed,
511}
512
513/// Check that every quote in `input` follows RFC 4180: a quoted field opens at
514/// the start of a field, `""` is a literal quote inside it, and the closing
515/// quote is followed by a delimiter or a line end. Returns the message, with its
516/// line, for the first violation.
517///
518/// The `csv` parser is deliberately lenient: an unterminated quote swallows the
519/// rest of the file into one field and a stray quote is literal text. Either
520/// would load wrong data without a word, and neither can be skipped row by row
521/// because the row boundaries are unknowable, so a file is checked whole before
522/// anything is written and a violation is fatal, `--ignore-errors` or not.
523pub fn check_quoting<R: std::io::Read>(mut input: R, delimiter: u8) -> Result<(), String> {
524    let mut state = Quote::FieldStart;
525    let mut line = 1u64;
526    let mut opened_at = 1u64;
527    let mut buf = vec![0u8; 1 << 20];
528    loop {
529        let n = input.read(&mut buf).map_err(|e| e.to_string())?;
530        if n == 0 {
531            break;
532        }
533        for &b in &buf[..n] {
534            state = match (state, b) {
535                (Quote::FieldStart, b'"') => {
536                    opened_at = line;
537                    Quote::Quoted
538                }
539                (Quote::Unquoted, b'"') => {
540                    return Err(format!("{line}: a quote inside an unquoted field"));
541                }
542                (Quote::Quoted, b'"') => Quote::Closed,
543                (Quote::Closed, b'"') => Quote::Quoted,
544                (Quote::Closed, b'\r') => Quote::Closed,
545                (Quote::Closed, b'\n') => {
546                    line += 1;
547                    Quote::FieldStart
548                }
549                (Quote::Closed, d) if d == delimiter => Quote::FieldStart,
550                (Quote::Closed, _) => {
551                    return Err(format!(
552                        "{line}: text after the closing quote of a quoted field"
553                    ));
554                }
555                (Quote::Quoted, b'\n') => {
556                    line += 1;
557                    Quote::Quoted
558                }
559                (Quote::Quoted, _) => Quote::Quoted,
560                (_, b'\n') => {
561                    line += 1;
562                    Quote::FieldStart
563                }
564                (_, d) if d == delimiter => Quote::FieldStart,
565                (_, _) => Quote::Unquoted,
566            };
567        }
568    }
569    if state == Quote::Quoted {
570        return Err(format!(
571            "{opened_at}: a quoted field opened here is never closed"
572        ));
573    }
574    Ok(())
575}
576
577fn check_file_quoting(path: &Path, opts: &ImportOptions) -> Result<(), CliError> {
578    let file =
579        std::fs::File::open(path).map_err(|e| CliError(format!("{}: {e}", path.display())))?;
580    check_quoting(file, opts.delimiter).map_err(|e| {
581        CliError(format!(
582            "{}:{e}; malformed quoting is fatal because row boundaries cannot be trusted, nothing was imported",
583            path.display()
584        ))
585    })
586}
587
588/// Shared state of one import run.
589struct Run<'a> {
590    opts: &'a ImportOptions,
591    report: ImportReport,
592    on_batch: &'a mut dyn FnMut(&BatchProgress),
593}
594
595impl Run<'_> {
596    fn committed(&mut self) {
597        self.report.batches += 1;
598        let progress = BatchProgress {
599            batches_committed: self.report.batches,
600            nodes_inserted: self.report.nodes_inserted,
601            edges_inserted: self.report.edges_inserted,
602        };
603        (self.on_batch)(&progress);
604    }
605
606    /// A bad row: skip it under `--ignore-errors`, otherwise stop with it.
607    fn bad_row(&mut self, file: &str, line: u64, reason: &str) -> Result<(), CliError> {
608        let message = format!("{file}:{line}: {reason}");
609        if !self.opts.ignore_errors {
610            return Err(CliError(format!(
611                "{message}\nstopped: {} nodes and {} edges in {} committed batches remain in the store \
612                 (an import is not all-or-nothing); rerun with --ignore-errors to skip bad rows",
613                self.report.nodes_inserted, self.report.edges_inserted, self.report.batches
614            )));
615        }
616        self.report.rows_skipped += 1;
617        if self.report.errors.len() < MAX_REPORTED_ERRORS {
618            self.report.errors.push(message);
619        }
620        Ok(())
621    }
622}
623
624fn reader_for(path: &Path, opts: &ImportOptions) -> Result<csv::Reader<std::fs::File>, CliError> {
625    csv::ReaderBuilder::new()
626        .delimiter(opts.delimiter)
627        .has_headers(true)
628        .flexible(true)
629        .from_path(path)
630        .map_err(|e| CliError(format!("{}: {e}", path.display())))
631}
632
633/// The line a csv error happened on, when it knows.
634fn error_line(e: &csv::Error) -> u64 {
635    e.position().map_or(0, csv::Position::line)
636}
637
638fn read_header(
639    reader: &mut csv::Reader<std::fs::File>,
640    file: &str,
641) -> Result<csv::StringRecord, CliError> {
642    match reader.headers() {
643        Ok(h) if h.is_empty() || (h.len() == 1 && h[0].is_empty()) => {
644            Err(CliError(format!("{file}:1: the file has no header row")))
645        }
646        Ok(h) => Ok(h.clone()),
647        Err(e) => Err(CliError(format!("{file}:{}: {e}", error_line(&e).max(1)))),
648    }
649}
650
651fn import_nodes(db: &mut Db, path: &Path, run: &mut Run<'_>) -> Result<(), CliError> {
652    let file = path.display().to_string();
653    let mut reader = reader_for(path, run.opts)?;
654    let header = read_header(&mut reader, &file)?;
655    let schema = parse_node_header(&header).map_err(|e| CliError(format!("{file}:1: {e}")))?;
656    for w in &schema.warnings {
657        run.report.warnings.push(format!("{file}:1: {w}"));
658    }
659    let ingest_opts = IngestOptions {
660        key_field: schema.key_field.clone(),
661        // Foreign-key rules are inferred from the rows; a bulk load declares
662        // nothing the file did not ask for.
663        auto_fk: AutoFk::Off,
664    };
665
666    // TODO(W2 unique constraints): the batch is where a declared unique
667    // constraint on (label, property) would be checked, before `ingest`.
668    let mut batch: HashMap<String, Vec<BTreeMap<String, Value>>> = HashMap::new();
669    let mut records: Vec<NodeRecord> = Vec::new();
670    let mut batch_keys: HashSet<String> = HashSet::new();
671    let mut buffered = 0usize;
672    let mut record = csv::StringRecord::new();
673    loop {
674        match reader.read_record(&mut record) {
675            Ok(false) => break,
676            Ok(true) => {}
677            Err(e) if matches!(e.kind(), csv::ErrorKind::Io(_)) => {
678                return Err(CliError(format!("{file}: {e}")));
679            }
680            Err(e) => {
681                run.bad_row(&file, error_line(&e), &e.to_string())?;
682                continue;
683            }
684        }
685        let line = record.position().map_or(0, csv::Position::line);
686        let (label, row) = match parse_node_row(&schema, &record, run.opts.array_delimiter) {
687            Ok(parsed) => parsed,
688            Err(reason) => {
689                run.bad_row(&file, line, &reason)?;
690                continue;
691            }
692        };
693        let key = record[schema.id_index].to_string();
694        if db.has_node(&key) || !batch_keys.insert(key.clone()) {
695            let reason = format!("duplicate :ID `{key}`");
696            run.bad_row(&file, line, &reason)?;
697            continue;
698        }
699        run.report.unlabelled_nodes += usize::from(label.is_empty());
700        if schema.key_stored {
701            batch.entry(label).or_default().push(row);
702        } else {
703            records.push(NodeRecord {
704                label,
705                key,
706                props: row.into_iter().collect(),
707                line,
708            });
709        }
710        buffered += 1;
711        if buffered >= run.opts.batch_size {
712            flush_nodes(db, &mut batch, &ingest_opts, run)?;
713            flush_node_records(db, &mut records, &file, run)?;
714            batch_keys.clear();
715            buffered = 0;
716        }
717    }
718    flush_nodes(db, &mut batch, &ingest_opts, run)?;
719    flush_node_records(db, &mut records, &file, run)?;
720    Ok(())
721}
722
723/// One node to insert exactly as given: its key is not also a property.
724#[derive(Clone)]
725struct NodeRecord {
726    label: String,
727    key: String,
728    props: Vec<(String, Value)>,
729    /// Line in the file, for messages.
730    line: u64,
731}
732
733fn commit_node_records(db: &mut Db, records: Vec<NodeRecord>) -> mushroomdb::Result<usize> {
734    let (inserted, _) = db.write_batch(|b| {
735        for r in records {
736            b.insert_node(&r.label, &r.key, r.props);
737        }
738    })?;
739    Ok(inserted)
740}
741
742/// Commit `records` as one WAL batch through the engine's ordinary batch path,
743/// the one the CSV edges use. A rejected batch is handled like a rejected edge
744/// batch: strict mode stops with the lines named and nothing of the batch
745/// written; `--ignore-errors` retries the rows one by one and skips the ones
746/// that fail alone.
747fn flush_node_records(
748    db: &mut Db,
749    records: &mut Vec<NodeRecord>,
750    file: &str,
751    run: &mut Run<'_>,
752) -> Result<(), CliError> {
753    if records.is_empty() {
754        return Ok(());
755    }
756    let batch = std::mem::take(records);
757    let first = batch.first().map_or(0, |r| r.line);
758    let last = batch.last().map_or(0, |r| r.line);
759    let count = batch.len();
760    // The rows are only needed again if the batch is rejected and the rows are
761    // to be tried one by one.
762    let retry: Option<Vec<NodeRecord>> = run.opts.ignore_errors.then(|| batch.clone());
763    match commit_node_records(db, batch) {
764        Ok(inserted) => {
765            run.report.nodes_inserted += inserted;
766            run.committed();
767        }
768        Err(batch_err) => {
769            let Some(rows) = retry else {
770                run.bad_row(
771                    file,
772                    first,
773                    &format!(
774                        "the batch of {count} nodes (lines {first}-{last}) was rejected: {batch_err}"
775                    ),
776                )?;
777                return Ok(());
778            };
779            let mut any = false;
780            for one in rows {
781                let line = one.line;
782                match commit_node_records(db, vec![one]) {
783                    Ok(inserted) => {
784                        run.report.nodes_inserted += inserted;
785                        any = true;
786                    }
787                    Err(e) => run.bad_row(file, line, &e.to_string())?,
788                }
789            }
790            if any {
791                run.committed();
792            }
793        }
794    }
795    Ok(())
796}
797
798fn flush_nodes(
799    db: &mut Db,
800    batch: &mut HashMap<String, Vec<BTreeMap<String, Value>>>,
801    ingest_opts: &IngestOptions,
802    run: &mut Run<'_>,
803) -> Result<(), CliError> {
804    let mut labels: Vec<String> = batch.keys().cloned().collect();
805    labels.sort();
806    for label in labels {
807        let rows = batch.remove(&label).unwrap_or_default();
808        if rows.is_empty() {
809            continue;
810        }
811        let report = db.ingest(&label, rows, ingest_opts)?;
812        run.report.nodes_inserted += report.inserted;
813        run.committed();
814    }
815    Ok(())
816}
817
818fn import_rels(db: &mut Db, path: &Path, run: &mut Run<'_>) -> Result<(), CliError> {
819    let file = path.display().to_string();
820    let mut reader = reader_for(path, run.opts)?;
821    let header = read_header(&mut reader, &file)?;
822    let schema = parse_rel_header(&header).map_err(|e| CliError(format!("{file}:1: {e}")))?;
823    if let Some(p) = schema.props.first() {
824        return Err(CliError(format!(
825            "{file}:1: relationship property column `{}` is not supported in mushroomdb 0.10: \
826             edges carry no properties; remove the column",
827            p.name
828        )));
829    }
830    for w in &schema.warnings {
831        run.report.warnings.push(format!("{file}:1: {w}"));
832    }
833
834    let mut batch: Vec<(String, String, String, u64)> = Vec::new();
835    let mut record = csv::StringRecord::new();
836    loop {
837        match reader.read_record(&mut record) {
838            Ok(false) => break,
839            Ok(true) => {}
840            Err(e) if matches!(e.kind(), csv::ErrorKind::Io(_)) => {
841                return Err(CliError(format!("{file}: {e}")));
842            }
843            Err(e) => {
844                run.bad_row(&file, error_line(&e), &e.to_string())?;
845                continue;
846            }
847        }
848        let line = record.position().map_or(0, csv::Position::line);
849        let (edge_type, start, end) = match parse_rel_row(&schema, &record) {
850            Ok(parsed) => parsed,
851            Err(reason) => {
852                run.bad_row(&file, line, &reason)?;
853                continue;
854            }
855        };
856        let missing = [(":START_ID", &start), (":END_ID", &end)]
857            .into_iter()
858            .find(|(_, id)| !db.has_node(id));
859        if let Some((column, id)) = missing {
860            let reason = format!("{column} `{id}` is not a node in the nodes file or the store");
861            run.bad_row(&file, line, &reason)?;
862            continue;
863        }
864        batch.push((edge_type, start, end, line));
865        if batch.len() >= run.opts.batch_size {
866            flush_edges(db, &mut batch, &file, run)?;
867        }
868    }
869    flush_edges(db, &mut batch, &file, run)?;
870    Ok(())
871}
872
873fn commit_edges(db: &mut Db, edges: &[(String, String, String, u64)]) -> mushroomdb::Result<usize> {
874    let (_, inserted) = db.write_batch(|b| {
875        for (edge_type, start, end, _) in edges {
876            b.insert_edge(edge_type, start, end);
877        }
878    })?;
879    Ok(inserted)
880}
881
882fn flush_edges(
883    db: &mut Db,
884    batch: &mut Vec<(String, String, String, u64)>,
885    file: &str,
886    run: &mut Run<'_>,
887) -> Result<(), CliError> {
888    if batch.is_empty() {
889        return Ok(());
890    }
891    let edges = std::mem::take(batch);
892    match commit_edges(db, &edges) {
893        Ok(inserted) => {
894            run.report.edges_inserted += inserted;
895            run.committed();
896        }
897        Err(batch_err) => {
898            let first = edges.first().map_or(0, |e| e.3);
899            let last = edges.last().map_or(0, |e| e.3);
900            if !run.opts.ignore_errors {
901                // The engine rejected the batch whole and nothing of it was
902                // committed; strict mode does not go looking for which row.
903                run.bad_row(
904                    file,
905                    first,
906                    &format!(
907                        "the batch of {} relationships (lines {first}-{last}) was rejected: \
908                         {batch_err}",
909                        edges.len()
910                    ),
911                )?;
912                return Ok(());
913            }
914            // Under `--ignore-errors`, find the rows that fail alone, skip them,
915            // and count what the rest add as one batch.
916            let mut any = false;
917            for edge in &edges {
918                match commit_edges(db, std::slice::from_ref(edge)) {
919                    Ok(inserted) => {
920                        run.report.edges_inserted += inserted;
921                        any = true;
922                    }
923                    Err(e) => run.bad_row(file, edge.3, &e.to_string())?,
924                }
925            }
926            if any {
927                run.committed();
928            }
929        }
930    }
931    Ok(())
932}
933
934/// The files one import reads. Any of them may be absent; at least one is
935/// needed. They load in this order: nodes (CSV, then JSONL), edges (CSV, then
936/// JSONL), rules. A JSONL path of `-` reads standard input.
937#[derive(Debug, Clone, Default, PartialEq, Eq)]
938pub struct ImportInputs {
939    pub nodes_csv: Option<PathBuf>,
940    pub rels_csv: Option<PathBuf>,
941    pub nodes_jsonl: Option<PathBuf>,
942    pub edges_jsonl: Option<PathBuf>,
943    pub rules_jsonl: Option<PathBuf>,
944    /// One stream of `{"type": "node" | "edge" | "rule", …}` lines, which
945    /// `export --format jsonl --single-file` writes.
946    pub stream_jsonl: Option<PathBuf>,
947}
948
949impl ImportInputs {
950    fn is_empty(&self) -> bool {
951        self.nodes_csv.is_none()
952            && self.rels_csv.is_none()
953            && self.nodes_jsonl.is_none()
954            && self.edges_jsonl.is_none()
955            && self.rules_jsonl.is_none()
956            && self.stream_jsonl.is_none()
957    }
958
959    fn csv_paths(&self) -> impl Iterator<Item = &PathBuf> {
960        self.nodes_csv.iter().chain(self.rels_csv.iter())
961    }
962
963    fn jsonl_paths(&self) -> impl Iterator<Item = &PathBuf> {
964        self.nodes_jsonl
965            .iter()
966            .chain(self.edges_jsonl.iter())
967            .chain(self.rules_jsonl.iter())
968            .chain(self.stream_jsonl.iter())
969    }
970}
971
972fn is_stdin(path: &Path) -> bool {
973    path.as_os_str() == "-"
974}
975
976// ── JSON lines ───────────────────────────────────────────────────────────
977
978/// The five type tags of the typed JSON forms (`docs/site/http.md`).
979const TYPE_TAGS: [&str; 5] = ["$date", "$ldt", "$dt", "$dur", "$binary"];
980
981/// A JSON value as a stored [`Value`], keeping a float a float.
982///
983/// [`mushroomdb::json_to_value`] reads an integral number such as `3.0` as an
984/// integer, which is right for hand-written JSON and wrong for the file an
985/// export wrote: `Float(3.0)` must come back as a float. A JSON number with a
986/// fraction or exponent is a float here, one without is an integer. A one-key
987/// object whose key is a type tag is the typed value, read by the engine's own
988/// reader; `null` is an absent value, and inside a list it drops the whole list,
989/// as in `json_to_value`.
990pub fn json_to_exact_value(v: serde_json::Value) -> Result<Option<Value>, String> {
991    use serde_json::Value as Json;
992    Ok(match v {
993        Json::Null => None,
994        Json::Bool(b) => Some(Value::Bool(b)),
995        Json::Number(n) => Some(match n.as_i64() {
996            Some(i) => Value::Int(i),
997            None => Value::Float(n.as_f64().ok_or("the number is out of range")?),
998        }),
999        Json::String(s) => Some(Value::Str(s)),
1000        Json::Array(items) => {
1001            let mut out = Vec::with_capacity(items.len());
1002            for (i, item) in items.into_iter().enumerate() {
1003                match json_to_exact_value(item).map_err(|e| format!("item {i}: {e}"))? {
1004                    Some(v) => out.push(v),
1005                    None => return Ok(None),
1006                }
1007            }
1008            Some(Value::List(out))
1009        }
1010        Json::Object(obj) => {
1011            if obj.len() == 1 && obj.keys().all(|k| TYPE_TAGS.contains(&k.as_str())) {
1012                return mushroomdb::json_to_value(Json::Object(obj));
1013            }
1014            let mut map = BTreeMap::new();
1015            for (k, v) in obj {
1016                if let Some(val) = json_to_exact_value(v).map_err(|e| format!("key {k:?}: {e}"))? {
1017                    map.insert(k, val);
1018                }
1019            }
1020            Some(Value::Map(map))
1021        }
1022    })
1023}
1024
1025/// The property of a `nodes.jsonl` line that holds the properties whose names
1026/// the line itself uses (`key`, `label`, and this one).
1027pub const JSONL_PROPS_FIELD: &str = "$props";
1028
1029/// Parse one `nodes.jsonl` line: `{"key": …, "label": …, <properties>}`.
1030/// Properties whose name is `key`, `label` or `$props` are in a `$props`
1031/// object, which is how the export writes them.
1032fn parse_node_line(text: &str) -> Result<NodeRecord, String> {
1033    let json: serde_json::Value =
1034        serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
1035    parse_node_value(json, false)
1036}
1037
1038/// [`parse_node_line`] for a line already parsed. In a stream (`typed`) the
1039/// `"type"` field is the line's discriminator, not a property (a property of
1040/// that name is in `$props`).
1041fn parse_node_value(json: serde_json::Value, typed: bool) -> Result<NodeRecord, String> {
1042    let serde_json::Value::Object(mut obj) = json else {
1043        return Err("a node line must be a JSON object".to_string());
1044    };
1045    if typed {
1046        obj.remove("type");
1047    }
1048    let mut text_field = |name: &str| match obj.remove(name) {
1049        Some(serde_json::Value::String(s)) if !s.is_empty() => Ok(s),
1050        Some(_) => Err(format!("`{name}` must be a non-empty string")),
1051        None => Err(format!("the line has no `{name}`")),
1052    };
1053    let key = text_field("key")?;
1054    // An empty `label` is a node with no label (QL-NM), which is how
1055    // `export` writes one.
1056    let label = match obj.remove("label") {
1057        Some(serde_json::Value::String(s)) => s,
1058        Some(_) => return Err("`label` must be a string".to_string()),
1059        None => return Err("the line has no `label`".to_string()),
1060    };
1061    let mut props: BTreeMap<String, Value> = BTreeMap::new();
1062    let reserved = match obj.remove(JSONL_PROPS_FIELD) {
1063        None => serde_json::Map::new(),
1064        Some(serde_json::Value::Object(m)) => m,
1065        Some(_) => return Err(format!("`{JSONL_PROPS_FIELD}` must be an object")),
1066    };
1067    for (name, value) in obj.into_iter().chain(reserved) {
1068        if let Some(v) =
1069            json_to_exact_value(value).map_err(|e| format!("property {name:?}: {e}"))?
1070        {
1071            props.insert(name, v);
1072        }
1073    }
1074    Ok(NodeRecord {
1075        label,
1076        key,
1077        props: props.into_iter().collect(),
1078        line: 0,
1079    })
1080}
1081
1082/// One `edges.jsonl` line: `(edge_type, src, dst, derived)`.
1083fn parse_edge_line(text: &str) -> Result<(String, String, String, bool), String> {
1084    let json: serde_json::Value =
1085        serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
1086    parse_edge_value(&json)
1087}
1088
1089fn parse_edge_value(json: &serde_json::Value) -> Result<(String, String, String, bool), String> {
1090    let serde_json::Value::Object(obj) = json else {
1091        return Err("an edge line must be a JSON object".to_string());
1092    };
1093    let field = |name: &str| match obj.get(name) {
1094        Some(serde_json::Value::String(s)) if !s.is_empty() => Ok(s.clone()),
1095        Some(_) => Err(format!("`{name}` must be a non-empty string")),
1096        None => Err(format!("the line has no `{name}`")),
1097    };
1098    let derived = matches!(obj.get("derived"), Some(serde_json::Value::Bool(true)));
1099    Ok((field("edge_type")?, field("src")?, field("dst")?, derived))
1100}
1101
1102/// A line source over a file, or standard input for `-`. Reports each non-blank
1103/// line with its 1-based number to `each`.
1104fn for_each_line(
1105    path: &Path,
1106    mut each: impl FnMut(u64, &str) -> Result<(), CliError>,
1107) -> Result<(), CliError> {
1108    use std::io::BufRead;
1109    let name = path.display().to_string();
1110    let mut reader: Box<dyn BufRead> = if is_stdin(path) {
1111        Box::new(std::io::BufReader::new(std::io::stdin().lock()))
1112    } else {
1113        let file = std::fs::File::open(path).map_err(|e| CliError(format!("{name}: {e}")))?;
1114        Box::new(std::io::BufReader::with_capacity(1 << 20, file))
1115    };
1116    let mut buf = String::new();
1117    let mut line = 0u64;
1118    loop {
1119        buf.clear();
1120        match reader.read_line(&mut buf) {
1121            Ok(0) => return Ok(()),
1122            Ok(_) => {}
1123            Err(e) => return Err(CliError(format!("{name}:{}: {e}", line + 1))),
1124        }
1125        line += 1;
1126        let text = buf.trim_end_matches(['\n', '\r']);
1127        if text.trim().is_empty() {
1128            continue;
1129        }
1130        each(line, text)?;
1131    }
1132}
1133
1134fn import_nodes_jsonl(db: &mut Db, path: &Path, run: &mut Run<'_>) -> Result<(), CliError> {
1135    let file = path.display().to_string();
1136    let mut records: Vec<NodeRecord> = Vec::new();
1137    let mut batch_keys: HashSet<String> = HashSet::new();
1138    for_each_line(path, |line, text| {
1139        let mut record = match parse_node_line(text) {
1140            Ok(r) => r,
1141            Err(reason) => return run.bad_row(&file, line, &reason),
1142        };
1143        record.line = line;
1144        if db.has_node(&record.key) || !batch_keys.insert(record.key.clone()) {
1145            let reason = format!("duplicate key `{}`", record.key);
1146            return run.bad_row(&file, line, &reason);
1147        }
1148        records.push(record);
1149        if records.len() >= run.opts.batch_size {
1150            flush_node_records(db, &mut records, &file, run)?;
1151            batch_keys.clear();
1152        }
1153        Ok(())
1154    })?;
1155    flush_node_records(db, &mut records, &file, run)
1156}
1157
1158fn import_edges_jsonl(db: &mut Db, path: &Path, run: &mut Run<'_>) -> Result<(), CliError> {
1159    let file = path.display().to_string();
1160    let mut batch: Vec<(String, String, String, u64)> = Vec::new();
1161    for_each_line(path, |line, text| {
1162        let (edge_type, src, dst, derived) = match parse_edge_line(text) {
1163            Ok(parsed) => parsed,
1164            Err(reason) => return run.bad_row(&file, line, &reason),
1165        };
1166        if derived {
1167            run.report.derived_edges_skipped += 1;
1168            return Ok(());
1169        }
1170        let missing = [("src", &src), ("dst", &dst)]
1171            .into_iter()
1172            .find(|(_, key)| !db.has_node(key));
1173        if let Some((field, key)) = missing {
1174            let reason = format!("{field} `{key}` is not a node in the nodes file or the store");
1175            return run.bad_row(&file, line, &reason);
1176        }
1177        batch.push((edge_type, src, dst, line));
1178        if batch.len() >= run.opts.batch_size {
1179            flush_edges(db, &mut batch, &file, run)?;
1180        }
1181        Ok(())
1182    })?;
1183    flush_edges(db, &mut batch, &file, run)
1184}
1185
1186/// Create the rules of a `rules.jsonl`, each in its own commit, after the
1187/// nodes and edges are in: the rule backfills once over all of them.
1188fn import_rules_jsonl(db: &mut Db, path: &Path, run: &mut Run<'_>) -> Result<(), CliError> {
1189    let file = path.display().to_string();
1190    for_each_line(path, |line, text| {
1191        let def: mushroomdb::RuleDef = match serde_json::from_str(text) {
1192            Ok(def) => def,
1193            Err(e) => return run.bad_row(&file, line, &format!("not a rule: {e}")),
1194        };
1195        match db.create_rule(def) {
1196            Ok(()) => {
1197                run.report.rules_created += 1;
1198                run.committed();
1199                Ok(())
1200            }
1201            Err(e) => run.bad_row(&file, line, &e.to_string()),
1202        }
1203    })
1204}
1205
1206/// Import a single stream of typed lines (`export --format jsonl
1207/// --single-file`): `{"type":"node",…}`, `{"type":"edge",…}` and
1208/// `{"type":"rule","rule":{…}}`.
1209///
1210/// Lines are taken in order and nodes and edges are committed in batches as
1211/// they arrive. An edge must come after both its nodes: one whose node is not
1212/// in the store or earlier in the stream is an error at its line (a node still
1213/// waiting in the open batch counts, and is committed first). Rules are created
1214/// last, so each backfills once over everything.
1215fn import_stream_jsonl(db: &mut Db, path: &Path, run: &mut Run<'_>) -> Result<(), CliError> {
1216    let file = path.display().to_string();
1217    let mut records: Vec<NodeRecord> = Vec::new();
1218    let mut batch_keys: HashSet<String> = HashSet::new();
1219    let mut edges: Vec<(String, String, String, u64)> = Vec::new();
1220    let mut rules: Vec<(u64, mushroomdb::RuleDef)> = Vec::new();
1221    for_each_line(path, |line, text| {
1222        let json: serde_json::Value = match serde_json::from_str(text) {
1223            Ok(v) => v,
1224            Err(e) => return run.bad_row(&file, line, &format!("not JSON: {e}")),
1225        };
1226        let kind = json.get("type").and_then(serde_json::Value::as_str);
1227        match kind {
1228            Some("node") => {
1229                let mut record = match parse_node_value(json, true) {
1230                    Ok(r) => r,
1231                    Err(reason) => return run.bad_row(&file, line, &reason),
1232                };
1233                record.line = line;
1234                if db.has_node(&record.key) || !batch_keys.insert(record.key.clone()) {
1235                    let reason = format!("duplicate key `{}`", record.key);
1236                    return run.bad_row(&file, line, &reason);
1237                }
1238                records.push(record);
1239                if records.len() >= run.opts.batch_size {
1240                    flush_node_records(db, &mut records, &file, run)?;
1241                    batch_keys.clear();
1242                }
1243            }
1244            Some("edge") => {
1245                let (edge_type, src, dst, derived) = match parse_edge_value(&json) {
1246                    Ok(parsed) => parsed,
1247                    Err(reason) => return run.bad_row(&file, line, &reason),
1248                };
1249                if derived {
1250                    run.report.derived_edges_skipped += 1;
1251                    return Ok(());
1252                }
1253                if [&src, &dst].iter().any(|k| batch_keys.contains(*k)) {
1254                    flush_node_records(db, &mut records, &file, run)?;
1255                    batch_keys.clear();
1256                }
1257                let missing = [("src", &src), ("dst", &dst)]
1258                    .into_iter()
1259                    .find(|(_, key)| !db.has_node(key));
1260                if let Some((field, key)) = missing {
1261                    let reason = format!(
1262                        "{field} `{key}` is not a node earlier in the stream or in the store \
1263                         (an edge must come after its nodes)"
1264                    );
1265                    return run.bad_row(&file, line, &reason);
1266                }
1267                edges.push((edge_type, src, dst, line));
1268                if edges.len() >= run.opts.batch_size {
1269                    flush_edges(db, &mut edges, &file, run)?;
1270                }
1271            }
1272            Some("rule") => match json.get("rule").cloned().map(serde_json::from_value) {
1273                Some(Ok(def)) => rules.push((line, def)),
1274                Some(Err(e)) => return run.bad_row(&file, line, &format!("not a rule: {e}")),
1275                None => return run.bad_row(&file, line, "the line has no `rule`"),
1276            },
1277            Some(other) => {
1278                let reason = format!("unknown line type `{other}` (node, edge or rule)");
1279                return run.bad_row(&file, line, &reason);
1280            }
1281            None => return run.bad_row(&file, line, "the line has no string `type`"),
1282        }
1283        Ok(())
1284    })?;
1285    flush_node_records(db, &mut records, &file, run)?;
1286    flush_edges(db, &mut edges, &file, run)?;
1287    for (line, def) in rules {
1288        match db.create_rule(def) {
1289            Ok(()) => {
1290                run.report.rules_created += 1;
1291                run.committed();
1292            }
1293            Err(e) => run.bad_row(&file, line, &e.to_string())?,
1294        }
1295    }
1296    Ok(())
1297}
1298
1299/// Import `nodes_csv` and then `rels_csv` into the store at `db_dir`
1300/// (created if absent). At least one file is required.
1301pub fn run_import(
1302    db_dir: &Path,
1303    nodes_csv: Option<&Path>,
1304    rels_csv: Option<&Path>,
1305    opts: &ImportOptions,
1306) -> Result<ImportReport, CliError> {
1307    run_import_with_progress(db_dir, nodes_csv, rels_csv, opts, &mut |_| {})
1308}
1309
1310fn csv_inputs(nodes_csv: Option<&Path>, rels_csv: Option<&Path>) -> ImportInputs {
1311    ImportInputs {
1312        nodes_csv: nodes_csv.map(Path::to_path_buf),
1313        rels_csv: rels_csv.map(Path::to_path_buf),
1314        ..ImportInputs::default()
1315    }
1316}
1317
1318/// [`run_import`], calling `on_batch` after every batch that committed.
1319pub fn run_import_with_progress(
1320    db_dir: &Path,
1321    nodes_csv: Option<&Path>,
1322    rels_csv: Option<&Path>,
1323    opts: &ImportOptions,
1324    on_batch: &mut dyn FnMut(&BatchProgress),
1325) -> Result<ImportReport, CliError> {
1326    run_import_inputs(db_dir, &csv_inputs(nodes_csv, rels_csv), opts, on_batch)
1327}
1328
1329/// Import every file of `inputs` into the store at `db_dir` (created if
1330/// absent), then take one snapshot.
1331pub fn run_import_inputs(
1332    db_dir: &Path,
1333    inputs: &ImportInputs,
1334    opts: &ImportOptions,
1335    on_batch: &mut dyn FnMut(&BatchProgress),
1336) -> Result<ImportReport, CliError> {
1337    check_inputs(inputs, opts)?;
1338    let mut db = GraphDb::open(db_dir)?;
1339    let report = import_inputs_into(&mut db, inputs, opts, on_batch)?;
1340    // One snapshot, last. History-preserving, like every snapshot the engine
1341    // takes on its own: a bulk load must not erase how the store got here.
1342    // Nothing committed (a header-only file) means nothing to fold in.
1343    if report.batches > 0 {
1344        db.snapshot_with(crate::AUTOMATIC_SNAPSHOT)?;
1345    }
1346    Ok(report)
1347}
1348
1349fn check_inputs(inputs: &ImportInputs, opts: &ImportOptions) -> Result<(), CliError> {
1350    if inputs.is_empty() {
1351        return Err(CliError(
1352            "import needs --nodes, --relationships, --nodes-jsonl, --edges-jsonl, --rules-jsonl or --jsonl"
1353                .into(),
1354        ));
1355    }
1356    if opts.batch_size == 0 {
1357        return Err(CliError("--batch-size must be at least 1".into()));
1358    }
1359    let files = inputs
1360        .csv_paths()
1361        .chain(inputs.jsonl_paths().filter(|p| !is_stdin(p)));
1362    for path in files {
1363        if !path.is_file() {
1364            return Err(CliError(format!("{}: no such file", path.display())));
1365        }
1366    }
1367    if inputs.jsonl_paths().filter(|p| is_stdin(p)).count() > 1 {
1368        return Err(CliError(
1369            "only one of the JSONL inputs can be - (standard input)".into(),
1370        ));
1371    }
1372    for path in inputs.csv_paths() {
1373        check_file_quoting(path, opts)?;
1374    }
1375    Ok(())
1376}
1377
1378/// The import itself, on a store the caller holds open and takes the snapshot
1379/// of: [`run_import_with_progress`] is this plus the open and the snapshot.
1380/// Public so a test can read the store while the importing handle is live.
1381pub fn import_into(
1382    db: &mut GraphDb<RealFs>,
1383    nodes_csv: Option<&Path>,
1384    rels_csv: Option<&Path>,
1385    opts: &ImportOptions,
1386    on_batch: &mut dyn FnMut(&BatchProgress),
1387) -> Result<ImportReport, CliError> {
1388    import_inputs_into(db, &csv_inputs(nodes_csv, rels_csv), opts, on_batch)
1389}
1390
1391/// [`import_into`] for every kind of input file.
1392pub fn import_inputs_into(
1393    db: &mut GraphDb<RealFs>,
1394    inputs: &ImportInputs,
1395    opts: &ImportOptions,
1396    on_batch: &mut dyn FnMut(&BatchProgress),
1397) -> Result<ImportReport, CliError> {
1398    check_inputs(inputs, opts)?;
1399    let mut run = Run {
1400        opts,
1401        report: ImportReport::default(),
1402        on_batch,
1403    };
1404    if let Some(path) = &inputs.nodes_csv {
1405        import_nodes(db, path, &mut run)?;
1406    }
1407    if let Some(path) = &inputs.nodes_jsonl {
1408        import_nodes_jsonl(db, path, &mut run)?;
1409    }
1410    if let Some(path) = &inputs.rels_csv {
1411        import_rels(db, path, &mut run)?;
1412    }
1413    if let Some(path) = &inputs.edges_jsonl {
1414        import_edges_jsonl(db, path, &mut run)?;
1415    }
1416    if let Some(path) = &inputs.stream_jsonl {
1417        import_stream_jsonl(db, path, &mut run)?;
1418    }
1419    if let Some(path) = &inputs.rules_jsonl {
1420        import_rules_jsonl(db, path, &mut run)?;
1421    }
1422    Ok(run.report)
1423}
1424
1425/// The summary `mushroomdb import` prints.
1426pub fn format_import(report: &ImportReport) -> String {
1427    let mut out = format!(
1428        "imported {} nodes and {} edges in {} batches",
1429        report.nodes_inserted, report.edges_inserted, report.batches
1430    );
1431    if report.rules_created > 0 {
1432        out.push_str(&format!(", created {} rules", report.rules_created));
1433    }
1434    if report.derived_edges_skipped > 0 {
1435        out.push_str(&format!(
1436            ", left {} derived edges to their rules",
1437            report.derived_edges_skipped
1438        ));
1439    }
1440    if report.rows_skipped > 0 {
1441        out.push_str(&format!(", skipped {} bad rows", report.rows_skipped));
1442    }
1443    if report.unlabelled_nodes > 0 {
1444        out.push_str(&format!(
1445            ", {} nodes with no label (an empty :LABEL cell)",
1446            report.unlabelled_nodes
1447        ));
1448    }
1449    out.push('\n');
1450    out
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455    use super::*;
1456
1457    fn rec(cells: &[&str]) -> csv::StringRecord {
1458        csv::StringRecord::from(cells.to_vec())
1459    }
1460
1461    #[test]
1462    fn csv_import_header_types_and_special_columns() {
1463        let s = parse_node_header(&rec(&[
1464            "personId:ID",
1465            ":LABEL",
1466            "name",
1467            "age:int",
1468            "weight:float",
1469            "active:boolean",
1470            "tags:string[]",
1471        ]))
1472        .unwrap();
1473        assert_eq!(s.key_field, "personId");
1474        assert_eq!((s.id_index, s.label_index), (0, 1));
1475        let tys: Vec<_> = s.props.iter().map(|p| (p.name.as_str(), p.ty)).collect();
1476        let t = |scalar, array| ColumnType { scalar, array };
1477        assert_eq!(
1478            tys,
1479            vec![
1480                ("name", t(ScalarType::Str, false)),
1481                ("age", t(ScalarType::Int, false)),
1482                ("weight", t(ScalarType::Float, false)),
1483                ("active", t(ScalarType::Bool, false)),
1484                ("tags", t(ScalarType::Str, true)),
1485            ]
1486        );
1487        assert!(s.warnings.is_empty());
1488    }
1489
1490    #[test]
1491    fn csv_import_unnamed_id_column_keys_as_id() {
1492        let s = parse_node_header(&rec(&[":ID", ":LABEL"])).unwrap();
1493        assert_eq!(s.key_field, "id");
1494    }
1495
1496    #[test]
1497    fn csv_import_id_spaces_are_refused_clearly() {
1498        for header in [
1499            rec(&["personId:ID(People)", ":LABEL"]),
1500            rec(&[":ID(People)", ":LABEL"]),
1501        ] {
1502            let e = parse_node_header(&header).unwrap_err();
1503            assert!(
1504                e.contains("id spaces are not supported in mushroomdb 0.10"),
1505                "{e}"
1506            );
1507        }
1508        for header in [
1509            rec(&[":START_ID(People)", ":END_ID", ":TYPE"]),
1510            rec(&[":START_ID", ":END_ID(People)", ":TYPE"]),
1511        ] {
1512            let e = parse_rel_header(&header).unwrap_err();
1513            assert!(
1514                e.contains("id spaces are not supported in mushroomdb 0.10"),
1515                "{e}"
1516            );
1517        }
1518    }
1519
1520    #[test]
1521    fn csv_import_missing_id_is_a_header_error() {
1522        let e = parse_node_header(&rec(&[":LABEL", "name"])).unwrap_err();
1523        assert!(e.contains(":ID"), "{e}");
1524        let e = parse_node_header(&rec(&[":ID", "name"])).unwrap_err();
1525        assert!(e.contains(":LABEL"), "{e}");
1526    }
1527
1528    #[test]
1529    fn csv_import_unknown_type_suffix_warns_and_is_a_string() {
1530        let s = parse_node_header(&rec(&[":ID", ":LABEL", "born:uuid"])).unwrap();
1531        assert_eq!(s.props[0].ty.scalar, ScalarType::Str);
1532        assert_eq!(s.warnings.len(), 1);
1533        assert!(s.warnings[0].contains("born:uuid"), "{:?}", s.warnings);
1534    }
1535
1536    #[test]
1537    fn csv_import_duplicate_property_columns_are_refused() {
1538        let e = parse_node_header(&rec(&[":ID", ":LABEL", "a", "a:int"])).unwrap_err();
1539        assert!(e.contains("twice"), "{e}");
1540        let e = parse_node_header(&rec(&["id:ID", ":LABEL", "id"])).unwrap_err();
1541        assert!(e.contains("same name"), "{e}");
1542    }
1543
1544    #[test]
1545    fn csv_import_relationship_header() {
1546        let s = parse_rel_header(&rec(&[":START_ID", ":END_ID", ":TYPE"])).unwrap();
1547        assert_eq!((s.start_index, s.end_index, s.type_index), (0, 1, 2));
1548        let e = parse_rel_header(&rec(&[":START_ID", ":TYPE"])).unwrap_err();
1549        assert!(e.contains(":END_ID"), "{e}");
1550        let e = parse_rel_header(&rec(&[":ID", ":START_ID", ":END_ID", ":TYPE"])).unwrap_err();
1551        assert!(e.contains("node column"), "{e}");
1552    }
1553
1554    type ParsedRow = Result<(String, BTreeMap<String, Value>), String>;
1555
1556    fn parse_one(csv_text: &str) -> Vec<ParsedRow> {
1557        let mut r = csv::ReaderBuilder::new().from_reader(csv_text.as_bytes());
1558        let schema = parse_node_header(&r.headers().unwrap().clone()).unwrap();
1559        r.records()
1560            .map(|rec| parse_node_row(&schema, &rec.unwrap(), ';'))
1561            .collect()
1562    }
1563
1564    #[test]
1565    fn csv_import_quoted_fields_keep_embedded_delimiters_and_newlines() {
1566        let rows = parse_one("n:ID,:LABEL,name\n1,\"PERSON\",\"last, first\"\n2,P,\"a\nb\"\n");
1567        let (label, row) = rows[0].clone().unwrap();
1568        assert_eq!(label, "PERSON");
1569        assert_eq!(row["name"], Value::Str("last, first".into()));
1570        assert_eq!(
1571            rows[1].clone().unwrap().1["name"],
1572            Value::Str("a\nb".into())
1573        );
1574    }
1575
1576    #[test]
1577    fn csv_import_multi_label_row_is_an_error_not_a_panic() {
1578        let rows = parse_one(":ID,:LABEL\n1,Person;Employee\n");
1579        let e = rows[0].clone().unwrap_err();
1580        assert!(e.contains("more than one label"), "{e}");
1581    }
1582
1583    #[test]
1584    fn the_summary_names_the_nodes_with_no_label() {
1585        let report = ImportReport {
1586            nodes_inserted: 3,
1587            unlabelled_nodes: 2,
1588            ..ImportReport::default()
1589        };
1590        let text = format_import(&report);
1591        assert!(
1592            text.contains("2 nodes with no label (an empty :LABEL cell)"),
1593            "{text}"
1594        );
1595        assert!(!format_import(&ImportReport::default()).contains("no label"));
1596    }
1597
1598    #[test]
1599    fn csv_import_empty_id_is_an_error_and_an_empty_label_is_no_label() {
1600        let rows = parse_one(":ID,:LABEL\n1,\n,P\n");
1601        assert_eq!(
1602            rows[0].clone().unwrap().0,
1603            "",
1604            "a node with no label (QL-NM)"
1605        );
1606        assert!(rows[1].clone().unwrap_err().contains(":ID"));
1607    }
1608
1609    #[test]
1610    fn csv_import_values_are_typed_and_empty_cells_are_absent() {
1611        let rows = parse_one(
1612            ":ID,:LABEL,age:int,w:float,ok:boolean,tags:string[],note\n\
1613             a,P,42,1.5,TRUE,x;y;z,\n\
1614             b,P,,,,,\n",
1615        );
1616        let (_, a) = rows[0].clone().unwrap();
1617        assert_eq!(a["age"], Value::Int(42));
1618        assert_eq!(a["w"], Value::Float(1.5));
1619        assert_eq!(a["ok"], Value::Bool(true));
1620        assert_eq!(
1621            a["tags"],
1622            Value::List(
1623                vec!["x".into(), "y".into(), "z".into()]
1624                    .into_iter()
1625                    .map(Value::Str)
1626                    .collect()
1627            )
1628        );
1629        assert!(!a.contains_key("note"));
1630        let (_, b) = rows[1].clone().unwrap();
1631        assert_eq!(b.len(), 1, "only the key: {b:?}");
1632    }
1633
1634    fn quoting(text: &str) -> Result<(), String> {
1635        check_quoting(text.as_bytes(), b',')
1636    }
1637
1638    #[test]
1639    fn csv_import_quoting_check_accepts_rfc_4180() {
1640        quoting("a,b\n\"x,y\",\"say \"\"hi\"\"\"\n\"multi\nline\",z\r\n").unwrap();
1641        quoting("").unwrap();
1642        quoting("a,\"\"\n").unwrap();
1643    }
1644
1645    #[test]
1646    fn csv_import_quoting_check_names_the_line() {
1647        let e = quoting("a,b\n1,\"never closed\n2,x\n").unwrap_err();
1648        assert!(e.starts_with("2:") && e.contains("never closed"), "{e}");
1649        let e = quoting("a,b\n1,ab\"c\n").unwrap_err();
1650        assert!(e.starts_with("2:") && e.contains("unquoted"), "{e}");
1651        let e = quoting("a\n\"x\"y\n").unwrap_err();
1652        assert!(e.starts_with("2:") && e.contains("closing quote"), "{e}");
1653        let e = quoting("a\n\"x\ny\"z\n").unwrap_err();
1654        assert!(
1655            e.starts_with("3:"),
1656            "line counts newlines inside a quoted field: {e}"
1657        );
1658    }
1659
1660    #[test]
1661    fn csv_import_bad_typed_values_name_the_column() {
1662        let rows = parse_one(":ID,:LABEL,age:int,ok:boolean\n1,P,old,true\n2,P,3,maybe\n");
1663        let e = rows[0].clone().unwrap_err();
1664        assert!(e.contains("age") && e.contains("old"), "{e}");
1665        let e = rows[1].clone().unwrap_err();
1666        assert!(e.contains("ok") && e.contains("maybe"), "{e}");
1667    }
1668
1669    #[test]
1670    fn jsonl_import_node_line_reads_tagged_forms_and_reserved_names() {
1671        let line = r#"{"key":"k","label":"L","born":{"$date":"2024-01-15"},"blob":{"$binary":"3q2+7w=="},"f":2.0,"$props":{"key":"x","label":1,"$props":true}}"#;
1672        let r = parse_node_line(line).unwrap();
1673        let props: BTreeMap<_, _> = r.props.into_iter().collect();
1674        assert_eq!((r.key.as_str(), r.label.as_str()), ("k", "L"));
1675        assert_eq!(props["born"], Value::Date(19_737));
1676        assert_eq!(props["blob"], Value::Binary(vec![0xDE, 0xAD, 0xBE, 0xEF]));
1677        assert_eq!(props["f"], Value::Float(2.0));
1678        assert_eq!(props["key"], Value::Str("x".into()));
1679        assert_eq!(props["label"], Value::Int(1));
1680        assert_eq!(props["$props"], Value::Bool(true));
1681    }
1682
1683    #[test]
1684    fn jsonl_import_node_line_refuses_bad_shapes() {
1685        for (line, why) in [
1686            ("[]", "must be a JSON object"),
1687            (r#"{"label":"L"}"#, "no `key`"),
1688            (r#"{"key":"","label":"L"}"#, "non-empty"),
1689            (r#"{"key":"k","label":"L","$props":3}"#, "must be an object"),
1690            (
1691                r#"{"key":"k","label":"L","d":{"$date":"nope"}}"#,
1692                "property \"d\"",
1693            ),
1694        ] {
1695            let e = parse_node_line(line).err().unwrap_or_default();
1696            assert!(e.contains(why), "{line}: {e}");
1697        }
1698    }
1699
1700    #[test]
1701    fn csv_import_typed_columns_and_key_column() {
1702        let schema = parse_node_header(&rec(&[
1703            ":KEY",
1704            ":LABEL",
1705            "d:date",
1706            "t:datetime",
1707            "u:duration",
1708            "b:binary",
1709            "j:json",
1710            "a:b:string",
1711        ]))
1712        .unwrap();
1713        assert!(!schema.key_stored);
1714        let row = rec(&[
1715            "k",
1716            "L",
1717            "2024-01-15",
1718            "2024-01-15T12:34:56.789012+05:30",
1719            "P1Y2M3DT4H5M6.789S",
1720            "3q2+7w==",
1721            r#"[1.0,{"$date":"2024-01-15"}]"#,
1722            "x",
1723        ]);
1724        let (_, props) = parse_node_row(&schema, &row, ';').unwrap();
1725        assert!(!props.contains_key("id"));
1726        assert_eq!(props["d"], Value::Date(19_737));
1727        assert!(matches!(
1728            props["t"],
1729            Value::DateTime {
1730                offset_seconds: 19_800,
1731                ..
1732            }
1733        ));
1734        assert!(matches!(
1735            props["u"],
1736            Value::Duration {
1737                months: 14,
1738                days: 3,
1739                ..
1740            }
1741        ));
1742        assert_eq!(props["b"], Value::Binary(vec![0xDE, 0xAD, 0xBE, 0xEF]));
1743        assert_eq!(
1744            props["j"],
1745            Value::List(vec![Value::Float(1.0), Value::Date(19_737)])
1746        );
1747        assert_eq!(props["a:b"], Value::Str("x".into()));
1748        let bad = rec(&["k", "L", "not a date", "", "", "", "", ""]);
1749        let e = parse_node_row(&schema, &bad, ';').unwrap_err();
1750        assert!(e.contains("column `d`"), "{e}");
1751        assert!(parse_node_header(&rec(&[":ID", ":KEY", ":LABEL"])).is_err());
1752    }
1753}