Skip to main content

grafeo_engine/export/
graphml.rs

1//! GraphML graph export.
2//!
3//! Serializes LPG nodes and edges to the [GraphML](http://graphml.graphdrawing.org/) XML format,
4//! readable by Gephi, Cytoscape, NetworkX, yEd, igraph, and other graph tools.
5
6use std::io::Write;
7
8use grafeo_core::graph::lpg::{Edge, Node};
9
10use super::{
11    ExportError, discover_edge_schema, discover_node_schema, escape_xml, value_to_graphml_type,
12    value_to_xml_string,
13};
14
15/// Writes a complete GraphML document to the given writer.
16///
17/// Node labels are stored as a `_labels` data key, edge types as `_type`.
18/// Node IDs are prefixed with `n`, edge IDs with `e` (GraphML convention).
19///
20/// # Errors
21///
22/// Returns [`ExportError::Io`] if writing fails.
23pub fn write_graphml<W: Write>(
24    writer: &mut W,
25    nodes: &[Node],
26    edges: &[Edge],
27) -> Result<(), ExportError> {
28    let node_schema = discover_node_schema(nodes, value_to_graphml_type);
29    let edge_schema = discover_edge_schema(edges, value_to_graphml_type);
30
31    // XML header
32    writeln!(writer, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
33    writeln!(
34        writer,
35        "<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">"
36    )?;
37
38    // Key declarations for node properties
39    // Reserve d0 for _labels (always present)
40    writeln!(
41        writer,
42        "  <key id=\"d0\" for=\"node\" attr.name=\"_labels\" attr.type=\"string\"/>"
43    )?;
44    for (key, (id, type_str)) in &node_schema {
45        // Offset by 1 to account for _labels at d0
46        let key_id = id + 1;
47        writeln!(
48            writer,
49            "  <key id=\"d{key_id}\" for=\"node\" attr.name=\"{}\" attr.type=\"{type_str}\"/>",
50            escape_xml(key.as_str())
51        )?;
52    }
53
54    // Key declarations for edge properties
55    // Edge keys start after all node keys. Reserve one for _type.
56    let edge_key_offset = node_schema.len() + 1;
57    writeln!(
58        writer,
59        "  <key id=\"d{edge_key_offset}\" for=\"edge\" attr.name=\"_type\" attr.type=\"string\"/>"
60    )?;
61    for (key, (id, type_str)) in &edge_schema {
62        let key_id = edge_key_offset + 1 + id;
63        writeln!(
64            writer,
65            "  <key id=\"d{key_id}\" for=\"edge\" attr.name=\"{}\" attr.type=\"{type_str}\"/>",
66            escape_xml(key.as_str())
67        )?;
68    }
69
70    // Graph element
71    writeln!(writer, "  <graph edgedefault=\"directed\">")?;
72
73    // Nodes
74    for node in nodes {
75        writeln!(writer, "    <node id=\"n{}\">", node.id.0)?;
76
77        // _labels data
78        let labels: String = node
79            .labels
80            .iter()
81            .map(|s| s.as_str())
82            .collect::<Vec<_>>()
83            .join(",");
84        writeln!(
85            writer,
86            "      <data key=\"d0\">{}</data>",
87            escape_xml(&labels)
88        )?;
89
90        // Property data
91        for (key, (schema_id, _)) in &node_schema {
92            if let Some(value) = node.properties.get(key)
93                && let Some(val_str) = value_to_xml_string(value)
94            {
95                let key_id = schema_id + 1;
96                writeln!(writer, "      <data key=\"d{key_id}\">{val_str}</data>")?;
97            }
98        }
99
100        writeln!(writer, "    </node>")?;
101    }
102
103    // Edges
104    for edge in edges {
105        writeln!(
106            writer,
107            "    <edge id=\"e{}\" source=\"n{}\" target=\"n{}\">",
108            edge.id.0, edge.src.0, edge.dst.0
109        )?;
110
111        // _type data
112        writeln!(
113            writer,
114            "      <data key=\"d{edge_key_offset}\">{}</data>",
115            escape_xml(edge.edge_type.as_str())
116        )?;
117
118        // Property data
119        for (key, (schema_id, _)) in &edge_schema {
120            if let Some(value) = edge.properties.get(key)
121                && let Some(val_str) = value_to_xml_string(value)
122            {
123                let key_id = edge_key_offset + 1 + schema_id;
124                writeln!(writer, "      <data key=\"d{key_id}\">{val_str}</data>")?;
125            }
126        }
127
128        writeln!(writer, "    </edge>")?;
129    }
130
131    // Close
132    writeln!(writer, "  </graph>")?;
133    writeln!(writer, "</graphml>")?;
134
135    Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use grafeo_common::PropertyKey;
142    use grafeo_common::types::PropertyMap;
143    use grafeo_common::types::{EdgeId, NodeId, Value};
144
145    fn make_node(id: u64, labels: &[&str], props: &[(&str, Value)]) -> Node {
146        let mut properties = PropertyMap::new();
147        for (k, v) in props {
148            properties.insert(PropertyKey::from(*k), v.clone());
149        }
150        Node {
151            id: NodeId(id),
152            labels: labels.iter().map(|s| arcstr::ArcStr::from(*s)).collect(),
153            properties,
154        }
155    }
156
157    fn make_edge(id: u64, src: u64, dst: u64, edge_type: &str, props: &[(&str, Value)]) -> Edge {
158        let mut properties = PropertyMap::new();
159        for (k, v) in props {
160            properties.insert(PropertyKey::from(*k), v.clone());
161        }
162        Edge {
163            id: EdgeId(id),
164            src: NodeId(src),
165            dst: NodeId(dst),
166            edge_type: arcstr::ArcStr::from(edge_type),
167            properties,
168        }
169    }
170
171    #[test]
172    fn test_empty_graph() {
173        let mut buf = Vec::new();
174        write_graphml(&mut buf, &[], &[]).unwrap();
175        let output = String::from_utf8(buf).unwrap();
176        assert!(output.contains("<graphml"));
177        assert!(output.contains("<graph"));
178        assert!(output.contains("</graph>"));
179        assert!(output.contains("</graphml>"));
180    }
181
182    #[test]
183    fn test_single_node_with_labels() {
184        let nodes = vec![make_node(
185            1,
186            &["Person"],
187            &[("name", Value::String("Alix".into()))],
188        )];
189        let mut buf = Vec::new();
190        write_graphml(&mut buf, &nodes, &[]).unwrap();
191        let output = String::from_utf8(buf).unwrap();
192        assert!(output.contains("id=\"n1\""));
193        assert!(output.contains("<data key=\"d0\">Person</data>"));
194        assert!(output.contains("attr.name=\"name\""));
195        assert!(output.contains("<data key=\"d1\">Alix</data>"));
196    }
197
198    #[test]
199    fn test_edge_with_type_and_properties() {
200        let nodes = vec![
201            make_node(1, &["Person"], &[]),
202            make_node(2, &["Person"], &[]),
203        ];
204        let edges = vec![make_edge(
205            0,
206            1,
207            2,
208            "KNOWS",
209            &[("since", Value::Int64(2020))],
210        )];
211        let mut buf = Vec::new();
212        write_graphml(&mut buf, &nodes, &edges).unwrap();
213        let output = String::from_utf8(buf).unwrap();
214        assert!(output.contains("source=\"n1\""));
215        assert!(output.contains("target=\"n2\""));
216        assert!(output.contains(">KNOWS</data>"));
217        assert!(output.contains(">2020</data>"));
218    }
219
220    #[test]
221    fn test_xml_escaping() {
222        let nodes = vec![make_node(
223            1,
224            &["A&B"],
225            &[("note", Value::String("<important>".into()))],
226        )];
227        let mut buf = Vec::new();
228        write_graphml(&mut buf, &nodes, &[]).unwrap();
229        let output = String::from_utf8(buf).unwrap();
230        assert!(output.contains(">A&amp;B</data>"));
231        assert!(output.contains(">&lt;important&gt;</data>"));
232    }
233
234    #[test]
235    fn test_null_properties_omitted() {
236        let nodes = vec![make_node(
237            1,
238            &["Person"],
239            &[("name", Value::String("Gus".into())), ("age", Value::Null)],
240        )];
241        let mut buf = Vec::new();
242        write_graphml(&mut buf, &nodes, &[]).unwrap();
243        let output = String::from_utf8(buf).unwrap();
244        assert!(output.contains(">Gus</data>"));
245        // age key should be declared but no data element for the null value
246        let data_count = output.matches("<data key=").count();
247        // d0 = _labels, d1 = name (age is null, omitted)
248        assert_eq!(data_count, 2);
249    }
250
251    #[test]
252    fn test_key_id_namespacing() {
253        // Verify node keys and edge keys don't collide
254        let nodes = vec![make_node(
255            1,
256            &["Person"],
257            &[("name", Value::String("Alix".into()))],
258        )];
259        let edges = vec![make_edge(
260            0,
261            1,
262            1,
263            "SELF",
264            &[("weight", Value::Float64(1.0))],
265        )];
266        let mut buf = Vec::new();
267        write_graphml(&mut buf, &nodes, &edges).unwrap();
268        let output = String::from_utf8(buf).unwrap();
269
270        // d0 = _labels (node), d1 = name (node), d2 = _type (edge), d3 = weight (edge)
271        assert!(output.contains("id=\"d0\" for=\"node\" attr.name=\"_labels\""));
272        assert!(output.contains("id=\"d1\" for=\"node\" attr.name=\"name\""));
273        assert!(output.contains("id=\"d2\" for=\"edge\" attr.name=\"_type\""));
274        assert!(output.contains("id=\"d3\" for=\"edge\" attr.name=\"weight\""));
275    }
276}