Skip to main content

cli/
export.rs

1//! Export writer for `mushroomdb export`.
2//!
3//! Supports three formats:
4//!   - `jsonl`: nodes.jsonl, edges.jsonl, rules.jsonl (always deterministic)
5//!   - `parquet`: nodes.parquet, edges.parquet, rules.parquet (byte-identical
6//!     output is NOT guaranteed across parquet-rs versions; use JSONL when
7//!     byte-stable reproducibility is required)
8//!   - `graphml`: a single `.graphml` file (nodes + edges only; rules have no
9//!     GraphML analogue) for import into generic graph viewers and analysis
10//!     tools
11//!
12//! Two runs on the same store state produce byte-identical JSONL and GraphML
13//! output (sorted by key / (edge_type, src, dst) / name — callers must
14//! pre-sort the slices).
15//!
16//! # Float precision loss
17//!
18//! `Value::Float` values that are NaN or ±Inf are not representable in JSON
19//! or in GraphML's numeric attribute types. They are serialised as JSON
20//! `null` (JSONL) or an empty `<data>` element (GraphML) rather than causing
21//! the export to fail. This is a lossy mapping; the original value is
22//! irrecoverable from the export. Stores that need exact float fidelity
23//! should use the binary backup command instead of export.
24
25use core_api::{ExportEdge, NodeInfo, RuleDef, Value};
26use std::collections::{BTreeMap, BTreeSet};
27use std::fmt::Write as _;
28use std::io::Write as _;
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31
32use arrow_array::{ArrayRef, BooleanArray, RecordBatch, StringArray};
33use arrow_schema::{DataType, Field, Schema};
34use parquet::arrow::ArrowWriter;
35use parquet::file::properties::WriterProperties;
36
37/// Accepted export formats.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ExportFormat {
40    Jsonl,
41    Parquet,
42    Graphml,
43}
44
45impl ExportFormat {
46    pub fn parse(s: &str) -> Option<Self> {
47        match s {
48            "jsonl" => Some(Self::Jsonl),
49            "parquet" => Some(Self::Parquet),
50            "graphml" => Some(Self::Graphml),
51            _ => None,
52        }
53    }
54
55    pub fn name(&self) -> &'static str {
56        match self {
57            Self::Jsonl => "jsonl",
58            Self::Parquet => "parquet",
59            Self::Graphml => "graphml",
60        }
61    }
62}
63
64/// Serialize a `Value` to serde_json `Value`.
65///
66/// NaN and ±Inf floats are not representable in JSON and are mapped to `null`
67/// (see module-level docs for the loss semantics).
68fn value_to_json(v: &Value) -> serde_json::Value {
69    match v {
70        Value::Int(i) => serde_json::Value::Number((*i).into()),
71        // serde_json::Number::from_f64 returns None for NaN/±Inf; map those to null.
72        Value::Float(f) => serde_json::Number::from_f64(*f)
73            .map(serde_json::Value::Number)
74            .unwrap_or(serde_json::Value::Null),
75        Value::Str(s) => serde_json::Value::String(s.clone()),
76        Value::Bool(b) => serde_json::Value::Bool(*b),
77        Value::List(xs) => serde_json::Value::Array(xs.iter().map(value_to_json).collect()),
78        Value::Map(m) => serde_json::Value::Object(
79            m.iter()
80                .map(|(k, v)| (k.clone(), value_to_json(v)))
81                .collect(),
82        ),
83    }
84}
85
86/// Write JSONL files to `dest`: nodes.jsonl, edges.jsonl, rules.jsonl.
87///
88/// `nodes` must be sorted by key, `edges` by (edge_type, src, dst), `rules` by name.
89/// Deterministic: same sorted input → byte-identical output.
90pub fn write_jsonl(
91    nodes: &[NodeInfo],
92    edges: &[ExportEdge],
93    rules: &[RuleDef],
94    dest: &Path,
95) -> Result<(), crate::CliError> {
96    std::fs::create_dir_all(dest)?;
97
98    // ── nodes.jsonl ────────────────────────────────────────────────────────
99    {
100        let mut f = std::fs::File::create(dest.join("nodes.jsonl"))?;
101        for node in nodes {
102            let mut obj = serde_json::Map::new();
103            obj.insert("key".into(), serde_json::Value::String(node.key.clone()));
104            obj.insert(
105                "label".into(),
106                serde_json::Value::String(node.label.clone()),
107            );
108            for (k, v) in &node.props {
109                obj.insert(k.clone(), value_to_json(v));
110            }
111            let line = serde_json::to_string(&serde_json::Value::Object(obj))
112                .map_err(|e| crate::CliError(format!("json encode error: {e}")))?;
113            writeln!(f, "{line}")?;
114        }
115    }
116
117    // ── edges.jsonl ────────────────────────────────────────────────────────
118    {
119        let mut f = std::fs::File::create(dest.join("edges.jsonl"))?;
120        for edge in edges {
121            let obj = serde_json::json!({
122                "edge_type": edge.edge_type,
123                "src": edge.src,
124                "dst": edge.dst,
125                "derived": edge.derived,
126                "rule": edge.rule,
127            });
128            let line = serde_json::to_string(&obj)
129                .map_err(|e| crate::CliError(format!("json encode error: {e}")))?;
130            writeln!(f, "{line}")?;
131        }
132    }
133
134    // ── rules.jsonl ────────────────────────────────────────────────────────
135    {
136        let mut f = std::fs::File::create(dest.join("rules.jsonl"))?;
137        for rule in rules {
138            let line = serde_json::to_string(rule)
139                .map_err(|e| crate::CliError(format!("json encode rule: {e}")))?;
140            writeln!(f, "{line}")?;
141        }
142    }
143
144    Ok(())
145}
146
147/// Write parquet files to `dest`: nodes.parquet, edges.parquet, rules.parquet.
148///
149/// Schema for each file:
150/// - nodes.parquet: key (Utf8), label (Utf8), props (Utf8, JSON)
151/// - edges.parquet: edge_type (Utf8), src (Utf8), dst (Utf8), derived (Boolean), rule (Utf8, nullable)
152/// - rules.parquet: name (Utf8), definition (Utf8, JSON)
153///
154/// `nodes` must be sorted by key, `edges` by (edge_type, src, dst), `rules` by name.
155///
156/// Compression: Snappy (parquet-rs default).  This is a format detail, not a
157/// stability contract — the column encoding and page layout may differ across
158/// parquet-rs versions.  Use JSONL export when byte-stable reproducibility is
159/// required.
160pub fn write_parquet(
161    nodes: &[NodeInfo],
162    edges: &[ExportEdge],
163    rules: &[RuleDef],
164    dest: &Path,
165) -> Result<(), crate::CliError> {
166    std::fs::create_dir_all(dest)?;
167    let props = WriterProperties::builder().build();
168
169    // ── nodes.parquet ──────────────────────────────────────────────────────
170    {
171        let schema = Arc::new(Schema::new(vec![
172            Field::new("key", DataType::Utf8, false),
173            Field::new("label", DataType::Utf8, false),
174            Field::new("props", DataType::Utf8, false),
175        ]));
176        let mut keys = Vec::new();
177        let mut labels = Vec::new();
178        let mut props_json = Vec::new();
179        for node in nodes {
180            keys.push(node.key.clone());
181            labels.push(node.label.clone());
182            let p: serde_json::Map<String, serde_json::Value> = node
183                .props
184                .iter()
185                .map(|(k, v)| (k.clone(), value_to_json(v)))
186                .collect();
187            props_json.push(
188                serde_json::to_string(&serde_json::Value::Object(p))
189                    .map_err(|e| crate::CliError(format!("json encode: {e}")))?,
190            );
191        }
192        let batch = RecordBatch::try_new(
193            schema.clone(),
194            vec![
195                Arc::new(StringArray::from(keys)) as ArrayRef,
196                Arc::new(StringArray::from(labels)) as ArrayRef,
197                Arc::new(StringArray::from(props_json)) as ArrayRef,
198            ],
199        )
200        .map_err(|e| crate::CliError(format!("arrow error: {e}")))?;
201
202        let file = std::fs::File::create(dest.join("nodes.parquet"))?;
203        let mut writer = ArrowWriter::try_new(file, schema, Some(props.clone()))
204            .map_err(|e| crate::CliError(format!("parquet writer: {e}")))?;
205        writer
206            .write(&batch)
207            .map_err(|e| crate::CliError(format!("parquet write: {e}")))?;
208        writer
209            .close()
210            .map_err(|e| crate::CliError(format!("parquet close: {e}")))?;
211    }
212
213    // ── edges.parquet ──────────────────────────────────────────────────────
214    {
215        let schema = Arc::new(Schema::new(vec![
216            Field::new("edge_type", DataType::Utf8, false),
217            Field::new("src", DataType::Utf8, false),
218            Field::new("dst", DataType::Utf8, false),
219            Field::new("derived", DataType::Boolean, false),
220            Field::new("rule", DataType::Utf8, true),
221        ]));
222        let mut etypes = Vec::new();
223        let mut srcs = Vec::new();
224        let mut dsts = Vec::new();
225        let mut deriveds = Vec::new();
226        let mut rule_names: Vec<Option<String>> = Vec::new();
227        for edge in edges {
228            etypes.push(edge.edge_type.clone());
229            srcs.push(edge.src.clone());
230            dsts.push(edge.dst.clone());
231            deriveds.push(edge.derived);
232            rule_names.push(edge.rule.clone());
233        }
234        let batch = RecordBatch::try_new(
235            schema.clone(),
236            vec![
237                Arc::new(StringArray::from(etypes)) as ArrayRef,
238                Arc::new(StringArray::from(srcs)) as ArrayRef,
239                Arc::new(StringArray::from(dsts)) as ArrayRef,
240                Arc::new(BooleanArray::from(deriveds)) as ArrayRef,
241                Arc::new(StringArray::from(rule_names)) as ArrayRef,
242            ],
243        )
244        .map_err(|e| crate::CliError(format!("arrow error: {e}")))?;
245
246        let file = std::fs::File::create(dest.join("edges.parquet"))?;
247        let mut writer = ArrowWriter::try_new(file, schema, Some(props.clone()))
248            .map_err(|e| crate::CliError(format!("parquet writer: {e}")))?;
249        writer
250            .write(&batch)
251            .map_err(|e| crate::CliError(format!("parquet write: {e}")))?;
252        writer
253            .close()
254            .map_err(|e| crate::CliError(format!("parquet close: {e}")))?;
255    }
256
257    // ── rules.parquet ──────────────────────────────────────────────────────
258    {
259        let schema = Arc::new(Schema::new(vec![
260            Field::new("name", DataType::Utf8, false),
261            Field::new("definition", DataType::Utf8, false),
262        ]));
263        let mut names = Vec::new();
264        let mut definitions = Vec::new();
265        for rule in rules {
266            names.push(rule.name.clone());
267            definitions.push(
268                serde_json::to_string(rule)
269                    .map_err(|e| crate::CliError(format!("json encode: {e}")))?,
270            );
271        }
272        let batch = RecordBatch::try_new(
273            schema.clone(),
274            vec![
275                Arc::new(StringArray::from(names)) as ArrayRef,
276                Arc::new(StringArray::from(definitions)) as ArrayRef,
277            ],
278        )
279        .map_err(|e| crate::CliError(format!("arrow error: {e}")))?;
280
281        let file = std::fs::File::create(dest.join("rules.parquet"))?;
282        let mut writer = ArrowWriter::try_new(file, schema, Some(props))
283            .map_err(|e| crate::CliError(format!("parquet writer: {e}")))?;
284        writer
285            .write(&batch)
286            .map_err(|e| crate::CliError(format!("parquet write: {e}")))?;
287        writer
288            .close()
289            .map_err(|e| crate::CliError(format!("parquet close: {e}")))?;
290    }
291
292    Ok(())
293}
294
295// ── GraphML ──────────────────────────────────────────────────────────────
296
297/// GraphML `attr.type` values this writer produces.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
299enum GmlType {
300    Boolean,
301    /// `Value::Int` is a 64-bit `i64` (`core_storage::Value::Int`). GraphML's
302    /// informal convention treats `attr.type="int"` as 32-bit, which cannot
303    /// represent the full range; `"long"` is the interoperable choice for a
304    /// 64-bit integer and is what this writer declares.
305    Long,
306    Double,
307    String,
308}
309
310impl GmlType {
311    fn as_str(self) -> &'static str {
312        match self {
313            Self::Boolean => "boolean",
314            Self::Long => "long",
315            Self::Double => "double",
316            Self::String => "string",
317        }
318    }
319}
320
321/// The GraphML attribute type for a scalar `Value`. Lists and maps are
322/// JSON-encoded text, so they declare `string`.
323fn value_gml_type(v: &Value) -> GmlType {
324    match v {
325        Value::Int(_) => GmlType::Long,
326        Value::Float(_) => GmlType::Double,
327        Value::Bool(_) => GmlType::Boolean,
328        Value::Str(_) | Value::List(_) | Value::Map(_) => GmlType::String,
329    }
330}
331
332/// Render a `Value` as GraphML `<data>` element text.
333///
334/// Reuses [`value_to_json`] so the NaN/±Inf → "no value" mapping is exactly
335/// the same lossy conversion JSONL export makes (see module docs): such
336/// floats become serde_json `Null`, which renders here as empty text.
337/// Lists and maps render as their JSON text form, matching the `string`
338/// `attr.type` declared for them.
339fn value_gml_text(v: &Value) -> String {
340    match value_to_json(v) {
341        serde_json::Value::Null => String::new(),
342        serde_json::Value::Bool(b) => b.to_string(),
343        serde_json::Value::Number(n) => n.to_string(),
344        serde_json::Value::String(s) => s,
345        other => other.to_string(),
346    }
347}
348
349/// Escape `&`, `<`, `>`, `"`, `'` for safe use in GraphML element text and
350/// attribute values, and strip characters the XML 1.0 grammar cannot encode
351/// at all (control characters other than tab/LF/CR).
352fn xml_escape(s: &str) -> String {
353    let mut out = String::with_capacity(s.len());
354    for c in s.chars() {
355        match c {
356            '&' => out.push_str("&amp;"),
357            '<' => out.push_str("&lt;"),
358            '>' => out.push_str("&gt;"),
359            '"' => out.push_str("&quot;"),
360            '\'' => out.push_str("&apos;"),
361            '\u{9}' | '\u{A}' | '\u{D}' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' => {
362                out.push(c)
363            }
364            _ => {} // strip: not a valid XML 1.0 character
365        }
366    }
367    out
368}
369
370/// Sanitize one component of a GraphML key `id` to an XML NCName-safe form:
371/// `[A-Za-z_]` for the first character, `[A-Za-z0-9_.-]` after. Any other
372/// character becomes `_`.
373fn sanitize_id_component(s: &str) -> String {
374    let mut out = String::with_capacity(s.len());
375    for (i, c) in s.chars().enumerate() {
376        let ok = if i == 0 {
377            c.is_ascii_alphabetic() || c == '_'
378        } else {
379            c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-'
380        };
381        out.push(if ok { c } else { '_' });
382    }
383    if out.is_empty() {
384        out.push('_');
385    }
386    out
387}
388
389/// Insert `base` into `used`, disambiguating with a numeric suffix on
390/// collision (`base`, `base_2`, `base_3`, ...). Deterministic for a fixed
391/// insertion order.
392fn unique_id(base: String, used: &mut BTreeSet<String>) -> String {
393    if used.insert(base.clone()) {
394        return base;
395    }
396    let mut n = 2u32;
397    loop {
398        let candidate = format!("{base}_{n}");
399        if used.insert(candidate.clone()) {
400            return candidate;
401        }
402        n += 1;
403    }
404}
405
406/// Resolve the `.graphml` file to write for a `dest` argument.
407///
408/// If `dest` is an existing directory, the file is `dest/graph.graphml`.
409/// Otherwise `dest` is treated as the file path itself and its parent
410/// directories are created.
411fn resolve_graphml_dest(dest: &Path) -> Result<PathBuf, crate::CliError> {
412    if dest.is_dir() {
413        Ok(dest.join("graph.graphml"))
414    } else {
415        if let Some(parent) = dest.parent() {
416            if !parent.as_os_str().is_empty() {
417                std::fs::create_dir_all(parent)?;
418            }
419        }
420        Ok(dest.to_path_buf())
421    }
422}
423
424/// Write a single GraphML file describing `nodes` and `edges`.
425///
426/// Nodes carry `label` plus every property (lists/maps JSON-encoded as
427/// `string`-typed text). Edges carry `type`, `derived`, and — when present —
428/// `rule` and `weight`. `<key>` elements are declared once per (`for`, name)
429/// pair, node keys before edge keys, each block sorted by attribute name.
430/// Key `id`s are XML-name-safe (`n_<prop>` / `e_<prop>`, sanitized); the
431/// original name is preserved in `attr.name`. A node property literally
432/// named `label` shares the `n_label` key (and `<data>` slot) with the
433/// built-in label field — an accepted collision, since mushroomdb's schema
434/// reserves `label` for the node's type name.
435///
436/// **Type declaration:** a node property's `attr.type` is [`value_gml_type`]
437/// of its value where every node reporting that property name agrees on the
438/// `Value` variant; if two nodes disagree (e.g. one has `Value::Int` under
439/// `score`, another `Value::Str`), the key declares `attr.type="string"` for
440/// every node — a safe fallback, since `string` can hold any value's text
441/// form — rather than picking one node's type and risking a value that
442/// doesn't fit it. `Value::Int` (a 64-bit `i64`) declares `attr.type="long"`,
443/// not `"int"`, since GraphML's informal convention treats `"int"` as
444/// 32-bit and `"long"` is the interoperable choice for the full range.
445///
446/// `nodes` must be sorted by key and `edges` by `(edge_type, src, dst)` —
447/// same precondition as [`write_jsonl`]. Two runs on the same sorted input
448/// produce byte-identical output: edge `id`s (`e0`, `e1`, ...) are assigned
449/// in that sorted order.
450///
451/// Rules have no GraphML representation; only nodes and edges are exported.
452/// See [`resolve_graphml_dest`] for how `dest` maps to the output file path.
453pub fn write_graphml(
454    nodes: &[NodeInfo],
455    edges: &[ExportEdge],
456    dest: &Path,
457) -> Result<PathBuf, crate::CliError> {
458    let file_path = resolve_graphml_dest(dest)?;
459
460    // Node keys: `label` plus the union of every prop name across all nodes.
461    // `Some(t)` while every node reporting this name agrees on type `t`;
462    // `None` once two nodes disagree — resolved to `string` below.
463    let mut node_key_types: BTreeMap<String, Option<GmlType>> = BTreeMap::new();
464    node_key_types.insert("label".to_string(), Some(GmlType::String));
465    for node in nodes {
466        for (k, v) in &node.props {
467            let t = value_gml_type(v);
468            node_key_types
469                .entry(k.clone())
470                .and_modify(|existing| {
471                    if *existing != Some(t) {
472                        *existing = None;
473                    }
474                })
475                .or_insert(Some(t));
476        }
477    }
478    let node_key_types: BTreeMap<String, GmlType> = node_key_types
479        .into_iter()
480        .map(|(k, t)| (k, t.unwrap_or(GmlType::String)))
481        .collect();
482
483    // Edge keys: a fixed schema, sorted by name.
484    let mut edge_key_types: Vec<(&str, GmlType)> = vec![
485        ("type", GmlType::String),
486        ("derived", GmlType::Boolean),
487        ("rule", GmlType::String),
488        ("weight", GmlType::Double),
489    ];
490    edge_key_types.sort_by(|a, b| a.0.cmp(b.0));
491
492    // Assign sanitized, collision-free key ids: node keys (`n_...`) first,
493    // then edge keys (`e_...`), each block sorted by attribute name.
494    let mut used_ids: BTreeSet<String> = BTreeSet::new();
495    let mut node_key_ids: BTreeMap<String, String> = BTreeMap::new();
496    for name in node_key_types.keys() {
497        let base = format!("n_{}", sanitize_id_component(name));
498        node_key_ids.insert(name.clone(), unique_id(base, &mut used_ids));
499    }
500    let mut edge_key_ids: BTreeMap<&str, String> = BTreeMap::new();
501    for (name, _) in &edge_key_types {
502        let base = format!("e_{}", sanitize_id_component(name));
503        edge_key_ids.insert(name, unique_id(base, &mut used_ids));
504    }
505
506    let mut out = String::new();
507    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
508    out.push_str("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">\n");
509
510    for (name, gtype) in &node_key_types {
511        let _ = writeln!(
512            out,
513            "  <key id=\"{}\" for=\"node\" attr.name=\"{}\" attr.type=\"{}\"/>",
514            xml_escape(&node_key_ids[name]),
515            xml_escape(name),
516            gtype.as_str()
517        );
518    }
519    for (name, gtype) in &edge_key_types {
520        let _ = writeln!(
521            out,
522            "  <key id=\"{}\" for=\"edge\" attr.name=\"{}\" attr.type=\"{}\"/>",
523            xml_escape(&edge_key_ids[name]),
524            xml_escape(name),
525            gtype.as_str()
526        );
527    }
528
529    out.push_str("  <graph id=\"G\" edgedefault=\"directed\">\n");
530
531    for node in nodes {
532        let _ = writeln!(out, "    <node id=\"{}\">", xml_escape(&node.key));
533        let _ = writeln!(
534            out,
535            "      <data key=\"{}\">{}</data>",
536            xml_escape(&node_key_ids["label"]),
537            xml_escape(&node.label)
538        );
539        for (k, v) in &node.props {
540            let _ = writeln!(
541                out,
542                "      <data key=\"{}\">{}</data>",
543                xml_escape(&node_key_ids[k]),
544                xml_escape(&value_gml_text(v))
545            );
546        }
547        out.push_str("    </node>\n");
548    }
549
550    for (i, edge) in edges.iter().enumerate() {
551        let _ = writeln!(
552            out,
553            "    <edge id=\"e{i}\" source=\"{}\" target=\"{}\">",
554            xml_escape(&edge.src),
555            xml_escape(&edge.dst)
556        );
557        let _ = writeln!(
558            out,
559            "      <data key=\"{}\">{}</data>",
560            xml_escape(&edge_key_ids["type"]),
561            xml_escape(&edge.edge_type)
562        );
563        let _ = writeln!(
564            out,
565            "      <data key=\"{}\">{}</data>",
566            xml_escape(&edge_key_ids["derived"]),
567            edge.derived
568        );
569        if let Some(rule) = &edge.rule {
570            let _ = writeln!(
571                out,
572                "      <data key=\"{}\">{}</data>",
573                xml_escape(&edge_key_ids["rule"]),
574                xml_escape(rule)
575            );
576        }
577        if let Some(w) = edge.weight {
578            let _ = writeln!(
579                out,
580                "      <data key=\"{}\">{}</data>",
581                xml_escape(&edge_key_ids["weight"]),
582                xml_escape(&value_gml_text(&Value::Float(w)))
583            );
584        }
585        out.push_str("    </edge>\n");
586    }
587
588    out.push_str("  </graph>\n");
589    out.push_str("</graphml>\n");
590
591    std::fs::write(&file_path, out)?;
592    Ok(file_path)
593}