use mushroomdb::{ExportEdge, NodeInfo, RuleDef, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use arrow_array::{ArrayRef, BooleanArray, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
use parquet::arrow::ArrowWriter;
use parquet::file::properties::WriterProperties;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExportFormat {
Jsonl,
JsonlSingle,
Csv,
Cypher,
Parquet,
Graphml,
}
impl ExportFormat {
pub fn parse(s: &str) -> Option<Self> {
match s {
"jsonl" => Some(Self::Jsonl),
"csv" => Some(Self::Csv),
"cypher" => Some(Self::Cypher),
"parquet" => Some(Self::Parquet),
"graphml" => Some(Self::Graphml),
_ => None,
}
}
pub fn name(&self) -> &'static str {
match self {
Self::Jsonl => "jsonl",
Self::JsonlSingle => "jsonl",
Self::Csv => "csv",
Self::Cypher => "cypher",
Self::Parquet => "parquet",
Self::Graphml => "graphml",
}
}
}
fn value_to_json(v: &Value) -> serde_json::Value {
match v {
Value::Int(i) => serde_json::Value::Number((*i).into()),
Value::Float(f) => serde_json::Number::from_f64(*f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
Value::Str(s) => serde_json::Value::String(s.clone()),
Value::Bool(b) => serde_json::Value::Bool(*b),
Value::List(xs) => serde_json::Value::Array(xs.iter().map(value_to_json).collect()),
Value::Map(m) => serde_json::Value::Object(
m.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect(),
),
Value::Date(_)
| Value::LocalDateTime(_)
| Value::DateTime { .. }
| Value::Duration { .. }
| Value::Binary(_) => mushroomdb::tagged_json(v)
.ok()
.flatten()
.unwrap_or(serde_json::Value::Null),
Value::Null => serde_json::Value::Null,
}
}
pub trait ExportSource {
fn for_each_node(
&self,
each: &mut dyn FnMut(NodeInfo) -> Result<(), crate::CliError>,
) -> Result<(), crate::CliError>;
fn for_each_edge(
&self,
each: &mut dyn FnMut(ExportEdge) -> Result<(), crate::CliError>,
) -> Result<(), crate::CliError>;
fn label_of(&self, key: &str) -> Option<String>;
}
impl ExportSource for mushroomdb::GraphDb<mushroomdb::RealFs> {
fn for_each_node(
&self,
each: &mut dyn FnMut(NodeInfo) -> Result<(), crate::CliError>,
) -> Result<(), crate::CliError> {
self.for_each_node_for_export(each)
}
fn for_each_edge(
&self,
each: &mut dyn FnMut(ExportEdge) -> Result<(), crate::CliError>,
) -> Result<(), crate::CliError> {
self.for_each_edge_for_export(each)
}
fn label_of(&self, key: &str) -> Option<String> {
self.node_ref(key).map(|n| n.label().to_string())
}
}
pub struct Slices<'a> {
pub nodes: &'a [NodeInfo],
pub edges: &'a [ExportEdge],
}
impl ExportSource for Slices<'_> {
fn for_each_node(
&self,
each: &mut dyn FnMut(NodeInfo) -> Result<(), crate::CliError>,
) -> Result<(), crate::CliError> {
self.nodes.iter().try_for_each(|n| each(n.clone()))
}
fn for_each_edge(
&self,
each: &mut dyn FnMut(ExportEdge) -> Result<(), crate::CliError>,
) -> Result<(), crate::CliError> {
self.edges.iter().try_for_each(|e| each(e.clone()))
}
fn label_of(&self, key: &str) -> Option<String> {
self.nodes
.iter()
.find(|n| n.key == key)
.map(|n| n.label.clone())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExportStats {
pub nodes: usize,
pub edges: usize,
pub derived_edges: usize,
pub non_finite_floats: usize,
}
fn count_non_finite(v: &Value) -> usize {
match v {
Value::Float(f) => usize::from(!f.is_finite()),
Value::List(xs) => xs.iter().map(count_non_finite).sum(),
Value::Map(m) => m.values().map(count_non_finite).sum(),
_ => 0,
}
}
fn json_line(w: &mut impl std::io::Write, v: &serde_json::Value) -> Result<(), crate::CliError> {
serde_json::to_writer(&mut *w, v)
.map_err(|e| crate::CliError(format!("json encode error: {e}")))?;
w.write_all(b"\n")?;
Ok(())
}
fn node_json(node: &NodeInfo, typed: bool) -> serde_json::Value {
let mut obj = serde_json::Map::new();
obj.insert("key".into(), serde_json::Value::String(node.key.clone()));
obj.insert(
"label".into(),
serde_json::Value::String(node.label.clone()),
);
let mut reserved = serde_json::Map::new();
for (k, v) in &node.props {
let own = matches!(k.as_str(), "key" | "label") || k == crate::import::JSONL_PROPS_FIELD;
if own || (typed && k == "type") {
reserved.insert(k.clone(), value_to_json(v));
} else {
obj.insert(k.clone(), value_to_json(v));
}
}
if !reserved.is_empty() {
obj.insert(
crate::import::JSONL_PROPS_FIELD.into(),
serde_json::Value::Object(reserved),
);
}
serde_json::Value::Object(obj)
}
fn edge_json(edge: &ExportEdge) -> serde_json::Value {
let mut obj = serde_json::Map::new();
obj.insert("edge_type".into(), edge.edge_type.clone().into());
obj.insert("src".into(), edge.src.clone().into());
obj.insert("dst".into(), edge.dst.clone().into());
obj.insert("derived".into(), edge.derived.into());
obj.insert(
"rule".into(),
edge.rule
.clone()
.map_or(serde_json::Value::Null, Into::into),
);
serde_json::Value::Object(obj)
}
fn typed_json_line(
w: &mut impl std::io::Write,
kind: &str,
value: &serde_json::Value,
) -> Result<(), crate::CliError> {
let text = serde_json::to_string(value)
.map_err(|e| crate::CliError(format!("json encode error: {e}")))?;
let rest = text.strip_prefix('{').unwrap_or(&text);
let comma = if rest == "}" { "" } else { "," };
writeln!(w, "{{\"type\":\"{kind}\"{comma}{rest}")?;
Ok(())
}
fn create_file(path: PathBuf) -> Result<std::io::BufWriter<std::fs::File>, crate::CliError> {
Ok(std::io::BufWriter::with_capacity(
1 << 20,
std::fs::File::create(path)?,
))
}
pub fn write_jsonl(
source: &dyn ExportSource,
rules: &[RuleDef],
dest: &Path,
) -> Result<ExportStats, crate::CliError> {
std::fs::create_dir_all(dest)?;
let mut stats = ExportStats::default();
let mut f = create_file(dest.join("nodes.jsonl"))?;
source.for_each_node(&mut |node| {
stats.non_finite_floats += node.props.values().map(count_non_finite).sum::<usize>();
stats.nodes += 1;
json_line(&mut f, &node_json(&node, false))
})?;
f.flush()?;
let mut f = create_file(dest.join("edges.jsonl"))?;
source.for_each_edge(&mut |edge| {
stats.edges += 1;
stats.derived_edges += usize::from(edge.derived);
json_line(&mut f, &edge_json(&edge))
})?;
f.flush()?;
write_rules_jsonl(rules, dest)?;
Ok(stats)
}
pub fn write_jsonl_single(
source: &dyn ExportSource,
rules: &[RuleDef],
dest: &Path,
) -> Result<ExportStats, crate::CliError> {
std::fs::create_dir_all(dest)?;
let mut stats = ExportStats::default();
let mut f = create_file(dest.join("graph.jsonl"))?;
source.for_each_node(&mut |node| {
stats.non_finite_floats += node.props.values().map(count_non_finite).sum::<usize>();
stats.nodes += 1;
typed_json_line(&mut f, "node", &node_json(&node, true))
})?;
source.for_each_edge(&mut |edge| {
stats.edges += 1;
stats.derived_edges += usize::from(edge.derived);
typed_json_line(&mut f, "edge", &edge_json(&edge))
})?;
for rule in rules {
let line = serde_json::json!({ "rule": rule });
typed_json_line(&mut f, "rule", &line)?;
}
f.flush()?;
Ok(stats)
}
fn write_rules_jsonl(rules: &[RuleDef], dest: &Path) -> Result<(), crate::CliError> {
let mut f = std::fs::File::create(dest.join("rules.jsonl"))?;
for rule in rules {
let line = serde_json::to_string(rule)
.map_err(|e| crate::CliError(format!("json encode rule: {e}")))?;
writeln!(f, "{line}")?;
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CsvKind {
Str,
Int,
Float,
Bool,
Date,
LocalDateTime,
DateTime,
Duration,
Binary,
Json,
}
impl CsvKind {
fn suffix(self) -> &'static str {
match self {
Self::Str => "string",
Self::Int => "int",
Self::Float => "float",
Self::Bool => "boolean",
Self::Date => "date",
Self::LocalDateTime => "localdatetime",
Self::DateTime => "datetime",
Self::Duration => "duration",
Self::Binary => "binary",
Self::Json => "json",
}
}
fn of(v: &Value) -> Self {
match v {
Value::Str(s) if s.is_empty() => Self::Json,
Value::Str(_) => Self::Str,
Value::Int(_) => Self::Int,
Value::Float(f) if f.is_finite() => Self::Float,
Value::Bool(_) => Self::Bool,
Value::Date(_) => Self::Date,
Value::LocalDateTime(_) => Self::LocalDateTime,
Value::DateTime { .. } => Self::DateTime,
Value::Duration { .. } => Self::Duration,
Value::Binary(_) => Self::Binary,
Value::Float(_) | Value::List(_) | Value::Map(_) | Value::Null => Self::Json,
}
}
}
fn tagged_text_of(v: &Value) -> String {
match mushroomdb::tagged_json(v) {
Ok(Some(serde_json::Value::Object(obj))) => obj
.into_iter()
.next()
.and_then(|(_, text)| text.as_str().map(str::to_string))
.unwrap_or_default(),
_ => String::new(),
}
}
fn csv_cell(v: &Value, kind: CsvKind) -> String {
match (kind, v) {
(CsvKind::Str, Value::Str(s)) => s.clone(),
(CsvKind::Int, Value::Int(i)) => i.to_string(),
(CsvKind::Float, Value::Float(f)) => format!("{f:?}"),
(CsvKind::Bool, Value::Bool(b)) => b.to_string(),
(CsvKind::Json, v) => serde_json::to_string(&value_to_json(v)).unwrap_or_default(),
(_, v) => tagged_text_of(v),
}
}
fn csv_error(e: csv::Error) -> crate::CliError {
crate::CliError(format!("csv write error: {e}"))
}
pub fn write_csv(
source: &dyn ExportSource,
rules: &[RuleDef],
dest: &Path,
) -> Result<ExportStats, crate::CliError> {
std::fs::create_dir_all(dest)?;
let mut stats = ExportStats::default();
let mut id_is_key = true;
let mut kinds: BTreeMap<String, CsvKind> = BTreeMap::new();
source.for_each_node(&mut |node| {
if node.label.contains(';') {
return Err(crate::CliError(format!(
"label `{}` of node `{}` cannot be written to CSV: the importer reads `;` as a multi-label",
node.label, node.key
)));
}
stats.nodes += 1;
id_is_key &= matches!(node.props.get("id"), Some(Value::Str(s)) if *s == node.key);
for (name, value) in &node.props {
if name.is_empty() {
return Err(crate::CliError(format!(
"node `{}` has a property with an empty name, which a CSV header cannot carry",
node.key
)));
}
stats.non_finite_floats += count_non_finite(value);
let kind = CsvKind::of(value);
match kinds.get_mut(name.as_str()) {
Some(k) if *k != kind => *k = CsvKind::Json,
Some(_) => {}
None => {
kinds.insert(name.clone(), kind);
}
}
}
Ok(())
})?;
let id_is_key = id_is_key && stats.nodes > 0;
if id_is_key {
kinds.remove("id");
}
let mut w = csv::Writer::from_path(dest.join("nodes.csv")).map_err(csv_error)?;
let mut header = vec![
if id_is_key { ":ID" } else { ":KEY" }.to_string(),
":LABEL".to_string(),
];
header.extend(kinds.iter().map(|(n, k)| format!("{n}:{}", k.suffix())));
w.write_record(&header).map_err(csv_error)?;
source.for_each_node(&mut |node| {
let mut row = vec![node.key.clone(), node.label.clone()];
for (name, kind) in &kinds {
row.push(
node.props
.get(name.as_str())
.map(|v| csv_cell(v, *kind))
.unwrap_or_default(),
);
}
w.write_record(&row).map_err(csv_error)
})?;
w.flush()?;
let mut w = csv::Writer::from_path(dest.join("relationships.csv")).map_err(csv_error)?;
w.write_record([":START_ID", ":END_ID", ":TYPE"])
.map_err(csv_error)?;
source.for_each_edge(&mut |edge| {
stats.edges += 1;
stats.derived_edges += usize::from(edge.derived);
if edge.derived {
return Ok(());
}
w.write_record([&edge.src, &edge.dst, &edge.edge_type])
.map_err(csv_error)
})?;
w.flush()?;
if !rules.is_empty() {
write_rules_jsonl(rules, dest)?;
}
Ok(stats)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CypherLoss {
pub typed_properties: usize,
pub unwritable_numbers: usize,
pub differing_ids: usize,
pub ids_added: usize,
}
impl CypherLoss {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
}
fn cypher_label(label: &str) -> String {
if label.is_empty() {
String::new()
} else {
format!(":{}", cypher_ident(label))
}
}
fn cypher_ident(name: &str) -> String {
format!("`{}`", name.replace('`', "``"))
}
fn cypher_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\'' => out.push_str("\\'"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 || c as u32 == 0x7f => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('\'');
out
}
fn cypher_literal(v: &Value) -> Result<String, CypherSkip> {
Ok(match v {
Value::Str(s) => cypher_string(s),
Value::Int(i64::MIN) => return Err(CypherSkip::Number),
Value::Int(i) => i.to_string(),
Value::Float(f) if !f.is_finite() => return Err(CypherSkip::Number),
Value::Float(f) => {
let text = format!("{f}");
if text.contains('.') {
text
} else {
format!("{text}.0")
}
}
Value::Bool(b) => b.to_string(),
Value::List(xs) => {
let items: Result<Vec<String>, _> = xs.iter().map(cypher_literal).collect();
format!("[{}]", items?.join(", "))
}
Value::Map(m) => {
let mut items = Vec::with_capacity(m.len());
for (k, v) in m {
if k.contains(['\n', '\r']) {
return Err(CypherSkip::Number);
}
items.push(format!("{}: {}", cypher_ident(k), cypher_literal(v)?));
}
format!("{{{}}}", items.join(", "))
}
Value::Date(_)
| Value::LocalDateTime(_)
| Value::DateTime { .. }
| Value::Duration { .. }
| Value::Binary(_) => return Err(CypherSkip::Typed),
Value::Null => return Err(CypherSkip::Number),
})
}
enum CypherSkip {
Typed,
Number,
}
pub fn write_cypher(
source: &dyn ExportSource,
dest: &Path,
) -> Result<(ExportStats, CypherLoss), crate::CliError> {
std::fs::create_dir_all(dest)?;
let mut out = create_file(dest.join("graph.cypher"))?;
let mut loss = CypherLoss::default();
let mut stats = ExportStats::default();
source.for_each_node(&mut |node| {
stats.nodes += 1;
let mut sets = Vec::new();
let mut has_id = false;
for (name, value) in &node.props {
if name == "id" {
has_id = true;
if !matches!(value, Value::Str(s) if *s == node.key) {
loss.differing_ids += 1;
}
continue;
}
if name.contains(['\n', '\r']) {
loss.unwritable_numbers += 1;
continue;
}
match cypher_literal(value) {
Ok(lit) => sets.push(format!("n.{} = {lit}", cypher_ident(name))),
Err(CypherSkip::Typed) => loss.typed_properties += 1,
Err(CypherSkip::Number) => loss.unwritable_numbers += 1,
}
}
loss.ids_added += usize::from(!has_id);
write!(
out,
"MERGE (n{} {{id: {}}})",
cypher_label(&node.label),
cypher_string(&node.key)
)?;
if !sets.is_empty() {
write!(out, " ON CREATE SET {}", sets.join(", "))?;
}
writeln!(out)?;
Ok(())
})?;
source.for_each_edge(&mut |edge| {
stats.edges += 1;
stats.derived_edges += usize::from(edge.derived);
if edge.derived {
return Ok(());
}
let (Some(src_label), Some(dst_label)) =
(source.label_of(&edge.src), source.label_of(&edge.dst))
else {
return Ok(());
};
writeln!(
out,
"MERGE (a{} {{id: {}}})-[:{}]->(b{} {{id: {}}})",
cypher_label(&src_label),
cypher_string(&edge.src),
cypher_ident(&edge.edge_type),
cypher_label(&dst_label),
cypher_string(&edge.dst)
)?;
Ok(())
})?;
out.flush()?;
Ok((stats, loss))
}
pub fn write_parquet(
nodes: &[NodeInfo],
edges: &[ExportEdge],
rules: &[RuleDef],
dest: &Path,
) -> Result<(), crate::CliError> {
std::fs::create_dir_all(dest)?;
let props = WriterProperties::builder().build();
{
let schema = Arc::new(Schema::new(vec![
Field::new("key", DataType::Utf8, false),
Field::new("label", DataType::Utf8, false),
Field::new("props", DataType::Utf8, false),
]));
let mut keys = Vec::new();
let mut labels = Vec::new();
let mut props_json = Vec::new();
for node in nodes {
keys.push(node.key.clone());
labels.push(node.label.clone());
let p: serde_json::Map<String, serde_json::Value> = node
.props
.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect();
props_json.push(
serde_json::to_string(&serde_json::Value::Object(p))
.map_err(|e| crate::CliError(format!("json encode: {e}")))?,
);
}
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(StringArray::from(keys)) as ArrayRef,
Arc::new(StringArray::from(labels)) as ArrayRef,
Arc::new(StringArray::from(props_json)) as ArrayRef,
],
)
.map_err(|e| crate::CliError(format!("arrow error: {e}")))?;
let file = std::fs::File::create(dest.join("nodes.parquet"))?;
let mut writer = ArrowWriter::try_new(file, schema, Some(props.clone()))
.map_err(|e| crate::CliError(format!("parquet writer: {e}")))?;
writer
.write(&batch)
.map_err(|e| crate::CliError(format!("parquet write: {e}")))?;
writer
.close()
.map_err(|e| crate::CliError(format!("parquet close: {e}")))?;
}
{
let schema = Arc::new(Schema::new(vec![
Field::new("edge_type", DataType::Utf8, false),
Field::new("src", DataType::Utf8, false),
Field::new("dst", DataType::Utf8, false),
Field::new("derived", DataType::Boolean, false),
Field::new("rule", DataType::Utf8, true),
]));
let mut etypes = Vec::new();
let mut srcs = Vec::new();
let mut dsts = Vec::new();
let mut deriveds = Vec::new();
let mut rule_names: Vec<Option<String>> = Vec::new();
for edge in edges {
etypes.push(edge.edge_type.clone());
srcs.push(edge.src.clone());
dsts.push(edge.dst.clone());
deriveds.push(edge.derived);
rule_names.push(edge.rule.clone());
}
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(StringArray::from(etypes)) as ArrayRef,
Arc::new(StringArray::from(srcs)) as ArrayRef,
Arc::new(StringArray::from(dsts)) as ArrayRef,
Arc::new(BooleanArray::from(deriveds)) as ArrayRef,
Arc::new(StringArray::from(rule_names)) as ArrayRef,
],
)
.map_err(|e| crate::CliError(format!("arrow error: {e}")))?;
let file = std::fs::File::create(dest.join("edges.parquet"))?;
let mut writer = ArrowWriter::try_new(file, schema, Some(props.clone()))
.map_err(|e| crate::CliError(format!("parquet writer: {e}")))?;
writer
.write(&batch)
.map_err(|e| crate::CliError(format!("parquet write: {e}")))?;
writer
.close()
.map_err(|e| crate::CliError(format!("parquet close: {e}")))?;
}
{
let schema = Arc::new(Schema::new(vec![
Field::new("name", DataType::Utf8, false),
Field::new("definition", DataType::Utf8, false),
]));
let mut names = Vec::new();
let mut definitions = Vec::new();
for rule in rules {
names.push(rule.name.clone());
definitions.push(
serde_json::to_string(rule)
.map_err(|e| crate::CliError(format!("json encode: {e}")))?,
);
}
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(StringArray::from(names)) as ArrayRef,
Arc::new(StringArray::from(definitions)) as ArrayRef,
],
)
.map_err(|e| crate::CliError(format!("arrow error: {e}")))?;
let file = std::fs::File::create(dest.join("rules.parquet"))?;
let mut writer = ArrowWriter::try_new(file, schema, Some(props))
.map_err(|e| crate::CliError(format!("parquet writer: {e}")))?;
writer
.write(&batch)
.map_err(|e| crate::CliError(format!("parquet write: {e}")))?;
writer
.close()
.map_err(|e| crate::CliError(format!("parquet close: {e}")))?;
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum GmlType {
Boolean,
Long,
Double,
String,
}
impl GmlType {
fn as_str(self) -> &'static str {
match self {
Self::Boolean => "boolean",
Self::Long => "long",
Self::Double => "double",
Self::String => "string",
}
}
}
fn value_gml_type(v: &Value) -> GmlType {
match v {
Value::Int(_) => GmlType::Long,
Value::Float(_) => GmlType::Double,
Value::Bool(_) => GmlType::Boolean,
Value::Str(_) | Value::List(_) | Value::Map(_) => GmlType::String,
Value::Date(_)
| Value::LocalDateTime(_)
| Value::DateTime { .. }
| Value::Duration { .. }
| Value::Binary(_) => GmlType::String,
Value::Null => GmlType::String,
}
}
fn value_gml_text(v: &Value) -> String {
match value_to_json(v) {
serde_json::Value::Null => String::new(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => s,
other => other.to_string(),
}
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
'\u{9}' | '\u{A}' | '\u{D}' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' => {
out.push(c)
}
_ => {} }
}
out
}
fn sanitize_id_component(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for (i, c) in s.chars().enumerate() {
let ok = if i == 0 {
c.is_ascii_alphabetic() || c == '_'
} else {
c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-'
};
out.push(if ok { c } else { '_' });
}
if out.is_empty() {
out.push('_');
}
out
}
fn unique_id(base: String, used: &mut BTreeSet<String>) -> String {
if used.insert(base.clone()) {
return base;
}
let mut n = 2u32;
loop {
let candidate = format!("{base}_{n}");
if used.insert(candidate.clone()) {
return candidate;
}
n += 1;
}
}
fn resolve_graphml_dest(dest: &Path) -> Result<PathBuf, crate::CliError> {
if dest.is_dir() {
Ok(dest.join("graph.graphml"))
} else {
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
Ok(dest.to_path_buf())
}
}
pub fn write_graphml(
nodes: &[NodeInfo],
edges: &[ExportEdge],
dest: &Path,
) -> Result<PathBuf, crate::CliError> {
let file_path = resolve_graphml_dest(dest)?;
let mut node_key_types: BTreeMap<String, Option<GmlType>> = BTreeMap::new();
node_key_types.insert("label".to_string(), Some(GmlType::String));
for node in nodes {
for (k, v) in &node.props {
let t = value_gml_type(v);
node_key_types
.entry(k.clone())
.and_modify(|existing| {
if *existing != Some(t) {
*existing = None;
}
})
.or_insert(Some(t));
}
}
let node_key_types: BTreeMap<String, GmlType> = node_key_types
.into_iter()
.map(|(k, t)| (k, t.unwrap_or(GmlType::String)))
.collect();
let mut edge_key_types: Vec<(&str, GmlType)> = vec![
("type", GmlType::String),
("derived", GmlType::Boolean),
("rule", GmlType::String),
("weight", GmlType::Double),
];
edge_key_types.sort_by(|a, b| a.0.cmp(b.0));
let mut used_ids: BTreeSet<String> = BTreeSet::new();
let mut node_key_ids: BTreeMap<String, String> = BTreeMap::new();
for name in node_key_types.keys() {
let base = format!("n_{}", sanitize_id_component(name));
node_key_ids.insert(name.clone(), unique_id(base, &mut used_ids));
}
let mut edge_key_ids: BTreeMap<&str, String> = BTreeMap::new();
for (name, _) in &edge_key_types {
let base = format!("e_{}", sanitize_id_component(name));
edge_key_ids.insert(name, unique_id(base, &mut used_ids));
}
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">\n");
for (name, gtype) in &node_key_types {
let _ = writeln!(
out,
" <key id=\"{}\" for=\"node\" attr.name=\"{}\" attr.type=\"{}\"/>",
xml_escape(&node_key_ids[name]),
xml_escape(name),
gtype.as_str()
);
}
for (name, gtype) in &edge_key_types {
let _ = writeln!(
out,
" <key id=\"{}\" for=\"edge\" attr.name=\"{}\" attr.type=\"{}\"/>",
xml_escape(&edge_key_ids[name]),
xml_escape(name),
gtype.as_str()
);
}
out.push_str(" <graph id=\"G\" edgedefault=\"directed\">\n");
for node in nodes {
let _ = writeln!(out, " <node id=\"{}\">", xml_escape(&node.key));
let _ = writeln!(
out,
" <data key=\"{}\">{}</data>",
xml_escape(&node_key_ids["label"]),
xml_escape(&node.label)
);
for (k, v) in &node.props {
let _ = writeln!(
out,
" <data key=\"{}\">{}</data>",
xml_escape(&node_key_ids[k]),
xml_escape(&value_gml_text(v))
);
}
out.push_str(" </node>\n");
}
for (i, edge) in edges.iter().enumerate() {
let _ = writeln!(
out,
" <edge id=\"e{i}\" source=\"{}\" target=\"{}\">",
xml_escape(&edge.src),
xml_escape(&edge.dst)
);
let _ = writeln!(
out,
" <data key=\"{}\">{}</data>",
xml_escape(&edge_key_ids["type"]),
xml_escape(&edge.edge_type)
);
let _ = writeln!(
out,
" <data key=\"{}\">{}</data>",
xml_escape(&edge_key_ids["derived"]),
edge.derived
);
if let Some(rule) = &edge.rule {
let _ = writeln!(
out,
" <data key=\"{}\">{}</data>",
xml_escape(&edge_key_ids["rule"]),
xml_escape(rule)
);
}
if let Some(w) = edge.weight {
let _ = writeln!(
out,
" <data key=\"{}\">{}</data>",
xml_escape(&edge_key_ids["weight"]),
xml_escape(&value_gml_text(&Value::Float(w)))
);
}
out.push_str(" </edge>\n");
}
out.push_str(" </graph>\n");
out.push_str("</graphml>\n");
std::fs::write(&file_path, out)?;
Ok(file_path)
}
#[cfg(test)]
mod v11_value_tests {
use super::{value_gml_text, value_gml_type, value_to_json, GmlType};
use mushroomdb::{json_to_value, Value};
use serde_json::json;
#[test]
fn export_writes_the_tagged_form_that_reimports() {
let date = Value::Date(19_737);
assert_eq!(value_to_json(&date), json!({"$date": "2024-01-15"}));
assert_eq!(json_to_value(value_to_json(&date)), Ok(Some(date.clone())));
let blob = Value::Binary(vec![0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(json_to_value(value_to_json(&blob)), Ok(Some(blob.clone())));
assert!(matches!(value_gml_type(&date), GmlType::String));
assert_eq!(value_gml_text(&date), r#"{"$date":"2024-01-15"}"#);
}
}
#[cfg(test)]
mod round_trip_unit_tests {
use super::{cypher_literal, cypher_string, write_csv, CypherSkip};
use crate::import::{parse_node_header, parse_node_row};
use mushroomdb::{NodeInfo, Value};
use std::collections::BTreeMap;
fn typed_values() -> Vec<(&'static str, Value)> {
vec![
("date", Value::Date(19_737)),
("ldt", Value::LocalDateTime(1_705_322_096_789_012)),
(
"dt",
Value::DateTime {
micros_utc: 1_705_302_296_789_012,
offset_seconds: 19_800,
},
),
(
"dur",
Value::Duration {
months: 14,
days: 3,
nanos: 1,
},
),
("bin", Value::Binary(vec![0xDE, 0xAD, 0xBE, 0xEF])),
(
"nested",
Value::List(vec![
Value::Date(0),
Value::Map(BTreeMap::from([("b".to_string(), Value::Binary(vec![1]))])),
]),
),
("whole", Value::Float(3.0)),
("empty", Value::Str(String::new())),
]
}
#[test]
fn csv_export_of_the_new_types_reads_back_typed() {
let dir = std::env::temp_dir().join(format!("mushroomdb-csv-typed-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let node = NodeInfo {
key: "n1".into(),
label: "T".into(),
props: typed_values()
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect(),
};
let source = super::Slices {
nodes: std::slice::from_ref(&node),
edges: &[],
};
write_csv(&source, &[], &dir).unwrap();
let mut reader = csv::Reader::from_path(dir.join("nodes.csv")).unwrap();
let header = reader.headers().unwrap().clone();
assert_eq!(&header[0], ":KEY");
let schema = parse_node_header(&header).unwrap();
assert!(schema.warnings.is_empty(), "{:?}", schema.warnings);
let record = reader.records().next().unwrap().unwrap();
let (label, props) = parse_node_row(&schema, &record, ';').unwrap();
assert_eq!(label, "T");
assert_eq!(props, node.props);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cypher_literals_have_no_exponent_and_refuse_what_has_no_literal() {
let lit = |v: Value| cypher_literal(&v).ok();
assert_eq!(lit(Value::Float(1.0)).as_deref(), Some("1.0"));
assert_eq!(lit(Value::Float(-0.5)).as_deref(), Some("-0.5"));
let huge = lit(Value::Float(1e300)).unwrap();
assert!(!huge.contains('e') && huge.ends_with(".0"), "{huge}");
assert_eq!(lit(Value::Int(-5)).as_deref(), Some("-5"));
assert!(matches!(
cypher_literal(&Value::Int(i64::MIN)),
Err(CypherSkip::Number)
));
assert!(matches!(
cypher_literal(&Value::Float(f64::NAN)),
Err(CypherSkip::Number)
));
assert!(matches!(
cypher_literal(&Value::List(vec![Value::Date(1)])),
Err(CypherSkip::Typed)
));
assert_eq!(
lit(Value::List(vec![Value::Int(1), Value::Str("a".into())])).as_deref(),
Some("[1, 'a']")
);
}
#[test]
fn cypher_strings_stay_on_one_line() {
assert_eq!(
cypher_string("it's \\ \n\r\t\u{1}"),
r"'it\'s \\ \n\r\t\u0001'"
);
assert!(!cypher_string("a\nb").contains('\n'));
}
}