1use crate::error::{GraphError, Result};
12use crate::{ast::*, GraphConfig};
13use serde::{Deserialize, Serialize};
14use snafu::Location;
15use std::collections::HashMap;
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub enum LogicalOperator {
20 ScanByLabel {
22 variable: String,
23 label: String,
24 properties: HashMap<String, PropertyValue>,
25 },
26
27 Unwind {
29 input: Option<Box<LogicalOperator>>,
31 expression: ValueExpression,
33 alias: String,
35 },
36
37 Filter {
39 input: Box<LogicalOperator>,
40 predicate: BooleanExpression,
41 },
42
43 Expand {
47 input: Box<LogicalOperator>,
49 source_variable: String,
51 target_variable: String,
53 target_label: String,
56 relationship_types: Vec<String>,
58 direction: RelationshipDirection,
60 relationship_variable: Option<String>,
62 properties: HashMap<String, PropertyValue>,
64 target_properties: HashMap<String, PropertyValue>,
66 },
67
68 VariableLengthExpand {
73 input: Box<LogicalOperator>,
75 source_variable: String,
77 target_variable: String,
79 relationship_types: Vec<String>,
81 direction: RelationshipDirection,
83 relationship_variable: Option<String>,
85 min_length: Option<u32>,
87 max_length: Option<u32>,
89 target_properties: HashMap<String, PropertyValue>,
91 },
92
93 Project {
95 input: Box<LogicalOperator>,
96 projections: Vec<ProjectionItem>,
97 },
98
99 Join {
101 left: Box<LogicalOperator>,
102 right: Box<LogicalOperator>,
103 join_type: JoinType,
104 },
105
106 Distinct { input: Box<LogicalOperator> },
108
109 Sort {
111 input: Box<LogicalOperator>,
112 sort_items: Vec<SortItem>,
113 },
114
115 Offset {
117 input: Box<LogicalOperator>,
118 offset: u64,
119 },
120
121 Limit {
123 input: Box<LogicalOperator>,
124 count: u64,
125 },
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct ProjectionItem {
131 pub expression: ValueExpression,
132 pub alias: Option<String>,
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub enum JoinType {
138 Inner,
139 Left,
140 Right,
141 Full,
142 Cross,
143}
144
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct SortItem {
148 pub expression: ValueExpression,
149 pub direction: SortDirection,
150}
151
152pub struct LogicalPlanner<'a> {
154 variables: HashMap<String, String>, config: &'a GraphConfig,
157}
158
159impl<'a> LogicalPlanner<'a> {
160 pub fn new(config: &'a GraphConfig) -> Self {
161 Self {
162 variables: HashMap::new(),
163 config,
164 }
165 }
166
167 pub fn plan(&mut self, query: &CypherQuery) -> Result<LogicalOperator> {
169 let mut plan = self.plan_reading_clauses(None, &query.reading_clauses)?;
171
172 if let Some(where_clause) = &query.where_clause {
174 plan = LogicalOperator::Filter {
175 input: Box::new(plan),
176 predicate: where_clause.expression.clone(),
177 };
178 }
179
180 if let Some(with_clause) = &query.with_clause {
182 plan = self.plan_with_clause(with_clause, plan)?;
183 }
184
185 if !query.post_with_reading_clauses.is_empty() {
187 plan = self.plan_reading_clauses(Some(plan), &query.post_with_reading_clauses)?;
188 }
189
190 if let Some(post_where) = &query.post_with_where_clause {
192 plan = LogicalOperator::Filter {
193 input: Box::new(plan),
194 predicate: post_where.expression.clone(),
195 };
196 }
197
198 plan = self.plan_return_clause(&query.return_clause, plan)?;
200
201 if let Some(order_by) = &query.order_by {
203 plan = LogicalOperator::Sort {
204 input: Box::new(plan),
205 sort_items: order_by
206 .items
207 .iter()
208 .map(|item| SortItem {
209 expression: item.expression.clone(),
210 direction: item.direction.clone(),
211 })
212 .collect(),
213 };
214 }
215
216 if let Some(skip) = query.skip {
218 plan = LogicalOperator::Offset {
219 input: Box::new(plan),
220 offset: skip,
221 };
222 }
223
224 if let Some(limit) = query.limit {
226 plan = LogicalOperator::Limit {
227 input: Box::new(plan),
228 count: limit,
229 };
230 }
231
232 Ok(plan)
233 }
234
235 fn plan_reading_clauses(
236 &mut self,
237 base_plan: Option<LogicalOperator>,
238 reading_clauses: &[ReadingClause],
239 ) -> Result<LogicalOperator> {
240 let mut plan = base_plan;
241
242 if reading_clauses.is_empty() && plan.is_none() {
243 return Err(GraphError::PlanError {
244 message: "Query must have at least one MATCH or UNWIND clause".to_string(),
245 location: snafu::Location::new(file!(), line!(), column!()),
246 });
247 }
248
249 for clause in reading_clauses {
250 plan = Some(self.plan_reading_clause_with_base(plan, clause)?);
251 }
252
253 plan.ok_or_else(|| GraphError::PlanError {
254 message: "Failed to plan clauses".to_string(),
255 location: snafu::Location::new(file!(), line!(), column!()),
256 })
257 }
258
259 fn plan_reading_clause_with_base(
261 &mut self,
262 base: Option<LogicalOperator>,
263 clause: &ReadingClause,
264 ) -> Result<LogicalOperator> {
265 match clause {
266 ReadingClause::Match(match_clause) => {
267 self.plan_match_clause_with_base(base, match_clause)
268 }
269 ReadingClause::Unwind(unwind_clause) => {
270 self.plan_unwind_clause_with_base(base, unwind_clause)
271 }
272 }
273 }
274
275 fn plan_unwind_clause_with_base(
277 &mut self,
278 base: Option<LogicalOperator>,
279 unwind_clause: &UnwindClause,
280 ) -> Result<LogicalOperator> {
281 self.variables
283 .insert(unwind_clause.alias.clone(), "Unwound".to_string());
284
285 Ok(LogicalOperator::Unwind {
286 input: base.map(Box::new),
287 expression: unwind_clause.expression.clone(),
288 alias: unwind_clause.alias.clone(),
289 })
290 }
291
292 fn plan_match_clause_with_base(
294 &mut self,
295 base: Option<LogicalOperator>,
296 match_clause: &MatchClause,
297 ) -> Result<LogicalOperator> {
298 if match_clause.patterns.is_empty() {
299 return Err(GraphError::PlanError {
300 message: "MATCH clause must have at least one pattern".to_string(),
301 location: snafu::Location::new(file!(), line!(), column!()),
302 });
303 }
304
305 let mut plan = base;
306 for pattern in &match_clause.patterns {
307 match pattern {
308 GraphPattern::Node(node) => {
309 let already_bound = node
310 .variable
311 .as_deref()
312 .is_some_and(|v| self.variables.contains_key(v));
313
314 match (already_bound, plan.as_ref()) {
315 (true, _) => { }
316 (false, None) => plan = Some(self.plan_node_scan(node)?),
317 (false, Some(_)) => {
318 let right = self.plan_node_scan(node)?;
319 plan = Some(LogicalOperator::Join {
320 left: Box::new(plan.unwrap()),
321 right: Box::new(right),
322 join_type: JoinType::Cross, });
324 }
325 }
326 }
327 GraphPattern::Path(path) => plan = Some(self.plan_path(plan, path)?),
328 }
329 }
330
331 plan.ok_or_else(|| GraphError::PlanError {
332 message: "Failed to plan MATCH clause".to_string(),
333 location: snafu::Location::new(file!(), line!(), column!()),
334 })
335 }
336
337 fn plan_node_scan(&mut self, node: &NodePattern) -> Result<LogicalOperator> {
339 let variable = node
340 .variable
341 .clone()
342 .unwrap_or_else(|| format!("_node_{}", self.variables.len()));
343
344 self.validate_variable_label(&variable, &node.labels)?;
346
347 let label = self
349 .variables
350 .get(&variable)
351 .cloned()
352 .or_else(|| node.labels.first().cloned())
353 .unwrap_or_else(|| "Node".to_string());
354
355 self.variables.insert(variable.clone(), label.clone());
357
358 Ok(LogicalOperator::ScanByLabel {
359 variable,
360 label,
361 properties: node.properties.clone(),
362 })
363 }
364
365 fn validate_variable_label(&self, variable: &str, ast_labels: &[String]) -> Result<()> {
369 if let Some(existing_label) = self.variables.get(variable) {
370 if let Some(ast_label) = ast_labels.first() {
371 if ast_label != existing_label {
372 return Err(GraphError::PlanError {
373 message: format!(
374 "Variable '{}' already has label '{}', cannot redefine as '{}'",
375 variable, existing_label, ast_label
376 ),
377 location: snafu::Location::new(file!(), line!(), column!()),
378 });
379 }
380 }
381 }
382 Ok(())
383 }
384
385 fn plan_path(
387 &mut self,
388 base: Option<LogicalOperator>,
389 path: &PathPattern,
390 ) -> Result<LogicalOperator> {
391 let mut plan = if let Some(p) = base {
393 p
394 } else {
395 self.plan_node_scan(&path.start_node)?
396 };
397
398 let mut current_src = match &path.start_node.variable {
400 Some(var) => var.clone(),
401 None => self.extract_variable_from_plan(&plan)?,
402 };
403
404 if let Some(start_var) = &path.start_node.variable {
406 self.validate_variable_label(start_var, &path.start_node.labels)?;
407 }
408
409 for segment in &path.segments {
411 let target_variable = segment
413 .end_node
414 .variable
415 .clone()
416 .unwrap_or_else(|| format!("_node_{}", self.variables.len()));
417
418 self.validate_variable_label(&target_variable, &segment.end_node.labels)?;
420
421 let target_label = self
423 .variables
424 .get(&target_variable)
425 .cloned()
426 .or_else(|| segment.end_node.labels.first().cloned())
427 .unwrap_or_else(|| "Node".to_string());
428
429 self.variables
430 .insert(target_variable.clone(), target_label.clone());
431
432 let next_plan = match segment.relationship.length.as_ref() {
434 Some(length_range)
435 if length_range.min == Some(1) && length_range.max == Some(1) =>
436 {
437 LogicalOperator::Expand {
438 input: Box::new(plan),
439 source_variable: current_src.clone(),
440 target_variable: target_variable.clone(),
441 target_label: target_label.clone(),
442 relationship_types: segment.relationship.types.clone(),
443 direction: segment.relationship.direction.clone(),
444 relationship_variable: segment.relationship.variable.clone(),
445 properties: segment.relationship.properties.clone(),
446 target_properties: segment.end_node.properties.clone(),
447 }
448 }
449 Some(length_range) => LogicalOperator::VariableLengthExpand {
450 input: Box::new(plan),
451 source_variable: current_src.clone(),
452 target_variable: target_variable.clone(),
453 relationship_types: segment.relationship.types.clone(),
454 direction: segment.relationship.direction.clone(),
455 relationship_variable: segment.relationship.variable.clone(),
456 min_length: length_range.min,
457 max_length: length_range.max,
458 target_properties: segment.end_node.properties.clone(),
459 },
460 None => LogicalOperator::Expand {
461 input: Box::new(plan),
462 source_variable: current_src.clone(),
463 target_variable: target_variable.clone(),
464 target_label: target_label.clone(),
465 relationship_types: segment.relationship.types.clone(),
466 direction: segment.relationship.direction.clone(),
467 relationship_variable: segment.relationship.variable.clone(),
468 properties: segment.relationship.properties.clone(),
469 target_properties: segment.end_node.properties.clone(),
470 },
471 };
472
473 plan = next_plan;
474 current_src = target_variable;
475 }
476
477 Ok(plan)
478 }
479
480 #[allow(clippy::only_used_in_recursion)]
482 fn extract_variable_from_plan(&self, plan: &LogicalOperator) -> Result<String> {
483 match plan {
484 LogicalOperator::ScanByLabel { variable, .. } => Ok(variable.clone()),
485 LogicalOperator::Unwind { alias, .. } => Ok(alias.clone()),
486 LogicalOperator::Expand {
487 target_variable, ..
488 } => Ok(target_variable.clone()),
489 LogicalOperator::VariableLengthExpand {
490 target_variable, ..
491 } => Ok(target_variable.clone()),
492 LogicalOperator::Filter { input, .. } => self.extract_variable_from_plan(input),
493 LogicalOperator::Project { input, .. } => self.extract_variable_from_plan(input),
494 LogicalOperator::Distinct { input } => self.extract_variable_from_plan(input),
495 LogicalOperator::Sort { input, .. } => self.extract_variable_from_plan(input),
496 LogicalOperator::Offset { input, .. } => self.extract_variable_from_plan(input),
497 LogicalOperator::Limit { input, .. } => self.extract_variable_from_plan(input),
498 LogicalOperator::Join { left, right, .. } => {
499 self.extract_variable_from_plan(right)
501 .or_else(|_| self.extract_variable_from_plan(left))
502 }
503 }
504 }
505
506 fn plan_return_clause(
508 &self,
509 return_clause: &ReturnClause,
510 input: LogicalOperator,
511 ) -> Result<LogicalOperator> {
512 let mut projections: Vec<ProjectionItem> = Vec::new();
513
514 for item in &return_clause.items {
515 let alias = &item.alias;
516 match &item.expression {
517 ValueExpression::Variable(var) => {
518 match self.variables.get(var) {
519 Some(label) if label != "Unwound" => {
521 let mapping = self.config.get_node_mapping(label).ok_or_else(|| {
522 GraphError::PlanError {
523 message: format!("Node label '{}' doesn't exist", label),
524 location: Location::new(file!(), line!(), column!()),
525 }
526 })?;
527
528 projections.push(ProjectionItem {
529 expression: ValueExpression::Property(PropertyRef {
530 variable: var.clone(),
531 property: mapping.id_field.clone(),
532 }),
533 alias: alias
534 .clone()
535 .map(|name| format!("{}.{}", name, mapping.id_field)),
536 });
537
538 for prop in &mapping.property_fields {
539 projections.push(ProjectionItem {
540 expression: ValueExpression::Property(PropertyRef {
541 variable: var.clone(),
542 property: prop.clone(),
543 }),
544 alias: alias.clone().map(|name| format!("{}.{}", name, prop)),
545 });
546 }
547 }
548 _ => {
549 projections.push(ProjectionItem {
550 expression: item.expression.clone(),
551 alias: alias.clone(),
552 });
553 }
554 }
555 }
556 _ => {
557 projections.push(ProjectionItem {
558 expression: item.expression.clone(),
559 alias: alias.clone(),
560 });
561 }
562 }
563 }
564
565 let mut plan = LogicalOperator::Project {
566 input: Box::new(input),
567 projections,
568 };
569
570 if return_clause.distinct {
572 plan = LogicalOperator::Distinct {
573 input: Box::new(plan),
574 };
575 }
576
577 Ok(plan)
578 }
579
580 fn plan_with_clause(
582 &self,
583 with_clause: &WithClause,
584 input: LogicalOperator,
585 ) -> Result<LogicalOperator> {
586 let projections = with_clause
588 .items
589 .iter()
590 .map(|item| ProjectionItem {
591 expression: item.expression.clone(),
592 alias: item.alias.clone(),
593 })
594 .collect();
595
596 let mut plan = LogicalOperator::Project {
597 input: Box::new(input),
598 projections,
599 };
600
601 if let Some(order_by) = &with_clause.order_by {
603 plan = LogicalOperator::Sort {
604 input: Box::new(plan),
605 sort_items: order_by
606 .items
607 .iter()
608 .map(|item| SortItem {
609 expression: item.expression.clone(),
610 direction: item.direction.clone(),
611 })
612 .collect(),
613 };
614 }
615
616 if let Some(limit) = with_clause.limit {
618 plan = LogicalOperator::Limit {
619 input: Box::new(plan),
620 count: limit,
621 };
622 }
623
624 Ok(plan)
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631 use crate::{parser::parse_cypher_query, NodeMapping};
632
633 #[test]
634 fn test_relationship_query_logical_plan_structure() {
635 let query_text = r#"MATCH (p:Person {name: "Alice"})-[:KNOWS]->(f:Person) WHERE f.age > 30 RETURN f.name"#;
636
637 let ast = parse_cypher_query(query_text).unwrap();
639
640 let config = GraphConfig::default();
642 let mut planner = LogicalPlanner::new(&config);
643 let logical_plan = planner.plan(&ast).unwrap();
644
645 match &logical_plan {
647 LogicalOperator::Project { input, projections } => {
648 assert_eq!(projections.len(), 1);
650 match &projections[0].expression {
651 ValueExpression::Property(prop_ref) => {
652 assert_eq!(prop_ref.variable, "f");
653 assert_eq!(prop_ref.property, "name");
654 }
655 _ => panic!("Expected property reference for f.name"),
656 }
657
658 match input.as_ref() {
660 LogicalOperator::Filter {
661 predicate,
662 input: filter_input,
663 } => {
664 match predicate {
666 BooleanExpression::Comparison {
667 left,
668 operator,
669 right,
670 } => {
671 match left {
672 ValueExpression::Property(prop_ref) => {
673 assert_eq!(prop_ref.variable, "f");
674 assert_eq!(prop_ref.property, "age");
675 }
676 _ => panic!("Expected property reference for f.age"),
677 }
678 assert_eq!(*operator, ComparisonOperator::GreaterThan);
679 match right {
680 ValueExpression::Literal(PropertyValue::Integer(val)) => {
681 assert_eq!(*val, 30);
682 }
683 _ => panic!("Expected integer literal 30"),
684 }
685 }
686 _ => panic!("Expected comparison expression"),
687 }
688
689 match filter_input.as_ref() {
691 LogicalOperator::Expand {
692 input: expand_input,
693 source_variable,
694 target_variable,
695 relationship_types,
696 direction,
697 ..
698 } => {
699 assert_eq!(source_variable, "p");
700 assert_eq!(target_variable, "f");
701 assert_eq!(relationship_types, &vec!["KNOWS".to_string()]);
702 assert_eq!(*direction, RelationshipDirection::Outgoing);
703
704 match expand_input.as_ref() {
706 LogicalOperator::ScanByLabel {
707 variable,
708 label,
709 properties,
710 } => {
711 assert_eq!(variable, "p");
712 assert_eq!(label, "Person");
713
714 assert_eq!(properties.len(), 1);
716 match properties.get("name") {
717 Some(PropertyValue::String(val)) => {
718 assert_eq!(val, "Alice");
719 }
720 _ => {
721 panic!("Expected name property with value 'Alice'")
722 }
723 }
724 }
725 _ => panic!("Expected ScanByLabel with properties for Person"),
726 }
727 }
728 _ => panic!("Expected Expand operation"),
729 }
730 }
731 _ => panic!("Expected Filter for f.age > 30"),
732 }
733 }
734 _ => panic!("Expected Project at the top level"),
735 }
736 }
737
738 #[test]
739 fn test_simple_node_query_logical_plan() {
740 let query_text = "MATCH (n:Person) RETURN n.name";
741
742 let ast = parse_cypher_query(query_text).unwrap();
743 let config = GraphConfig::default();
744 let mut planner = LogicalPlanner::new(&config);
745 let logical_plan = planner.plan(&ast).unwrap();
746
747 match &logical_plan {
749 LogicalOperator::Project { input, projections } => {
750 assert_eq!(projections.len(), 1);
751 match input.as_ref() {
752 LogicalOperator::ScanByLabel {
753 variable, label, ..
754 } => {
755 assert_eq!(variable, "n");
756 assert_eq!(label, "Person");
757 }
758 _ => panic!("Expected ScanByLabel"),
759 }
760 }
761 _ => panic!("Expected Project"),
762 }
763 }
764
765 #[test]
766 fn test_node_with_properties_logical_plan() {
767 let query_text = "MATCH (n:Person {age: 25}) RETURN n.name";
768
769 let ast = parse_cypher_query(query_text).unwrap();
770 let config = GraphConfig::default();
771 let mut planner = LogicalPlanner::new(&config);
772 let logical_plan = planner.plan(&ast).unwrap();
773
774 match &logical_plan {
777 LogicalOperator::Project { input, .. } => {
778 match input.as_ref() {
779 LogicalOperator::ScanByLabel {
780 variable,
781 label,
782 properties,
783 } => {
784 assert_eq!(variable, "n");
785 assert_eq!(label, "Person");
786
787 assert_eq!(properties.len(), 1);
789 match properties.get("age") {
790 Some(PropertyValue::Integer(25)) => {}
791 _ => panic!("Expected age property with value 25"),
792 }
793 }
794 _ => panic!("Expected ScanByLabel with properties"),
795 }
796 }
797 _ => panic!("Expected Project"),
798 }
799 }
800
801 #[test]
802 fn test_variable_length_path_logical_plan() {
803 let query_text = "MATCH (a:Person)-[:KNOWS*1..2]->(b:Person) RETURN b.name";
804
805 let ast = parse_cypher_query(query_text).unwrap();
806 let config = GraphConfig::default();
807 let mut planner = LogicalPlanner::new(&config);
808 let logical_plan = planner.plan(&ast).unwrap();
809
810 match &logical_plan {
812 LogicalOperator::Project { input, .. } => match input.as_ref() {
813 LogicalOperator::VariableLengthExpand {
814 input: expand_input,
815 source_variable,
816 target_variable,
817 relationship_types,
818 min_length,
819 max_length,
820 ..
821 } => {
822 assert_eq!(source_variable, "a");
823 assert_eq!(target_variable, "b");
824 assert_eq!(relationship_types, &vec!["KNOWS".to_string()]);
825 assert_eq!(*min_length, Some(1));
826 assert_eq!(*max_length, Some(2));
827
828 match expand_input.as_ref() {
829 LogicalOperator::ScanByLabel {
830 variable, label, ..
831 } => {
832 assert_eq!(variable, "a");
833 assert_eq!(label, "Person");
834 }
835 _ => panic!("Expected ScanByLabel"),
836 }
837 }
838 _ => panic!("Expected VariableLengthExpand"),
839 },
840 _ => panic!("Expected Project"),
841 }
842 }
843
844 #[test]
845 fn test_where_clause_logical_plan() {
846 let query_text = r#"MATCH (n:Person) WHERE n.age > 25 RETURN n.name"#;
848
849 let ast = parse_cypher_query(query_text).unwrap();
850 let config = GraphConfig::default();
851 let mut planner = LogicalPlanner::new(&config);
852 let logical_plan = planner.plan(&ast).unwrap();
853
854 match &logical_plan {
856 LogicalOperator::Project { input, .. } => {
857 match input.as_ref() {
858 LogicalOperator::Filter {
859 predicate,
860 input: scan_input,
861 } => {
862 match predicate {
864 BooleanExpression::Comparison {
865 left,
866 operator,
867 right: _,
868 } => {
869 match left {
870 ValueExpression::Property(prop_ref) => {
871 assert_eq!(prop_ref.variable, "n");
872 assert_eq!(prop_ref.property, "age");
873 }
874 _ => panic!("Expected property reference for age"),
875 }
876 assert_eq!(*operator, ComparisonOperator::GreaterThan);
877 }
878 _ => panic!("Expected comparison expression"),
879 }
880
881 match scan_input.as_ref() {
882 LogicalOperator::ScanByLabel { .. } => {}
883 _ => panic!("Expected ScanByLabel"),
884 }
885 }
886 _ => panic!("Expected Filter"),
887 }
888 }
889 _ => panic!("Expected Project"),
890 }
891 }
892
893 #[test]
894 fn test_multiple_node_patterns_join_in_match() {
895 let query_text = "MATCH (a:Person), (b:Company) RETURN a.name, b.name";
896
897 let ast = parse_cypher_query(query_text).unwrap();
898 let config = GraphConfig::default();
899 let mut planner = LogicalPlanner::new(&config);
900 let logical_plan = planner.plan(&ast).unwrap();
901
902 match &logical_plan {
904 LogicalOperator::Project { input, projections } => {
905 assert_eq!(projections.len(), 2);
906 match input.as_ref() {
907 LogicalOperator::Join {
908 left,
909 right,
910 join_type,
911 } => {
912 assert!(matches!(join_type, JoinType::Cross));
913 match left.as_ref() {
914 LogicalOperator::ScanByLabel {
915 variable, label, ..
916 } => {
917 assert_eq!(variable, "a");
918 assert_eq!(label, "Person");
919 }
920 _ => panic!("Expected left ScanByLabel for a:Person"),
921 }
922 match right.as_ref() {
923 LogicalOperator::ScanByLabel {
924 variable, label, ..
925 } => {
926 assert_eq!(variable, "b");
927 assert_eq!(label, "Company");
928 }
929 _ => panic!("Expected right ScanByLabel for b:Company"),
930 }
931 }
932 _ => panic!("Expected Join after Project"),
933 }
934 }
935 _ => panic!("Expected Project at top level"),
936 }
937 }
938
939 #[test]
940 fn test_shared_variable_chained_paths_in_match() {
941 let query_text =
942 "MATCH (a:Person)-[:KNOWS]->(b:Person), (b)-[:LIKES]->(c:Thing) RETURN c.name";
943
944 let ast = parse_cypher_query(query_text).unwrap();
945 let config = GraphConfig::default();
946 let mut planner = LogicalPlanner::new(&config);
947 let logical_plan = planner.plan(&ast).unwrap();
948
949 match &logical_plan {
951 LogicalOperator::Project { input, .. } => match input.as_ref() {
952 LogicalOperator::Expand {
953 source_variable: src2,
954 target_variable: tgt2,
955 input: inner2,
956 ..
957 } => {
958 assert_eq!(src2, "b");
959 assert_eq!(tgt2, "c");
960 match inner2.as_ref() {
961 LogicalOperator::Expand {
962 source_variable: src1,
963 target_variable: tgt1,
964 input: inner1,
965 ..
966 } => {
967 assert_eq!(src1, "a");
968 assert_eq!(tgt1, "b");
969 match inner1.as_ref() {
970 LogicalOperator::ScanByLabel {
971 variable, label, ..
972 } => {
973 assert_eq!(variable, "a");
974 assert_eq!(label, "Person");
975 }
976 _ => panic!("Expected ScanByLabel for a:Person"),
977 }
978 }
979 _ => panic!("Expected first Expand a->b"),
980 }
981 }
982 _ => panic!("Expected second Expand b->c at top of input"),
983 },
984 _ => panic!("Expected Project at top level"),
985 }
986 }
987
988 #[test]
989 fn test_fixed_length_variable_path_is_expand() {
990 let query_text = "MATCH (a:Person)-[:KNOWS*1..1]->(b:Person) RETURN b.name";
991
992 let ast = parse_cypher_query(query_text).unwrap();
993 let config = GraphConfig::default();
994 let mut planner = LogicalPlanner::new(&config);
995 let logical_plan = planner.plan(&ast).unwrap();
996
997 match &logical_plan {
998 LogicalOperator::Project { input, .. } => match input.as_ref() {
999 LogicalOperator::Expand {
1000 source_variable,
1001 target_variable,
1002 ..
1003 } => {
1004 assert_eq!(source_variable, "a");
1005 assert_eq!(target_variable, "b");
1006 }
1007 _ => panic!("Expected Expand for fixed-length *1..1"),
1008 },
1009 _ => panic!("Expected Project at top level"),
1010 }
1011 }
1012
1013 #[test]
1014 fn test_distinct_and_order_limit_wrapping() {
1015 let q1 = "MATCH (n:Person) RETURN DISTINCT n.name";
1017 let ast1 = parse_cypher_query(q1).unwrap();
1018 let config = GraphConfig::default();
1019 let mut planner = LogicalPlanner::new(&config);
1020 let logical1 = planner.plan(&ast1).unwrap();
1021 match logical1 {
1022 LogicalOperator::Distinct { input } => match *input {
1023 LogicalOperator::Project { .. } => {}
1024 _ => panic!("Expected Project under Distinct"),
1025 },
1026 _ => panic!("Expected Distinct at top level"),
1027 }
1028
1029 let q2 = "MATCH (n:Person) RETURN n.name ORDER BY n.name LIMIT 10";
1031 let ast2 = parse_cypher_query(q2).unwrap();
1032 let mut planner2 = LogicalPlanner::new(&config);
1033 let logical2 = planner2.plan(&ast2).unwrap();
1034 match logical2 {
1035 LogicalOperator::Limit { input, count } => {
1036 assert_eq!(count, 10);
1037 match *input {
1038 LogicalOperator::Sort { input: inner, .. } => match *inner {
1039 LogicalOperator::Project { .. } => {}
1040 _ => panic!("Expected Project under Sort"),
1041 },
1042 _ => panic!("Expected Sort under Limit"),
1043 }
1044 }
1045 _ => panic!("Expected Limit at top level"),
1046 }
1047 }
1048
1049 #[test]
1050 fn test_order_skip_limit_wrapping() {
1051 let q = "MATCH (n:Person) RETURN n.name ORDER BY n.name SKIP 5 LIMIT 10";
1053 let ast = parse_cypher_query(q).unwrap();
1054 let config = GraphConfig::default();
1055 let mut planner = LogicalPlanner::new(&config);
1056 let logical = planner.plan(&ast).unwrap();
1057 match logical {
1058 LogicalOperator::Limit { input, count } => {
1059 assert_eq!(count, 10);
1060 match *input {
1061 LogicalOperator::Offset {
1062 input: inner,
1063 offset,
1064 } => {
1065 assert_eq!(offset, 5);
1066 match *inner {
1067 LogicalOperator::Sort { input: inner2, .. } => match *inner2 {
1068 LogicalOperator::Project { .. } => {}
1069 _ => panic!("Expected Project under Sort"),
1070 },
1071 _ => panic!("Expected Sort under Offset"),
1072 }
1073 }
1074 _ => panic!("Expected Offset under Limit"),
1075 }
1076 }
1077 _ => panic!("Expected Limit at top level"),
1078 }
1079 }
1080
1081 #[test]
1082 fn test_skip_only_wrapping() {
1083 let q = "MATCH (n:Person) RETURN n.name SKIP 3";
1085 let ast = parse_cypher_query(q).unwrap();
1086 let config = GraphConfig::default();
1087 let mut planner = LogicalPlanner::new(&config);
1088 let logical = planner.plan(&ast).unwrap();
1089 match logical {
1090 LogicalOperator::Offset { input, offset } => {
1091 assert_eq!(offset, 3);
1092 match *input {
1093 LogicalOperator::Project { .. } => {}
1094 _ => panic!("Expected Project under Offset"),
1095 }
1096 }
1097 _ => panic!("Expected Offset at top level"),
1098 }
1099 }
1100
1101 #[test]
1102 fn test_relationship_properties_pushed_into_expand() {
1103 let q = "MATCH (a)-[:KNOWS {since: 2020}]->(b) RETURN b.name";
1104 let ast = parse_cypher_query(q).unwrap();
1105 let config = GraphConfig::default();
1106 let mut planner = LogicalPlanner::new(&config);
1107 let logical = planner.plan(&ast).unwrap();
1108 match logical {
1109 LogicalOperator::Project { input, .. } => match *input {
1110 LogicalOperator::Expand { properties, .. } => match properties.get("since") {
1111 Some(PropertyValue::Integer(2020)) => {}
1112 _ => panic!("Expected relationship property since=2020 in Expand"),
1113 },
1114 _ => panic!("Expected Expand under Project"),
1115 },
1116 _ => panic!("Expected Project at top level"),
1117 }
1118 }
1119
1120 #[test]
1121 fn test_multiple_match_clauses_cross_join() {
1122 let q = "MATCH (a:Person) MATCH (b:Company) RETURN a.name, b.name";
1123 let ast = parse_cypher_query(q).unwrap();
1124 let config = GraphConfig::default();
1125 let mut planner = LogicalPlanner::new(&config);
1126 let logical = planner.plan(&ast).unwrap();
1127 match logical {
1128 LogicalOperator::Project { input, .. } => match *input {
1129 LogicalOperator::Join {
1130 left,
1131 right,
1132 join_type,
1133 } => {
1134 assert!(matches!(join_type, JoinType::Cross));
1135 match (*left, *right) {
1136 (
1137 LogicalOperator::ScanByLabel {
1138 variable: va,
1139 label: la,
1140 ..
1141 },
1142 LogicalOperator::ScanByLabel {
1143 variable: vb,
1144 label: lb,
1145 ..
1146 },
1147 ) => {
1148 assert_eq!(va, "a");
1149 assert_eq!(la, "Person");
1150 assert_eq!(vb, "b");
1151 assert_eq!(lb, "Company");
1152 }
1153 _ => panic!("Expected two scans under Join"),
1154 }
1155 }
1156 _ => panic!("Expected Join under Project"),
1157 },
1158 _ => panic!("Expected Project at top level"),
1159 }
1160 }
1161
1162 #[test]
1163 fn test_variable_only_node_default_label() {
1164 let q = "MATCH (x) RETURN x.name";
1165 let ast = parse_cypher_query(q).unwrap();
1166 let config = GraphConfig::default();
1167 let mut planner = LogicalPlanner::new(&config);
1168 let logical = planner.plan(&ast).unwrap();
1169 match logical {
1170 LogicalOperator::Project { input, .. } => match *input {
1171 LogicalOperator::ScanByLabel {
1172 variable, label, ..
1173 } => {
1174 assert_eq!(variable, "x");
1175 assert_eq!(label, "Node");
1176 }
1177 _ => panic!("Expected ScanByLabel under Project"),
1178 },
1179 _ => panic!("Expected Project at top level"),
1180 }
1181 }
1182
1183 #[test]
1184 fn test_multi_label_node_uses_first_label() {
1185 let q = "MATCH (n:Person:Employee) RETURN n.name";
1186 let ast = parse_cypher_query(q).unwrap();
1187 let config = GraphConfig::default();
1188 let mut planner = LogicalPlanner::new(&config);
1189 let logical = planner.plan(&ast).unwrap();
1190 match logical {
1191 LogicalOperator::Project { input, .. } => match *input {
1192 LogicalOperator::ScanByLabel { label, .. } => {
1193 assert_eq!(label, "Person");
1194 }
1195 _ => panic!("Expected ScanByLabel under Project"),
1196 },
1197 _ => panic!("Expected Project at top level"),
1198 }
1199 }
1200
1201 #[test]
1202 fn test_open_ended_and_partial_var_length_ranges() {
1203 let q1 = "MATCH (a)-[:R*]->(b:Node) RETURN b.name";
1205 let ast1 = parse_cypher_query(q1).unwrap();
1206 let config = GraphConfig::default();
1207 let mut planner1 = LogicalPlanner::new(&config);
1208 let plan1 = planner1.plan(&ast1).unwrap();
1209 match plan1 {
1210 LogicalOperator::Project { input, .. } => match *input {
1211 LogicalOperator::VariableLengthExpand {
1212 min_length,
1213 max_length,
1214 ..
1215 } => {
1216 assert_eq!(min_length, None);
1217 assert_eq!(max_length, None);
1218 }
1219 _ => panic!("Expected VariableLengthExpand for *"),
1220 },
1221 _ => panic!("Expected Project at top level"),
1222 }
1223
1224 let q2 = "MATCH (a)-[:R*2..]->(b) RETURN b.name";
1226 let ast2 = parse_cypher_query(q2).unwrap();
1227 let mut planner2 = LogicalPlanner::new(&config);
1228 let plan2 = planner2.plan(&ast2).unwrap();
1229 match plan2 {
1230 LogicalOperator::Project { input, .. } => match *input {
1231 LogicalOperator::VariableLengthExpand {
1232 min_length,
1233 max_length,
1234 ..
1235 } => {
1236 assert_eq!(min_length, Some(2));
1237 assert_eq!(max_length, None);
1238 }
1239 _ => panic!("Expected VariableLengthExpand for *2.."),
1240 },
1241 _ => panic!("Expected Project at top level"),
1242 }
1243
1244 let q3 = "MATCH (a)-[:R*..3]->(b) RETURN b.name";
1246 let ast3 = parse_cypher_query(q3).unwrap();
1247 let mut planner3 = LogicalPlanner::new(&config);
1248 let plan3 = planner3.plan(&ast3).unwrap();
1249 match plan3 {
1250 LogicalOperator::Project { input, .. } => match *input {
1251 LogicalOperator::VariableLengthExpand {
1252 min_length,
1253 max_length,
1254 ..
1255 } => {
1256 assert_eq!(min_length, None);
1257 assert_eq!(max_length, Some(3));
1258 }
1259 _ => panic!("Expected VariableLengthExpand for *..3"),
1260 },
1261 _ => panic!("Expected Project at top level"),
1262 }
1263 }
1264
1265 #[test]
1266 fn test_variable_reuse_across_patterns() {
1267 let query_text =
1268 "MATCH (a:Person)-[:KNOWS]->(shared:Person), (shared)-[:KNOWS]->(b:Person) RETURN b.name";
1269
1270 let ast = parse_cypher_query(query_text).unwrap();
1271 let config = GraphConfig::default();
1272 let mut planner = LogicalPlanner::new(&config);
1273 let logical_plan = planner.plan(&ast).unwrap();
1274
1275 match &logical_plan {
1277 LogicalOperator::Project { input, .. } => match input.as_ref() {
1278 LogicalOperator::Expand {
1279 input: inner,
1280 source_variable,
1281 target_variable,
1282 ..
1283 } => {
1284 assert_eq!(source_variable, "shared");
1285 assert_eq!(target_variable, "b");
1286
1287 match inner.as_ref() {
1288 LogicalOperator::Expand {
1289 source_variable: first_src,
1290 target_variable: first_dst,
1291 ..
1292 } => {
1293 assert_eq!(first_src, "a");
1294 assert_eq!(first_dst, "shared");
1295 }
1296 _ => panic!("Expected first Expand (a->shared)"),
1297 }
1298 }
1299 _ => panic!("Expected second Expand (shared->b)"),
1300 },
1301 _ => panic!("Expected Project at top level"),
1302 }
1303 }
1304
1305 #[test]
1306 fn test_variable_reuse_with_conflicting_labels() {
1307 let query_text =
1308 "MATCH (a:Person)-[:KNOWS]->(shared:Person), (shared:Company)-[:EMPLOYS]->(b:Person) RETURN b.name";
1309
1310 let ast = parse_cypher_query(query_text).unwrap();
1311 let config = GraphConfig::default();
1312 let mut planner = LogicalPlanner::new(&config);
1313 let err = planner.plan(&ast).unwrap_err();
1314 let err_msg = err.to_string();
1315
1316 assert!(
1317 err_msg.contains("already has label 'Person'")
1318 && err_msg.contains("cannot redefine as 'Company'"),
1319 "Expected error about label conflict, got: {}",
1320 err_msg
1321 );
1322 }
1323
1324 #[test]
1325 fn test_return_node_variable() {
1326 let query_text = "MATCH (a:Person) RETURN a";
1327
1328 let ast = parse_cypher_query(query_text).unwrap();
1329 let config = GraphConfig::builder()
1330 .with_node_mapping(NodeMapping {
1331 label: "Person".to_string(),
1332 id_field: "id".to_string(),
1333 property_fields: vec!["name".to_string(), "age".to_string()],
1334 filter_conditions: None,
1335 })
1336 .build()
1337 .unwrap();
1338 let mut planner = LogicalPlanner::new(&config);
1339 let logical_plan = planner.plan(&ast).unwrap();
1340
1341 match &logical_plan {
1342 LogicalOperator::Project { projections, .. } => {
1343 assert_eq!(projections.len(), 3);
1344 match &projections[0].expression {
1345 ValueExpression::Property(prop_ref) => {
1346 assert_eq!(prop_ref.variable, "a");
1347 assert_eq!(prop_ref.property, "id");
1348 }
1349 _ => panic!("Expected property reference for a.id"),
1350 }
1351 match &projections[1].expression {
1352 ValueExpression::Property(prop_ref) => {
1353 assert_eq!(prop_ref.variable, "a");
1354 assert_eq!(prop_ref.property, "name");
1355 }
1356 _ => panic!("Expected property reference for a.name"),
1357 }
1358 match &projections[2].expression {
1359 ValueExpression::Property(prop_ref) => {
1360 assert_eq!(prop_ref.variable, "a");
1361 assert_eq!(prop_ref.property, "age");
1362 }
1363 _ => panic!("Expected property reference for a.age"),
1364 }
1365 }
1366 _ => panic!("Expected Project at the top level"),
1367 }
1368 }
1369
1370 #[test]
1371 fn test_return_node_variable_with_alias() {
1372 let query_text = "MATCH (a:Person) RETURN a AS b";
1373
1374 let ast = parse_cypher_query(query_text).unwrap();
1375 let config = GraphConfig::builder()
1376 .with_node_label("Person", "id")
1377 .build()
1378 .unwrap();
1379 let mut planner = LogicalPlanner::new(&config);
1380 let logical_plan = planner.plan(&ast).unwrap();
1381
1382 match &logical_plan {
1383 LogicalOperator::Project { projections, .. } => {
1384 assert_eq!(projections.len(), 1);
1385 match &projections[0].expression {
1386 ValueExpression::Property(prop_ref) => {
1387 assert_eq!(prop_ref.variable, "a");
1388 assert_eq!(prop_ref.property, "id");
1389 }
1390 _ => panic!("Expected property reference for a.id"),
1391 }
1392 match &projections[0].alias {
1393 Some(alias) => assert_eq!(alias, "b.id"),
1394 None => panic!("Expected alias for a.id as b.id"),
1395 }
1396 }
1397 _ => panic!("Expected Project at the top level"),
1398 }
1399 }
1400
1401 #[test]
1402 fn test_return_node_variable_no_label() {
1403 let query_text = "MATCH (a:Person) RETURN a";
1404
1405 let ast = parse_cypher_query(query_text).unwrap();
1406 let config = GraphConfig::default();
1407 let mut planner = LogicalPlanner::new(&config);
1408 let err = planner.plan(&ast).unwrap_err();
1409 let err_msg = err.to_string();
1410
1411 assert!(
1412 err_msg.contains("Node label 'Person' doesn't exist"),
1413 "Expected error about missing label 'Person', got: {}",
1414 err_msg
1415 );
1416 }
1417}