Skip to main content

lora_io/
mapping.rs

1//! Translate parsed rows into Cypher `CREATE` (or `MATCH … CREATE`) statements.
2//!
3//! Two flavours:
4//!   * [`RowMapping::Node`] turns each row into one node, optionally
5//!     keyed by an `id` column so a follow-up [`RowMapping::Relationship`]
6//!     can match start/end nodes by that key.
7//!   * [`RowMapping::Relationship`] looks up two existing nodes and
8//!     wires a typed relationship between them.
9//!
10//! The generated statement uses `UNWIND $rows AS r CREATE …` so the
11//! import driver can stream batches through a single parameterized
12//! transaction call instead of compiling a new query per row.
13
14use serde::{Deserialize, Serialize};
15
16/// One file column → one target property. `source` is the column
17/// name as it appears in the file (after schema-marker
18/// canonicalisation, e.g. `_id` for `:ID`); `property` is the target
19/// node/relationship property name. When equal, the mapping is the
20/// identity — useful default for the auto-mapping path.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ColumnSpec {
23    pub source: String,
24    pub property: String,
25}
26
27impl ColumnSpec {
28    pub fn identity(name: impl Into<String>) -> Self {
29        let n = name.into();
30        Self {
31            source: n.clone(),
32            property: n,
33        }
34    }
35}
36
37/// Top-level mapping: how to turn a stream of decoded rows into
38/// Cypher inserts. Carries enough information for the playground's
39/// auto-detect / preview path; users can edit this in the import
40/// dialog before running.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(tag = "kind", rename_all = "snake_case")]
43pub enum RowMapping {
44    /// Each row becomes a node of `label` with the listed properties.
45    Node {
46        /// Single label or `Label1:Label2:Label3`.
47        label: String,
48        /// Optional file column whose value becomes a unique property
49        /// later referenced by relationship imports. When set, the
50        /// generated statement includes a property literal; when
51        /// omitted, the node has no identity beyond Cypher's
52        /// auto-allocated node id.
53        id_column: Option<String>,
54        /// Property name to store the id column under (defaults to
55        /// `id` when [`Self::id_column`] is set).
56        id_property: Option<String>,
57        /// Source column → property name mappings.
58        properties: Vec<ColumnSpec>,
59    },
60    /// Each row becomes a relationship between two nodes located by
61    /// `(start_label, start_match_property)` and the corresponding
62    /// `end_*` fields.
63    Relationship {
64        rel_type: String,
65        start_label: String,
66        start_column: String,
67        start_match_property: String,
68        end_label: String,
69        end_column: String,
70        end_match_property: String,
71        properties: Vec<ColumnSpec>,
72    },
73}
74
75/// Quote an identifier (label, property, parameter) safely. The
76/// engine's Cypher dialect supports backticks for identifiers
77/// containing reserved chars, so we delegate to that — and reject
78/// identifiers that contain a backtick themselves since we can't
79/// escape them without ambiguity.
80fn quote_ident(name: &str) -> Result<String, String> {
81    if name.is_empty() {
82        return Err("identifier must not be empty".into());
83    }
84    if name.contains('`') {
85        return Err(format!("identifier `{name}` contains a backtick"));
86    }
87    let safe_simple = name.chars().enumerate().all(|(i, c)| {
88        if i == 0 {
89            c.is_ascii_alphabetic() || c == '_'
90        } else {
91            c.is_ascii_alphanumeric() || c == '_'
92        }
93    });
94    if safe_simple {
95        Ok(name.to_string())
96    } else {
97        Ok(format!("`{name}`"))
98    }
99}
100
101fn quote_param(name: &str) -> Result<String, String> {
102    if name.is_empty() {
103        return Err("parameter name must not be empty".into());
104    }
105    if name.contains('`') {
106        return Err(format!("parameter `{name}` contains a backtick"));
107    }
108    Ok(format!("r.{}", quote_ident(name)?))
109}
110
111/// Build the Cypher template for a node mapping. The result expects
112/// a `$rows` parameter containing an array of objects whose keys
113/// match the source column names referenced by the mapping.
114pub fn parameterized_create_for_node(
115    label: &str,
116    id_column: Option<&str>,
117    id_property: Option<&str>,
118    properties: &[ColumnSpec],
119) -> Result<String, String> {
120    let label_part = label
121        .split(':')
122        .filter(|s| !s.is_empty())
123        .map(quote_ident)
124        .collect::<Result<Vec<_>, _>>()?
125        .join(":");
126    if label_part.is_empty() {
127        return Err("node mapping requires at least one label".into());
128    }
129
130    let mut prop_pairs: Vec<String> = Vec::new();
131    if let Some(col) = id_column {
132        let prop = id_property.unwrap_or("id");
133        prop_pairs.push(format!("{}: {}", quote_ident(prop)?, quote_param(col)?));
134    }
135    for spec in properties {
136        if Some(spec.source.as_str()) == id_column {
137            continue; // already wired through the id column
138        }
139        prop_pairs.push(format!(
140            "{}: {}",
141            quote_ident(&spec.property)?,
142            quote_param(&spec.source)?
143        ));
144    }
145
146    let props_block = if prop_pairs.is_empty() {
147        String::new()
148    } else {
149        format!(" {{{}}}", prop_pairs.join(", "))
150    };
151    Ok(format!(
152        "UNWIND $rows AS r CREATE (:{label_part}{props_block})"
153    ))
154}
155
156/// Build the Cypher template for a relationship mapping.
157#[allow(clippy::too_many_arguments)]
158pub fn parameterized_create_for_relationship(
159    rel_type: &str,
160    start_label: &str,
161    start_column: &str,
162    start_match_property: &str,
163    end_label: &str,
164    end_column: &str,
165    end_match_property: &str,
166    properties: &[ColumnSpec],
167) -> Result<String, String> {
168    let start_label = quote_ident(start_label)?;
169    let end_label = quote_ident(end_label)?;
170    let rel_type = quote_ident(rel_type)?;
171    let start_match_property = quote_ident(start_match_property)?;
172    let end_match_property = quote_ident(end_match_property)?;
173    let start_param = quote_param(start_column)?;
174    let end_param = quote_param(end_column)?;
175
176    let mut prop_pairs: Vec<String> = Vec::new();
177    for spec in properties {
178        if spec.source == start_column || spec.source == end_column {
179            continue;
180        }
181        prop_pairs.push(format!(
182            "{}: {}",
183            quote_ident(&spec.property)?,
184            quote_param(&spec.source)?
185        ));
186    }
187    let props_block = if prop_pairs.is_empty() {
188        String::new()
189    } else {
190        format!(" {{{}}}", prop_pairs.join(", "))
191    };
192
193    Ok(format!(
194        "UNWIND $rows AS r \
195         MATCH (a:{start_label} {{{start_match_property}: {start_param}}}), \
196         (b:{end_label} {{{end_match_property}: {end_param}}}) \
197         CREATE (a)-[:{rel_type}{props_block}]->(b)"
198    ))
199}
200
201impl RowMapping {
202    /// Render the mapping into a Cypher template. The template binds
203    /// `$rows` to a batch of decoded rows.
204    pub fn to_cypher(&self) -> Result<String, String> {
205        match self {
206            RowMapping::Node {
207                label,
208                id_column,
209                id_property,
210                properties,
211            } => parameterized_create_for_node(
212                label,
213                id_column.as_deref(),
214                id_property.as_deref(),
215                properties,
216            ),
217            RowMapping::Relationship {
218                rel_type,
219                start_label,
220                start_column,
221                start_match_property,
222                end_label,
223                end_column,
224                end_match_property,
225                properties,
226            } => parameterized_create_for_relationship(
227                rel_type,
228                start_label,
229                start_column,
230                start_match_property,
231                end_label,
232                end_column,
233                end_match_property,
234                properties,
235            ),
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn node_with_id_and_props() {
246        let m = RowMapping::Node {
247            label: "User".into(),
248            id_column: Some("uid".into()),
249            id_property: Some("uid".into()),
250            properties: vec![ColumnSpec::identity("name"), ColumnSpec::identity("age")],
251        };
252        let cypher = m.to_cypher().unwrap();
253        assert_eq!(
254            cypher,
255            "UNWIND $rows AS r CREATE (:User {uid: r.uid, name: r.name, age: r.age})"
256        );
257    }
258
259    #[test]
260    fn node_without_id() {
261        let m = RowMapping::Node {
262            label: "Tag".into(),
263            id_column: None,
264            id_property: None,
265            properties: vec![ColumnSpec::identity("name")],
266        };
267        let cypher = m.to_cypher().unwrap();
268        assert_eq!(cypher, "UNWIND $rows AS r CREATE (:Tag {name: r.name})");
269    }
270
271    #[test]
272    fn multi_label_node() {
273        let m = RowMapping::Node {
274            label: "User:Admin".into(),
275            id_column: None,
276            id_property: None,
277            properties: vec![],
278        };
279        let cypher = m.to_cypher().unwrap();
280        assert_eq!(cypher, "UNWIND $rows AS r CREATE (:User:Admin)");
281    }
282
283    #[test]
284    fn relationship_basic() {
285        let m = RowMapping::Relationship {
286            rel_type: "FOLLOWS".into(),
287            start_label: "User".into(),
288            start_column: "src".into(),
289            start_match_property: "uid".into(),
290            end_label: "User".into(),
291            end_column: "dst".into(),
292            end_match_property: "uid".into(),
293            properties: vec![ColumnSpec::identity("since")],
294        };
295        let cypher = m.to_cypher().unwrap();
296        assert!(cypher.contains("MATCH (a:User {uid: r.src})"));
297        assert!(cypher.contains("(b:User {uid: r.dst})"));
298        assert!(cypher.contains("CREATE (a)-[:FOLLOWS {since: r.since}]->(b)"));
299    }
300
301    #[test]
302    fn rejects_identifier_with_backtick() {
303        let m = RowMapping::Node {
304            label: "Bad`Label".into(),
305            id_column: None,
306            id_property: None,
307            properties: vec![],
308        };
309        assert!(m.to_cypher().is_err());
310    }
311}