Skip to main content

grafeo_engine/export/
gexf.rs

1//! GEXF 1.3 graph export.
2//!
3//! Serializes LPG nodes and edges to the [GEXF 1.3](https://gexf.net/1.3/) XML format,
4//! readable by Gephi, Gephi Lite, NetworkX, 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_gexf_type,
12    value_to_xml_string,
13};
14
15/// Writes a complete GEXF 1.3 document to the given writer.
16///
17/// # Errors
18///
19/// Returns [`ExportError::Io`] if writing fails.
20pub fn write_gexf<W: Write>(
21    writer: &mut W,
22    nodes: &[Node],
23    edges: &[Edge],
24) -> Result<(), ExportError> {
25    let node_schema = discover_node_schema(nodes, value_to_gexf_type);
26    let edge_schema = discover_edge_schema(edges, value_to_gexf_type);
27
28    // XML header
29    writeln!(writer, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
30    writeln!(
31        writer,
32        "<gexf xmlns=\"http://gexf.net/1.3\" version=\"1.3\">"
33    )?;
34    writeln!(writer, "  <meta>")?;
35    writeln!(writer, "    <creator>GrafeoDB</creator>")?;
36    writeln!(writer, "  </meta>")?;
37    writeln!(
38        writer,
39        "  <graph defaultedgetype=\"directed\" mode=\"static\">"
40    )?;
41
42    // Node attribute declarations
43    if !node_schema.is_empty() {
44        writeln!(writer, "    <attributes class=\"node\">")?;
45        for (key, (id, type_str)) in &node_schema {
46            writeln!(
47                writer,
48                "      <attribute id=\"{id}\" title=\"{}\" type=\"{type_str}\"/>",
49                escape_xml(key.as_str())
50            )?;
51        }
52        writeln!(writer, "    </attributes>")?;
53    }
54
55    // Edge attribute declarations
56    if !edge_schema.is_empty() {
57        writeln!(writer, "    <attributes class=\"edge\">")?;
58        for (key, (id, type_str)) in &edge_schema {
59            writeln!(
60                writer,
61                "      <attribute id=\"{id}\" title=\"{}\" type=\"{type_str}\"/>",
62                escape_xml(key.as_str())
63            )?;
64        }
65        writeln!(writer, "    </attributes>")?;
66    }
67
68    // Nodes
69    writeln!(writer, "    <nodes>")?;
70    for node in nodes {
71        let label: String = node
72            .labels
73            .iter()
74            .map(|s| s.as_str())
75            .collect::<Vec<_>>()
76            .join(",");
77        write!(
78            writer,
79            "      <node id=\"{}\" label=\"{}\"",
80            node.id.0,
81            escape_xml(&label)
82        )?;
83
84        // Collect non-null attribute values
85        let attvalues: Vec<_> = node_schema
86            .iter()
87            .filter_map(|(key, (id, _))| {
88                node.properties
89                    .get(key)
90                    .and_then(|v| value_to_xml_string(v).map(|s| (*id, s)))
91            })
92            .collect();
93
94        if attvalues.is_empty() {
95            writeln!(writer, "/>")?;
96        } else {
97            writeln!(writer, ">")?;
98            writeln!(writer, "        <attvalues>")?;
99            for (id, val_str) in &attvalues {
100                writeln!(
101                    writer,
102                    "          <attvalue for=\"{id}\" value=\"{}\"/>",
103                    val_str
104                )?;
105            }
106            writeln!(writer, "        </attvalues>")?;
107            writeln!(writer, "      </node>")?;
108        }
109    }
110    writeln!(writer, "    </nodes>")?;
111
112    // Edges
113    writeln!(writer, "    <edges>")?;
114    for edge in edges {
115        write!(
116            writer,
117            "      <edge id=\"{}\" source=\"{}\" target=\"{}\" label=\"{}\"",
118            edge.id.0,
119            edge.src.0,
120            edge.dst.0,
121            escape_xml(edge.edge_type.as_str())
122        )?;
123
124        let attvalues: Vec<_> = edge_schema
125            .iter()
126            .filter_map(|(key, (id, _))| {
127                edge.properties
128                    .get(key)
129                    .and_then(|v| value_to_xml_string(v).map(|s| (*id, s)))
130            })
131            .collect();
132
133        if attvalues.is_empty() {
134            writeln!(writer, "/>")?;
135        } else {
136            writeln!(writer, ">")?;
137            writeln!(writer, "        <attvalues>")?;
138            for (id, val_str) in &attvalues {
139                writeln!(
140                    writer,
141                    "          <attvalue for=\"{id}\" value=\"{}\"/>",
142                    val_str
143                )?;
144            }
145            writeln!(writer, "        </attvalues>")?;
146            writeln!(writer, "      </edge>")?;
147        }
148    }
149    writeln!(writer, "    </edges>")?;
150
151    // Close
152    writeln!(writer, "  </graph>")?;
153    writeln!(writer, "</gexf>")?;
154
155    Ok(())
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use grafeo_common::PropertyKey;
162    use grafeo_common::types::PropertyMap;
163    use grafeo_common::types::{EdgeId, NodeId, Value};
164
165    fn make_node(id: u64, labels: &[&str], props: &[(&str, Value)]) -> Node {
166        let mut properties = PropertyMap::new();
167        for (k, v) in props {
168            properties.insert(PropertyKey::from(*k), v.clone());
169        }
170        Node {
171            id: NodeId(id),
172            labels: labels.iter().map(|s| arcstr::ArcStr::from(*s)).collect(),
173            properties,
174        }
175    }
176
177    fn make_edge(id: u64, src: u64, dst: u64, edge_type: &str, props: &[(&str, Value)]) -> Edge {
178        let mut properties = PropertyMap::new();
179        for (k, v) in props {
180            properties.insert(PropertyKey::from(*k), v.clone());
181        }
182        Edge {
183            id: EdgeId(id),
184            src: NodeId(src),
185            dst: NodeId(dst),
186            edge_type: arcstr::ArcStr::from(edge_type),
187            properties,
188        }
189    }
190
191    #[test]
192    fn test_empty_graph() {
193        let mut buf = Vec::new();
194        write_gexf(&mut buf, &[], &[]).unwrap();
195        let output = String::from_utf8(buf).unwrap();
196        assert!(output.contains("<gexf"));
197        assert!(output.contains("<nodes>"));
198        assert!(output.contains("</nodes>"));
199        assert!(output.contains("<edges>"));
200        assert!(output.contains("</edges>"));
201    }
202
203    #[test]
204    fn test_single_node() {
205        let nodes = vec![make_node(
206            1,
207            &["Person"],
208            &[("name", Value::String("Alix".into()))],
209        )];
210        let mut buf = Vec::new();
211        write_gexf(&mut buf, &nodes, &[]).unwrap();
212        let output = String::from_utf8(buf).unwrap();
213        assert!(output.contains("id=\"1\""));
214        assert!(output.contains("label=\"Person\""));
215        assert!(output.contains("title=\"name\""));
216        assert!(output.contains("value=\"Alix\""));
217    }
218
219    #[test]
220    fn test_node_with_multiple_labels() {
221        let nodes = vec![make_node(1, &["Person", "Employee"], &[])];
222        let mut buf = Vec::new();
223        write_gexf(&mut buf, &nodes, &[]).unwrap();
224        let output = String::from_utf8(buf).unwrap();
225        assert!(output.contains("label=\"Person,Employee\""));
226    }
227
228    #[test]
229    fn test_edge_with_properties() {
230        let nodes = vec![
231            make_node(1, &["Person"], &[]),
232            make_node(2, &["Person"], &[]),
233        ];
234        let edges = vec![make_edge(
235            0,
236            1,
237            2,
238            "KNOWS",
239            &[("since", Value::Int64(2020))],
240        )];
241        let mut buf = Vec::new();
242        write_gexf(&mut buf, &nodes, &edges).unwrap();
243        let output = String::from_utf8(buf).unwrap();
244        assert!(output.contains("source=\"1\""));
245        assert!(output.contains("target=\"2\""));
246        assert!(output.contains("label=\"KNOWS\""));
247        assert!(output.contains("value=\"2020\""));
248    }
249
250    #[test]
251    fn test_xml_escaping_in_values() {
252        let nodes = vec![make_node(
253            1,
254            &["Type<A>"],
255            &[("desc", Value::String("a & b".into()))],
256        )];
257        let mut buf = Vec::new();
258        write_gexf(&mut buf, &nodes, &[]).unwrap();
259        let output = String::from_utf8(buf).unwrap();
260        assert!(output.contains("label=\"Type&lt;A&gt;\""));
261        assert!(output.contains("value=\"a &amp; b\""));
262    }
263
264    #[test]
265    fn test_null_properties_omitted() {
266        let nodes = vec![make_node(
267            1,
268            &["Person"],
269            &[("name", Value::String("Alix".into())), ("age", Value::Null)],
270        )];
271        let mut buf = Vec::new();
272        write_gexf(&mut buf, &nodes, &[]).unwrap();
273        let output = String::from_utf8(buf).unwrap();
274        // name should be present, age attvalue should be omitted
275        assert!(output.contains("value=\"Alix\""));
276        // There should be only one attvalue entry (name), not two
277        assert_eq!(output.matches("attvalue for=").count(), 1);
278    }
279}