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