1use crate::error::GraphError;
11use std::collections::HashMap;
12
13#[derive(Debug, Clone)]
14#[non_exhaustive]
15pub enum ParsedQuery {
16 MatchNode {
17 alias: String,
18 label: Option<String>,
19 where_clause: Option<WhereClause>,
20 return_items: Vec<ReturnItem>,
21 },
22 MatchRelationship {
23 from: NodePattern,
24 rel: RelPattern,
25 to: NodePattern,
26 return_items: Vec<ReturnItem>,
27 },
28 CreateNode {
29 alias: String,
30 label: String,
31 properties: Vec<(String, String)>,
32 },
33 MergeNode {
34 alias: String,
35 label: String,
36 properties: Vec<(String, String)>,
37 },
38 Delete {
39 alias: String,
40 },
41 Set {
42 alias: String,
43 prop: String,
44 param_name: String,
45 },
46}
47
48#[derive(Debug, Clone)]
49pub struct NodePattern {
50 pub alias: String,
51 pub label: Option<String>,
52}
53
54#[derive(Debug, Clone)]
55pub struct RelPattern {
56 pub alias: String,
57 pub rel_type: Option<String>,
58}
59
60#[derive(Debug, Clone)]
61pub struct WhereClause {
62 pub alias: String,
63 pub prop: String,
64 pub param_name: String,
65}
66
67#[derive(Debug, Clone)]
68pub enum ReturnItem {
69 Node(String),
70 Relationship(String),
71 Count(String),
72}
73
74pub struct CypherSubsetParser;
75
76impl CypherSubsetParser {
77 pub fn parse(
78 cypher: &str,
79 _params: &HashMap<String, serde_json::Value>,
80 ) -> Result<ParsedQuery, GraphError> {
81 let cypher = cypher.trim();
82
83 if cypher.is_empty() {
84 return Err(GraphError::QueryError("empty query".into()));
85 }
86
87 let upper = cypher.to_uppercase();
88
89 if upper.contains("SELECT") {
90 return Err(GraphError::SqlNotSupported(
91 "SQL passthrough is not supported in graph query".into(),
92 ));
93 }
94
95 if upper.starts_with("CREATE") {
96 let after = cypher[6..].trim_start();
97 return Self::parse_create(after);
98 }
99 if upper.starts_with("MERGE") {
100 let after = cypher[5..].trim_start();
101 return Self::parse_merge(after);
102 }
103 if upper.starts_with("DELETE") {
104 let after = cypher[6..].trim_start();
105 return Self::parse_delete(after);
106 }
107 if upper.starts_with("SET") {
108 let after = cypher[3..].trim_start();
109 return Self::parse_set(after);
110 }
111
112 if !upper.starts_with("MATCH") {
113 return Err(GraphError::QueryError(format!(
114 "unsupported syntax: query must start with MATCH/CREATE/MERGE/DELETE/SET, got: {}",
115 &cypher[..cypher.len().min(20)]
116 )));
117 }
118
119 let after_match = cypher[5..].trim_start();
120
121 if let Some(arrow_pos) = after_match.find("]->") {
122 return Self::parse_relationship(after_match, arrow_pos);
123 }
124
125 Self::parse_node(after_match)
126 }
127
128 fn parse_node(rest: &str) -> Result<ParsedQuery, GraphError> {
129 let (node_pattern, after_node) = Self::parse_node_pattern(rest)?;
130 let mut where_clause = None;
131 let mut after_where = after_node;
132
133 let after_trimmed = after_node.trim_start();
134 if after_trimmed.to_uppercase().starts_with("WHERE") {
135 let where_rest = after_trimmed[5..].trim_start();
136 let (wc, rest) = Self::parse_where(where_rest)?;
137 where_clause = Some(wc);
138 after_where = rest;
139 }
140
141 let return_rest = after_where.trim_start();
142 let return_items = Self::parse_return(return_rest)?;
143
144 Ok(ParsedQuery::MatchNode {
145 alias: node_pattern.alias,
146 label: node_pattern.label,
147 where_clause,
148 return_items,
149 })
150 }
151
152 fn parse_relationship(rest: &str, _arrow_pos: usize) -> Result<ParsedQuery, GraphError> {
153 let (from, after_from) = Self::parse_node_pattern(rest)?;
154
155 let after_from_trimmed = after_from.trim_start();
156 if !after_from_trimmed.starts_with('-') {
157 return Err(GraphError::QueryError(
158 "expected '-' after node pattern in relationship".into(),
159 ));
160 }
161
162 let bracket_start = after_from_trimmed.find('[').ok_or_else(|| {
163 GraphError::QueryError("expected '[' for relationship pattern".into())
164 })?;
165 let bracket_end = after_from_trimmed.find(']').ok_or_else(|| {
166 GraphError::QueryError("expected ']' for relationship pattern".into())
167 })?;
168
169 let rel_content = &after_from_trimmed[bracket_start + 1..bracket_end];
170 let rel_pattern = Self::parse_rel_pattern(rel_content)?;
171
172 let after_bracket = &after_from_trimmed[bracket_end + 1..];
173 let after_arrow = after_bracket.trim_start_matches(['-', '>']).trim_start();
174
175 let (to, after_to) = Self::parse_node_pattern(after_arrow)?;
176
177 let return_rest = after_to.trim_start();
178 let return_items = Self::parse_return(return_rest)?;
179
180 Ok(ParsedQuery::MatchRelationship {
181 from,
182 rel: rel_pattern,
183 to,
184 return_items,
185 })
186 }
187
188 fn parse_node_pattern(rest: &str) -> Result<(NodePattern, &str), GraphError> {
189 let rest = rest.trim_start();
190 if !rest.starts_with('(') {
191 return Err(GraphError::QueryError(
192 "expected '(' for node pattern".into(),
193 ));
194 }
195
196 let close_pos = rest
197 .find(')')
198 .ok_or_else(|| GraphError::QueryError("expected ')' for node pattern".into()))?;
199
200 let content = &rest[1..close_pos];
201 let after = &rest[close_pos + 1..];
202
203 let (alias, label) = if let Some(colon_pos) = content.find(':') {
204 let alias = content[..colon_pos].trim().to_string();
205 let label_part = &content[colon_pos + 1..];
206 let label = if let Some(brace_pos) = label_part.find('{') {
207 label_part[..brace_pos].trim().to_string()
208 } else {
209 label_part.trim().to_string()
210 };
211 (alias, Some(label))
212 } else {
213 (content.trim().to_string(), None)
214 };
215
216 if alias.is_empty() {
217 return Err(GraphError::QueryError(
218 "node pattern alias cannot be empty".into(),
219 ));
220 }
221
222 Ok((NodePattern { alias, label }, after))
223 }
224
225 fn parse_rel_pattern(content: &str) -> Result<RelPattern, GraphError> {
226 let content = content.trim();
227 let (alias, rel_type) = if let Some(colon_pos) = content.find(':') {
228 let alias = content[..colon_pos].trim().to_string();
229 let rel_type = content[colon_pos + 1..].trim().to_string();
230 (alias, Some(rel_type))
231 } else {
232 (content.to_string(), None)
233 };
234
235 Ok(RelPattern { alias, rel_type })
236 }
237
238 fn parse_where(rest: &str) -> Result<(WhereClause, &str), GraphError> {
239 let eq_pos = rest
240 .find('=')
241 .ok_or_else(|| GraphError::QueryError("WHERE clause must contain '='".into()))?;
242
243 let left = rest[..eq_pos].trim();
244 let right_start = rest[eq_pos + 1..].trim_start();
245
246 let param_match = right_start.find('$').ok_or_else(|| {
247 GraphError::QueryError("WHERE clause must use $param parameter".into())
248 })?;
249
250 let param_rest = &right_start[param_match + 1..];
251 let param_end = param_rest
252 .find(|c: char| c.is_whitespace())
253 .unwrap_or(param_rest.len());
254 let param_name = param_rest[..param_end].to_string();
255
256 let after_param = ¶m_rest[param_end..];
257
258 let dot_pos = left.find('.').ok_or_else(|| {
259 GraphError::QueryError("WHERE clause must use alias.prop format".into())
260 })?;
261
262 let alias = left[..dot_pos].trim().to_string();
263 let prop = left[dot_pos + 1..].trim().to_string();
264
265 Ok((
266 WhereClause {
267 alias,
268 prop,
269 param_name,
270 },
271 after_param,
272 ))
273 }
274
275 fn parse_return(rest: &str) -> Result<Vec<ReturnItem>, GraphError> {
276 let rest = rest.trim_start();
277 let upper = rest.to_uppercase();
278 if !upper.starts_with("RETURN") {
279 return Err(GraphError::QueryError("expected RETURN clause".into()));
280 }
281
282 let return_rest = rest[6..].trim();
283 let mut items = Vec::new();
284
285 for part in return_rest.split(',') {
286 let part = part.trim();
287 if part.is_empty() {
288 continue;
289 }
290 if part.to_uppercase().starts_with("COUNT(") {
291 let inner_start = part.find('(').unwrap();
292 let inner_end = part.find(')').unwrap();
293 let alias = part[inner_start + 1..inner_end].trim().to_string();
294 items.push(ReturnItem::Count(alias));
295 } else {
296 items.push(ReturnItem::Node(part.to_string()));
297 }
298 }
299
300 if items.is_empty() {
301 return Err(GraphError::QueryError(
302 "RETURN clause must have at least one item".into(),
303 ));
304 }
305
306 Ok(items)
307 }
308
309 fn parse_create(rest: &str) -> Result<ParsedQuery, GraphError> {
310 let (node_pattern, properties) = Self::parse_node_with_properties(rest)?;
311 let label = node_pattern.label.ok_or_else(|| {
312 GraphError::QueryError(
313 "CREATE requires a node label, e.g. CREATE (n:Label {...})".into(),
314 )
315 })?;
316 Ok(ParsedQuery::CreateNode {
317 alias: node_pattern.alias,
318 label,
319 properties,
320 })
321 }
322
323 fn parse_merge(rest: &str) -> Result<ParsedQuery, GraphError> {
324 let (node_pattern, properties) = Self::parse_node_with_properties(rest)?;
325 let label = node_pattern.label.ok_or_else(|| {
326 GraphError::QueryError("MERGE requires a node label, e.g. MERGE (n:Label {...})".into())
327 })?;
328 Ok(ParsedQuery::MergeNode {
329 alias: node_pattern.alias,
330 label,
331 properties,
332 })
333 }
334
335 fn parse_delete(rest: &str) -> Result<ParsedQuery, GraphError> {
336 let rest = rest.trim();
337 if rest.is_empty() {
338 return Err(GraphError::QueryError(
339 "DELETE requires an alias, e.g. DELETE n".into(),
340 ));
341 }
342 let alias = rest
343 .split(|c: char| c.is_whitespace())
344 .next()
345 .unwrap_or("")
346 .to_string();
347 if alias.is_empty() {
348 return Err(GraphError::QueryError(
349 "DELETE alias cannot be empty".into(),
350 ));
351 }
352 Ok(ParsedQuery::Delete { alias })
353 }
354
355 fn parse_set(rest: &str) -> Result<ParsedQuery, GraphError> {
356 let rest = rest.trim();
357 let eq_pos = rest.find('=').ok_or_else(|| {
358 GraphError::QueryError("SET clause must contain '=', e.g. SET n.prop = $param".into())
359 })?;
360
361 let left = rest[..eq_pos].trim();
362 let right = rest[eq_pos + 1..].trim();
363
364 if !right.starts_with('$') {
365 return Err(GraphError::ParameterizationError(
366 "SET clause must use $param parameter, literal values are not allowed".into(),
367 ));
368 }
369 let param_name = right[1..].trim().to_string();
370 if param_name.is_empty() {
371 return Err(GraphError::QueryError(
372 "SET clause param name cannot be empty".into(),
373 ));
374 }
375
376 let dot_pos = left.find('.').ok_or_else(|| {
377 GraphError::QueryError("SET clause must use alias.prop format".into())
378 })?;
379 let alias = left[..dot_pos].trim().to_string();
380 let prop = left[dot_pos + 1..].trim().to_string();
381
382 if alias.is_empty() || prop.is_empty() {
383 return Err(GraphError::QueryError(
384 "SET clause alias and prop cannot be empty".into(),
385 ));
386 }
387
388 Ok(ParsedQuery::Set {
389 alias,
390 prop,
391 param_name,
392 })
393 }
394
395 fn parse_properties(content: &str) -> Result<Vec<(String, String)>, GraphError> {
396 let content = content.trim();
397 if content.is_empty() {
398 return Ok(Vec::new());
399 }
400 if !content.starts_with('{') || !content.ends_with('}') {
401 return Err(GraphError::QueryError(
402 "properties must be enclosed in {}, e.g. {prop: $param}".into(),
403 ));
404 }
405 let inner = &content[1..content.len() - 1];
406 let mut props = Vec::new();
407 for part in inner.split(',') {
408 let part = part.trim();
409 if part.is_empty() {
410 continue;
411 }
412 let colon_pos = part.find(':').ok_or_else(|| {
413 GraphError::QueryError("property must be prop: $param format".into())
414 })?;
415 let prop_name = part[..colon_pos].trim().to_string();
416 let param_part = part[colon_pos + 1..].trim();
417 if !param_part.starts_with('$') {
418 return Err(GraphError::ParameterizationError(format!(
419 "property {} must use $param parameter, literal values are not allowed",
420 prop_name
421 )));
422 }
423 let param_name = param_part[1..].trim().to_string();
424 if prop_name.is_empty() || param_name.is_empty() {
425 return Err(GraphError::QueryError(
426 "property name and param name cannot be empty".into(),
427 ));
428 }
429 props.push((prop_name, param_name));
430 }
431 Ok(props)
432 }
433
434 fn parse_node_with_properties(
435 rest: &str,
436 ) -> Result<(NodePattern, Vec<(String, String)>), GraphError> {
437 let rest = rest.trim_start();
438 if !rest.starts_with('(') {
439 return Err(GraphError::QueryError(
440 "expected '(' for node pattern, e.g. (alias:Label {prop: $param})".into(),
441 ));
442 }
443 let close_pos = rest
444 .find(')')
445 .ok_or_else(|| GraphError::QueryError("expected ')' for node pattern".into()))?;
446
447 let content = &rest[1..close_pos];
448 let (alias, label, properties) = if let Some(brace_pos) = content.find('{') {
449 let before_brace = &content[..brace_pos];
450 let props_str = &content[brace_pos..];
451 let props = Self::parse_properties(props_str)?;
452 let colon_pos = before_brace
453 .find(':')
454 .ok_or_else(|| GraphError::QueryError("node pattern requires :Label".into()))?;
455 let alias = before_brace[..colon_pos].trim().to_string();
456 let label = before_brace[colon_pos + 1..].trim().to_string();
457 if alias.is_empty() || label.is_empty() {
458 return Err(GraphError::QueryError(
459 "alias and label cannot be empty in node pattern".into(),
460 ));
461 }
462 (alias, Some(label), props)
463 } else {
464 let colon_pos = content
465 .find(':')
466 .ok_or_else(|| GraphError::QueryError("node pattern requires :Label".into()))?;
467 let alias = content[..colon_pos].trim().to_string();
468 let label = content[colon_pos + 1..].trim().to_string();
469 if alias.is_empty() || label.is_empty() {
470 return Err(GraphError::QueryError(
471 "alias and label cannot be empty in node pattern".into(),
472 ));
473 }
474 (alias, Some(label), Vec::new())
475 };
476
477 Ok((NodePattern { alias, label }, properties))
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 fn empty_params() -> HashMap<String, serde_json::Value> {
486 HashMap::new()
487 }
488
489 #[test]
490 fn test_parse_match_node_label() {
491 let q = CypherSubsetParser::parse("MATCH (n:Person) RETURN n", &empty_params()).unwrap();
492 match q {
493 ParsedQuery::MatchNode { alias, label, .. } => {
494 assert_eq!(alias, "n");
495 assert_eq!(label.as_deref(), Some("Person"));
496 }
497 _ => panic!("expected MatchNode"),
498 }
499 }
500
501 #[test]
502 fn test_parse_match_node_where_param() {
503 let q = CypherSubsetParser::parse(
504 "MATCH (n:Person) WHERE n.name = $name RETURN n",
505 &empty_params(),
506 )
507 .unwrap();
508 match q {
509 ParsedQuery::MatchNode { where_clause, .. } => {
510 let wc = where_clause.unwrap();
511 assert_eq!(wc.alias, "n");
512 assert_eq!(wc.prop, "name");
513 assert_eq!(wc.param_name, "name");
514 }
515 _ => panic!("expected MatchNode"),
516 }
517 }
518
519 #[test]
520 fn test_parse_match_relationship() {
521 let q = CypherSubsetParser::parse(
522 "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a, r, b",
523 &empty_params(),
524 )
525 .unwrap();
526 match q {
527 ParsedQuery::MatchRelationship { from, rel, to, .. } => {
528 assert_eq!(from.alias, "a");
529 assert_eq!(from.label.as_deref(), Some("Person"));
530 assert_eq!(rel.rel_type.as_deref(), Some("KNOWS"));
531 assert_eq!(to.alias, "b");
532 }
533 _ => panic!("expected MatchRelationship"),
534 }
535 }
536
537 #[test]
538 fn test_parse_count_aggregation() {
539 let q =
540 CypherSubsetParser::parse("MATCH (n:Person) RETURN count(n)", &empty_params()).unwrap();
541 match q {
542 ParsedQuery::MatchNode { return_items, .. } => {
543 assert!(matches!(return_items[0], ReturnItem::Count(_)));
544 }
545 _ => panic!("expected MatchNode"),
546 }
547 }
548
549 #[test]
550 fn test_parse_create_node() {
551 let q =
552 CypherSubsetParser::parse("CREATE (n:Person {name: $name})", &empty_params()).unwrap();
553 match q {
554 ParsedQuery::CreateNode {
555 alias,
556 label,
557 properties,
558 } => {
559 assert_eq!(alias, "n");
560 assert_eq!(label, "Person");
561 assert_eq!(properties, vec![("name".to_string(), "name".to_string())]);
562 }
563 _ => panic!("expected CreateNode"),
564 }
565 }
566
567 #[test]
568 fn test_parse_merge_node() {
569 let q =
570 CypherSubsetParser::parse("MERGE (n:Person {name: $name, age: $age})", &empty_params())
571 .unwrap();
572 match q {
573 ParsedQuery::MergeNode {
574 alias,
575 label,
576 properties,
577 } => {
578 assert_eq!(alias, "n");
579 assert_eq!(label, "Person");
580 assert_eq!(properties.len(), 2);
581 }
582 _ => panic!("expected MergeNode"),
583 }
584 }
585
586 #[test]
587 fn test_parse_delete() {
588 let q = CypherSubsetParser::parse("DELETE n", &empty_params()).unwrap();
589 match q {
590 ParsedQuery::Delete { alias } => {
591 assert_eq!(alias, "n");
592 }
593 _ => panic!("expected Delete"),
594 }
595 }
596
597 #[test]
598 fn test_parse_set() {
599 let q = CypherSubsetParser::parse("SET n.name = $name", &empty_params()).unwrap();
600 match q {
601 ParsedQuery::Set {
602 alias,
603 prop,
604 param_name,
605 } => {
606 assert_eq!(alias, "n");
607 assert_eq!(prop, "name");
608 assert_eq!(param_name, "name");
609 }
610 _ => panic!("expected Set"),
611 }
612 }
613
614 #[test]
615 fn test_parse_set_reject_literal() {
616 let result = CypherSubsetParser::parse("SET n.name = \"Alice\"", &empty_params());
617 assert!(result.is_err());
618 assert!(matches!(
619 result.unwrap_err(),
620 GraphError::ParameterizationError(_)
621 ));
622 }
623
624 #[test]
625 fn test_parse_create_reject_literal() {
626 let result =
627 CypherSubsetParser::parse("CREATE (n:Person {name: \"Alice\"})", &empty_params());
628 assert!(result.is_err());
629 assert!(matches!(
630 result.unwrap_err(),
631 GraphError::ParameterizationError(_)
632 ));
633 }
634
635 #[test]
636 fn test_parse_reject_delete() {
637 let result = CypherSubsetParser::parse("MATCH (n) DELETE n", &empty_params());
638 assert!(result.is_err());
639 }
640
641 #[test]
642 fn test_parse_reject_sql() {
643 let result = CypherSubsetParser::parse("SELECT * FROM users", &empty_params());
644 assert!(result.is_err());
645 assert!(matches!(
646 result.unwrap_err(),
647 GraphError::SqlNotSupported(_)
648 ));
649 }
650
651 #[test]
652 fn test_parse_reject_no_match() {
653 let result = CypherSubsetParser::parse("RETURN n", &empty_params());
654 assert!(result.is_err());
655 }
656}