1use crate::CliError;
39use mushroomdb::{AutoFk, GraphDb, IngestOptions, RealFs, Value, EDGE_COUNT_PROP};
40
41type Db = GraphDb<RealFs>;
42
43type EdgeRow = (String, String, String, Vec<(String, Value)>, u64);
47
48type RelCsvRow = (String, String, String, Vec<(String, Value)>);
50
51type JsonlEdgeRow = (String, String, String, bool, Vec<(String, Value)>);
53use std::collections::{BTreeMap, HashMap, HashSet};
54use std::path::{Path, PathBuf};
55
56pub const DEFAULT_BATCH_SIZE: usize = 50_000;
58
59const MAX_REPORTED_ERRORS: usize = 100;
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct ImportOptions {
65 pub delimiter: u8,
67 pub array_delimiter: char,
70 pub ignore_errors: bool,
72 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
89pub struct ImportReport {
90 pub nodes_inserted: usize,
91 pub edges_inserted: usize,
92 pub rows_skipped: usize,
94 pub errors: Vec<String>,
96 pub warnings: Vec<String>,
98 pub batches: usize,
100 pub rules_created: usize,
102 pub derived_edges_skipped: usize,
106 pub unlabelled_nodes: usize,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct BatchProgress {
114 pub batches_committed: usize,
116 pub nodes_inserted: usize,
117 pub edges_inserted: usize,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum ScalarType {
123 Str,
124 Int,
125 Float,
126 Bool,
127 Json,
130 Date,
134 LocalDateTime,
135 DateTime,
136 Duration,
137 Binary,
138}
139
140impl ScalarType {
141 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub struct ColumnType {
157 pub scalar: ScalarType,
158 pub array: bool,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct PropColumn {
164 pub name: String,
165 pub index: usize,
166 pub ty: ColumnType,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct NodeSchema {
172 pub key_field: String,
175 pub key_stored: bool,
179 pub id_index: usize,
180 pub label_index: usize,
181 pub props: Vec<PropColumn>,
182 pub width: usize,
183 pub warnings: Vec<String>,
185}
186
187#[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
210fn 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 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
350pub 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
387pub 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 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
459pub 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 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
499pub 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
552pub 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
627struct 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 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
672fn 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 auto_fk: AutoFk::Off,
703 };
704
705 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#[derive(Clone)]
764struct NodeRecord {
765 label: String,
766 key: String,
767 props: Vec<(String, Value)>,
768 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
781fn 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 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 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 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#[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 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
1012const TYPE_TAGS: [&str; 5] = ["$date", "$ldt", "$dt", "$dur", "$binary"];
1016
1017pub 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
1061pub const JSONL_PROPS_FIELD: &str = "$props";
1064
1065fn 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
1074fn 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 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
1118fn 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 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
1160fn 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
1244fn 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
1264fn 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
1357pub 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
1376pub 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
1387pub 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 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
1436pub 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
1449pub 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
1483pub 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 #[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 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 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}