1use serde::{Deserialize, Serialize};
15
16#[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#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(tag = "kind", rename_all = "snake_case")]
43pub enum RowMapping {
44 Node {
46 label: String,
48 id_column: Option<String>,
54 id_property: Option<String>,
57 properties: Vec<ColumnSpec>,
59 },
60 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
75fn 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
111pub 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; }
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#[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 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}