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