1use crate::ast::*;
12use crate::error::{GraphError, Result};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub enum LogicalOperator {
19 ScanByLabel {
21 variable: String,
22 label: String,
23 properties: HashMap<String, PropertyValue>,
24 },
25
26 Unwind {
28 input: Option<Box<LogicalOperator>>,
30 expression: ValueExpression,
32 alias: String,
34 },
35
36 Filter {
38 input: Box<LogicalOperator>,
39 predicate: BooleanExpression,
40 },
41
42 Expand {
46 input: Box<LogicalOperator>,
48 source_variable: String,
50 target_variable: String,
52 target_label: String,
55 relationship_types: Vec<String>,
57 direction: RelationshipDirection,
59 relationship_variable: Option<String>,
61 properties: HashMap<String, PropertyValue>,
63 target_properties: HashMap<String, PropertyValue>,
65 },
66
67 VariableLengthExpand {
72 input: Box<LogicalOperator>,
74 source_variable: String,
76 target_variable: String,
78 relationship_types: Vec<String>,
80 direction: RelationshipDirection,
82 relationship_variable: Option<String>,
84 min_length: Option<u32>,
86 max_length: Option<u32>,
88 target_properties: HashMap<String, PropertyValue>,
90 },
91
92 Project {
94 input: Box<LogicalOperator>,
95 projections: Vec<ProjectionItem>,
96 },
97
98 Join {
100 left: Box<LogicalOperator>,
101 right: Box<LogicalOperator>,
102 join_type: JoinType,
103 },
104
105 Distinct { input: Box<LogicalOperator> },
107
108 Sort {
110 input: Box<LogicalOperator>,
111 sort_items: Vec<SortItem>,
112 },
113
114 Offset {
116 input: Box<LogicalOperator>,
117 offset: u64,
118 },
119
120 Limit {
122 input: Box<LogicalOperator>,
123 count: u64,
124 },
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct ProjectionItem {
130 pub expression: ValueExpression,
131 pub alias: Option<String>,
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub enum JoinType {
137 Inner,
138 Left,
139 Right,
140 Full,
141 Cross,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct SortItem {
147 pub expression: ValueExpression,
148 pub direction: SortDirection,
149}
150
151pub struct LogicalPlanner {
153 variables: HashMap<String, String>, }
156
157impl LogicalPlanner {
158 pub fn new() -> Self {
159 Self {
160 variables: HashMap::new(),
161 }
162 }
163
164 pub fn plan(&mut self, query: &CypherQuery) -> Result<LogicalOperator> {
166 let mut plan = self.plan_reading_clauses(None, &query.reading_clauses)?;
168
169 if let Some(where_clause) = &query.where_clause {
171 plan = LogicalOperator::Filter {
172 input: Box::new(plan),
173 predicate: where_clause.expression.clone(),
174 };
175 }
176
177 if let Some(with_clause) = &query.with_clause {
179 plan = self.plan_with_clause(with_clause, plan)?;
180 }
181
182 if !query.post_with_reading_clauses.is_empty() {
184 plan = self.plan_reading_clauses(Some(plan), &query.post_with_reading_clauses)?;
185 }
186
187 if let Some(post_where) = &query.post_with_where_clause {
189 plan = LogicalOperator::Filter {
190 input: Box::new(plan),
191 predicate: post_where.expression.clone(),
192 };
193 }
194
195 plan = self.plan_return_clause(&query.return_clause, plan)?;
197
198 if let Some(order_by) = &query.order_by {
200 plan = LogicalOperator::Sort {
201 input: Box::new(plan),
202 sort_items: order_by
203 .items
204 .iter()
205 .map(|item| SortItem {
206 expression: item.expression.clone(),
207 direction: item.direction.clone(),
208 })
209 .collect(),
210 };
211 }
212
213 if let Some(skip) = query.skip {
215 plan = LogicalOperator::Offset {
216 input: Box::new(plan),
217 offset: skip,
218 };
219 }
220
221 if let Some(limit) = query.limit {
223 plan = LogicalOperator::Limit {
224 input: Box::new(plan),
225 count: limit,
226 };
227 }
228
229 Ok(plan)
230 }
231
232 fn plan_reading_clauses(
233 &mut self,
234 base_plan: Option<LogicalOperator>,
235 reading_clauses: &[ReadingClause],
236 ) -> Result<LogicalOperator> {
237 let mut plan = base_plan;
238
239 if reading_clauses.is_empty() && plan.is_none() {
240 return Err(GraphError::PlanError {
241 message: "Query must have at least one MATCH or UNWIND clause".to_string(),
242 location: snafu::Location::new(file!(), line!(), column!()),
243 });
244 }
245
246 for clause in reading_clauses {
247 plan = Some(self.plan_reading_clause_with_base(plan, clause)?);
248 }
249
250 plan.ok_or_else(|| GraphError::PlanError {
251 message: "Failed to plan clauses".to_string(),
252 location: snafu::Location::new(file!(), line!(), column!()),
253 })
254 }
255
256 fn plan_reading_clause_with_base(
258 &mut self,
259 base: Option<LogicalOperator>,
260 clause: &ReadingClause,
261 ) -> Result<LogicalOperator> {
262 match clause {
263 ReadingClause::Match(match_clause) => {
264 self.plan_match_clause_with_base(base, match_clause)
265 }
266 ReadingClause::Unwind(unwind_clause) => {
267 self.plan_unwind_clause_with_base(base, unwind_clause)
268 }
269 }
270 }
271
272 fn plan_unwind_clause_with_base(
274 &mut self,
275 base: Option<LogicalOperator>,
276 unwind_clause: &UnwindClause,
277 ) -> Result<LogicalOperator> {
278 self.variables
280 .insert(unwind_clause.alias.clone(), "Unwound".to_string());
281
282 Ok(LogicalOperator::Unwind {
283 input: base.map(Box::new),
284 expression: unwind_clause.expression.clone(),
285 alias: unwind_clause.alias.clone(),
286 })
287 }
288
289 fn plan_match_clause_with_base(
291 &mut self,
292 base: Option<LogicalOperator>,
293 match_clause: &MatchClause,
294 ) -> Result<LogicalOperator> {
295 if match_clause.patterns.is_empty() {
296 return Err(GraphError::PlanError {
297 message: "MATCH clause must have at least one pattern".to_string(),
298 location: snafu::Location::new(file!(), line!(), column!()),
299 });
300 }
301
302 let mut plan = base;
303 for pattern in &match_clause.patterns {
304 match pattern {
305 GraphPattern::Node(node) => {
306 let already_bound = node
307 .variable
308 .as_deref()
309 .is_some_and(|v| self.variables.contains_key(v));
310
311 match (already_bound, plan.as_ref()) {
312 (true, _) => { }
313 (false, None) => plan = Some(self.plan_node_scan(node)?),
314 (false, Some(_)) => {
315 let right = self.plan_node_scan(node)?;
316 plan = Some(LogicalOperator::Join {
317 left: Box::new(plan.unwrap()),
318 right: Box::new(right),
319 join_type: JoinType::Cross, });
321 }
322 }
323 }
324 GraphPattern::Path(path) => plan = Some(self.plan_path(plan, path)?),
325 }
326 }
327
328 plan.ok_or_else(|| GraphError::PlanError {
329 message: "Failed to plan MATCH clause".to_string(),
330 location: snafu::Location::new(file!(), line!(), column!()),
331 })
332 }
333
334 fn plan_node_scan(&mut self, node: &NodePattern) -> Result<LogicalOperator> {
336 let variable = node
337 .variable
338 .clone()
339 .unwrap_or_else(|| format!("_node_{}", self.variables.len()));
340
341 self.validate_variable_label(&variable, &node.labels)?;
343
344 let label = self
346 .variables
347 .get(&variable)
348 .cloned()
349 .or_else(|| node.labels.first().cloned())
350 .unwrap_or_else(|| "Node".to_string());
351
352 self.variables.insert(variable.clone(), label.clone());
354
355 Ok(LogicalOperator::ScanByLabel {
356 variable,
357 label,
358 properties: node.properties.clone(),
359 })
360 }
361
362 fn validate_variable_label(&self, variable: &str, ast_labels: &[String]) -> Result<()> {
366 if let Some(existing_label) = self.variables.get(variable) {
367 if let Some(ast_label) = ast_labels.first() {
368 if ast_label != existing_label {
369 return Err(GraphError::PlanError {
370 message: format!(
371 "Variable '{}' already has label '{}', cannot redefine as '{}'",
372 variable, existing_label, ast_label
373 ),
374 location: snafu::Location::new(file!(), line!(), column!()),
375 });
376 }
377 }
378 }
379 Ok(())
380 }
381
382 fn plan_path(
384 &mut self,
385 base: Option<LogicalOperator>,
386 path: &PathPattern,
387 ) -> Result<LogicalOperator> {
388 let mut plan = if let Some(p) = base {
390 p
391 } else {
392 self.plan_node_scan(&path.start_node)?
393 };
394
395 let mut current_src = match &path.start_node.variable {
397 Some(var) => var.clone(),
398 None => self.extract_variable_from_plan(&plan)?,
399 };
400
401 if let Some(start_var) = &path.start_node.variable {
403 self.validate_variable_label(start_var, &path.start_node.labels)?;
404 }
405
406 for segment in &path.segments {
408 let target_variable = segment
410 .end_node
411 .variable
412 .clone()
413 .unwrap_or_else(|| format!("_node_{}", self.variables.len()));
414
415 self.validate_variable_label(&target_variable, &segment.end_node.labels)?;
417
418 let target_label = self
420 .variables
421 .get(&target_variable)
422 .cloned()
423 .or_else(|| segment.end_node.labels.first().cloned())
424 .unwrap_or_else(|| "Node".to_string());
425
426 self.variables
427 .insert(target_variable.clone(), target_label.clone());
428
429 let next_plan = match segment.relationship.length.as_ref() {
431 Some(length_range)
432 if length_range.min == Some(1) && length_range.max == Some(1) =>
433 {
434 LogicalOperator::Expand {
435 input: Box::new(plan),
436 source_variable: current_src.clone(),
437 target_variable: target_variable.clone(),
438 target_label: target_label.clone(),
439 relationship_types: segment.relationship.types.clone(),
440 direction: segment.relationship.direction.clone(),
441 relationship_variable: segment.relationship.variable.clone(),
442 properties: segment.relationship.properties.clone(),
443 target_properties: segment.end_node.properties.clone(),
444 }
445 }
446 Some(length_range) => LogicalOperator::VariableLengthExpand {
447 input: Box::new(plan),
448 source_variable: current_src.clone(),
449 target_variable: target_variable.clone(),
450 relationship_types: segment.relationship.types.clone(),
451 direction: segment.relationship.direction.clone(),
452 relationship_variable: segment.relationship.variable.clone(),
453 min_length: length_range.min,
454 max_length: length_range.max,
455 target_properties: segment.end_node.properties.clone(),
456 },
457 None => LogicalOperator::Expand {
458 input: Box::new(plan),
459 source_variable: current_src.clone(),
460 target_variable: target_variable.clone(),
461 target_label: target_label.clone(),
462 relationship_types: segment.relationship.types.clone(),
463 direction: segment.relationship.direction.clone(),
464 relationship_variable: segment.relationship.variable.clone(),
465 properties: segment.relationship.properties.clone(),
466 target_properties: segment.end_node.properties.clone(),
467 },
468 };
469
470 plan = next_plan;
471 current_src = target_variable;
472 }
473
474 Ok(plan)
475 }
476
477 #[allow(clippy::only_used_in_recursion)]
479 fn extract_variable_from_plan(&self, plan: &LogicalOperator) -> Result<String> {
480 match plan {
481 LogicalOperator::ScanByLabel { variable, .. } => Ok(variable.clone()),
482 LogicalOperator::Unwind { alias, .. } => Ok(alias.clone()),
483 LogicalOperator::Expand {
484 target_variable, ..
485 } => Ok(target_variable.clone()),
486 LogicalOperator::VariableLengthExpand {
487 target_variable, ..
488 } => Ok(target_variable.clone()),
489 LogicalOperator::Filter { input, .. } => self.extract_variable_from_plan(input),
490 LogicalOperator::Project { input, .. } => self.extract_variable_from_plan(input),
491 LogicalOperator::Distinct { input } => self.extract_variable_from_plan(input),
492 LogicalOperator::Sort { input, .. } => self.extract_variable_from_plan(input),
493 LogicalOperator::Offset { input, .. } => self.extract_variable_from_plan(input),
494 LogicalOperator::Limit { input, .. } => self.extract_variable_from_plan(input),
495 LogicalOperator::Join { left, right, .. } => {
496 self.extract_variable_from_plan(right)
498 .or_else(|_| self.extract_variable_from_plan(left))
499 }
500 }
501 }
502
503 fn plan_return_clause(
505 &self,
506 return_clause: &ReturnClause,
507 input: LogicalOperator,
508 ) -> Result<LogicalOperator> {
509 let projections = return_clause
510 .items
511 .iter()
512 .map(|item| ProjectionItem {
513 expression: item.expression.clone(),
514 alias: item.alias.clone(),
515 })
516 .collect();
517
518 let mut plan = LogicalOperator::Project {
519 input: Box::new(input),
520 projections,
521 };
522
523 if return_clause.distinct {
525 plan = LogicalOperator::Distinct {
526 input: Box::new(plan),
527 };
528 }
529
530 Ok(plan)
531 }
532
533 fn plan_with_clause(
535 &self,
536 with_clause: &WithClause,
537 input: LogicalOperator,
538 ) -> Result<LogicalOperator> {
539 let projections = with_clause
541 .items
542 .iter()
543 .map(|item| ProjectionItem {
544 expression: item.expression.clone(),
545 alias: item.alias.clone(),
546 })
547 .collect();
548
549 let mut plan = LogicalOperator::Project {
550 input: Box::new(input),
551 projections,
552 };
553
554 if let Some(order_by) = &with_clause.order_by {
556 plan = LogicalOperator::Sort {
557 input: Box::new(plan),
558 sort_items: order_by
559 .items
560 .iter()
561 .map(|item| SortItem {
562 expression: item.expression.clone(),
563 direction: item.direction.clone(),
564 })
565 .collect(),
566 };
567 }
568
569 if let Some(limit) = with_clause.limit {
571 plan = LogicalOperator::Limit {
572 input: Box::new(plan),
573 count: limit,
574 };
575 }
576
577 Ok(plan)
578 }
579}
580
581impl Default for LogicalPlanner {
582 fn default() -> Self {
583 Self::new()
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use crate::parser::parse_cypher_query;
591
592 #[test]
593 fn test_relationship_query_logical_plan_structure() {
594 let query_text = r#"MATCH (p:Person {name: "Alice"})-[:KNOWS]->(f:Person) WHERE f.age > 30 RETURN f.name"#;
595
596 let ast = parse_cypher_query(query_text).unwrap();
598
599 let mut planner = LogicalPlanner::new();
601 let logical_plan = planner.plan(&ast).unwrap();
602
603 match &logical_plan {
605 LogicalOperator::Project { input, projections } => {
606 assert_eq!(projections.len(), 1);
608 match &projections[0].expression {
609 ValueExpression::Property(prop_ref) => {
610 assert_eq!(prop_ref.variable, "f");
611 assert_eq!(prop_ref.property, "name");
612 }
613 _ => panic!("Expected property reference for f.name"),
614 }
615
616 match input.as_ref() {
618 LogicalOperator::Filter {
619 predicate,
620 input: filter_input,
621 } => {
622 match predicate {
624 BooleanExpression::Comparison {
625 left,
626 operator,
627 right,
628 } => {
629 match left {
630 ValueExpression::Property(prop_ref) => {
631 assert_eq!(prop_ref.variable, "f");
632 assert_eq!(prop_ref.property, "age");
633 }
634 _ => panic!("Expected property reference for f.age"),
635 }
636 assert_eq!(*operator, ComparisonOperator::GreaterThan);
637 match right {
638 ValueExpression::Literal(PropertyValue::Integer(val)) => {
639 assert_eq!(*val, 30);
640 }
641 _ => panic!("Expected integer literal 30"),
642 }
643 }
644 _ => panic!("Expected comparison expression"),
645 }
646
647 match filter_input.as_ref() {
649 LogicalOperator::Expand {
650 input: expand_input,
651 source_variable,
652 target_variable,
653 relationship_types,
654 direction,
655 ..
656 } => {
657 assert_eq!(source_variable, "p");
658 assert_eq!(target_variable, "f");
659 assert_eq!(relationship_types, &vec!["KNOWS".to_string()]);
660 assert_eq!(*direction, RelationshipDirection::Outgoing);
661
662 match expand_input.as_ref() {
664 LogicalOperator::ScanByLabel {
665 variable,
666 label,
667 properties,
668 } => {
669 assert_eq!(variable, "p");
670 assert_eq!(label, "Person");
671
672 assert_eq!(properties.len(), 1);
674 match properties.get("name") {
675 Some(PropertyValue::String(val)) => {
676 assert_eq!(val, "Alice");
677 }
678 _ => {
679 panic!("Expected name property with value 'Alice'")
680 }
681 }
682 }
683 _ => panic!("Expected ScanByLabel with properties for Person"),
684 }
685 }
686 _ => panic!("Expected Expand operation"),
687 }
688 }
689 _ => panic!("Expected Filter for f.age > 30"),
690 }
691 }
692 _ => panic!("Expected Project at the top level"),
693 }
694 }
695
696 #[test]
697 fn test_simple_node_query_logical_plan() {
698 let query_text = "MATCH (n:Person) RETURN n.name";
699
700 let ast = parse_cypher_query(query_text).unwrap();
701 let mut planner = LogicalPlanner::new();
702 let logical_plan = planner.plan(&ast).unwrap();
703
704 match &logical_plan {
706 LogicalOperator::Project { input, projections } => {
707 assert_eq!(projections.len(), 1);
708 match input.as_ref() {
709 LogicalOperator::ScanByLabel {
710 variable, label, ..
711 } => {
712 assert_eq!(variable, "n");
713 assert_eq!(label, "Person");
714 }
715 _ => panic!("Expected ScanByLabel"),
716 }
717 }
718 _ => panic!("Expected Project"),
719 }
720 }
721
722 #[test]
723 fn test_node_with_properties_logical_plan() {
724 let query_text = "MATCH (n:Person {age: 25}) RETURN n.name";
725
726 let ast = parse_cypher_query(query_text).unwrap();
727 let mut planner = LogicalPlanner::new();
728 let logical_plan = planner.plan(&ast).unwrap();
729
730 match &logical_plan {
733 LogicalOperator::Project { input, .. } => {
734 match input.as_ref() {
735 LogicalOperator::ScanByLabel {
736 variable,
737 label,
738 properties,
739 } => {
740 assert_eq!(variable, "n");
741 assert_eq!(label, "Person");
742
743 assert_eq!(properties.len(), 1);
745 match properties.get("age") {
746 Some(PropertyValue::Integer(25)) => {}
747 _ => panic!("Expected age property with value 25"),
748 }
749 }
750 _ => panic!("Expected ScanByLabel with properties"),
751 }
752 }
753 _ => panic!("Expected Project"),
754 }
755 }
756
757 #[test]
758 fn test_variable_length_path_logical_plan() {
759 let query_text = "MATCH (a:Person)-[:KNOWS*1..2]->(b:Person) RETURN b.name";
760
761 let ast = parse_cypher_query(query_text).unwrap();
762 let mut planner = LogicalPlanner::new();
763 let logical_plan = planner.plan(&ast).unwrap();
764
765 match &logical_plan {
767 LogicalOperator::Project { input, .. } => match input.as_ref() {
768 LogicalOperator::VariableLengthExpand {
769 input: expand_input,
770 source_variable,
771 target_variable,
772 relationship_types,
773 min_length,
774 max_length,
775 ..
776 } => {
777 assert_eq!(source_variable, "a");
778 assert_eq!(target_variable, "b");
779 assert_eq!(relationship_types, &vec!["KNOWS".to_string()]);
780 assert_eq!(*min_length, Some(1));
781 assert_eq!(*max_length, Some(2));
782
783 match expand_input.as_ref() {
784 LogicalOperator::ScanByLabel {
785 variable, label, ..
786 } => {
787 assert_eq!(variable, "a");
788 assert_eq!(label, "Person");
789 }
790 _ => panic!("Expected ScanByLabel"),
791 }
792 }
793 _ => panic!("Expected VariableLengthExpand"),
794 },
795 _ => panic!("Expected Project"),
796 }
797 }
798
799 #[test]
800 fn test_where_clause_logical_plan() {
801 let query_text = r#"MATCH (n:Person) WHERE n.age > 25 RETURN n.name"#;
803
804 let ast = parse_cypher_query(query_text).unwrap();
805 let mut planner = LogicalPlanner::new();
806 let logical_plan = planner.plan(&ast).unwrap();
807
808 match &logical_plan {
810 LogicalOperator::Project { input, .. } => {
811 match input.as_ref() {
812 LogicalOperator::Filter {
813 predicate,
814 input: scan_input,
815 } => {
816 match predicate {
818 BooleanExpression::Comparison {
819 left,
820 operator,
821 right: _,
822 } => {
823 match left {
824 ValueExpression::Property(prop_ref) => {
825 assert_eq!(prop_ref.variable, "n");
826 assert_eq!(prop_ref.property, "age");
827 }
828 _ => panic!("Expected property reference for age"),
829 }
830 assert_eq!(*operator, ComparisonOperator::GreaterThan);
831 }
832 _ => panic!("Expected comparison expression"),
833 }
834
835 match scan_input.as_ref() {
836 LogicalOperator::ScanByLabel { .. } => {}
837 _ => panic!("Expected ScanByLabel"),
838 }
839 }
840 _ => panic!("Expected Filter"),
841 }
842 }
843 _ => panic!("Expected Project"),
844 }
845 }
846
847 #[test]
848 fn test_multiple_node_patterns_join_in_match() {
849 let query_text = "MATCH (a:Person), (b:Company) RETURN a.name, b.name";
850
851 let ast = parse_cypher_query(query_text).unwrap();
852 let mut planner = LogicalPlanner::new();
853 let logical_plan = planner.plan(&ast).unwrap();
854
855 match &logical_plan {
857 LogicalOperator::Project { input, projections } => {
858 assert_eq!(projections.len(), 2);
859 match input.as_ref() {
860 LogicalOperator::Join {
861 left,
862 right,
863 join_type,
864 } => {
865 assert!(matches!(join_type, JoinType::Cross));
866 match left.as_ref() {
867 LogicalOperator::ScanByLabel {
868 variable, label, ..
869 } => {
870 assert_eq!(variable, "a");
871 assert_eq!(label, "Person");
872 }
873 _ => panic!("Expected left ScanByLabel for a:Person"),
874 }
875 match right.as_ref() {
876 LogicalOperator::ScanByLabel {
877 variable, label, ..
878 } => {
879 assert_eq!(variable, "b");
880 assert_eq!(label, "Company");
881 }
882 _ => panic!("Expected right ScanByLabel for b:Company"),
883 }
884 }
885 _ => panic!("Expected Join after Project"),
886 }
887 }
888 _ => panic!("Expected Project at top level"),
889 }
890 }
891
892 #[test]
893 fn test_shared_variable_chained_paths_in_match() {
894 let query_text =
895 "MATCH (a:Person)-[:KNOWS]->(b:Person), (b)-[:LIKES]->(c:Thing) RETURN c.name";
896
897 let ast = parse_cypher_query(query_text).unwrap();
898 let mut planner = LogicalPlanner::new();
899 let logical_plan = planner.plan(&ast).unwrap();
900
901 match &logical_plan {
903 LogicalOperator::Project { input, .. } => match input.as_ref() {
904 LogicalOperator::Expand {
905 source_variable: src2,
906 target_variable: tgt2,
907 input: inner2,
908 ..
909 } => {
910 assert_eq!(src2, "b");
911 assert_eq!(tgt2, "c");
912 match inner2.as_ref() {
913 LogicalOperator::Expand {
914 source_variable: src1,
915 target_variable: tgt1,
916 input: inner1,
917 ..
918 } => {
919 assert_eq!(src1, "a");
920 assert_eq!(tgt1, "b");
921 match inner1.as_ref() {
922 LogicalOperator::ScanByLabel {
923 variable, label, ..
924 } => {
925 assert_eq!(variable, "a");
926 assert_eq!(label, "Person");
927 }
928 _ => panic!("Expected ScanByLabel for a:Person"),
929 }
930 }
931 _ => panic!("Expected first Expand a->b"),
932 }
933 }
934 _ => panic!("Expected second Expand b->c at top of input"),
935 },
936 _ => panic!("Expected Project at top level"),
937 }
938 }
939
940 #[test]
941 fn test_fixed_length_variable_path_is_expand() {
942 let query_text = "MATCH (a:Person)-[:KNOWS*1..1]->(b:Person) RETURN b.name";
943
944 let ast = parse_cypher_query(query_text).unwrap();
945 let mut planner = LogicalPlanner::new();
946 let logical_plan = planner.plan(&ast).unwrap();
947
948 match &logical_plan {
949 LogicalOperator::Project { input, .. } => match input.as_ref() {
950 LogicalOperator::Expand {
951 source_variable,
952 target_variable,
953 ..
954 } => {
955 assert_eq!(source_variable, "a");
956 assert_eq!(target_variable, "b");
957 }
958 _ => panic!("Expected Expand for fixed-length *1..1"),
959 },
960 _ => panic!("Expected Project at top level"),
961 }
962 }
963
964 #[test]
965 fn test_distinct_and_order_limit_wrapping() {
966 let q1 = "MATCH (n:Person) RETURN DISTINCT n.name";
968 let ast1 = parse_cypher_query(q1).unwrap();
969 let mut planner = LogicalPlanner::new();
970 let logical1 = planner.plan(&ast1).unwrap();
971 match logical1 {
972 LogicalOperator::Distinct { input } => match *input {
973 LogicalOperator::Project { .. } => {}
974 _ => panic!("Expected Project under Distinct"),
975 },
976 _ => panic!("Expected Distinct at top level"),
977 }
978
979 let q2 = "MATCH (n:Person) RETURN n.name ORDER BY n.name LIMIT 10";
981 let ast2 = parse_cypher_query(q2).unwrap();
982 let mut planner2 = LogicalPlanner::new();
983 let logical2 = planner2.plan(&ast2).unwrap();
984 match logical2 {
985 LogicalOperator::Limit { input, count } => {
986 assert_eq!(count, 10);
987 match *input {
988 LogicalOperator::Sort { input: inner, .. } => match *inner {
989 LogicalOperator::Project { .. } => {}
990 _ => panic!("Expected Project under Sort"),
991 },
992 _ => panic!("Expected Sort under Limit"),
993 }
994 }
995 _ => panic!("Expected Limit at top level"),
996 }
997 }
998
999 #[test]
1000 fn test_order_skip_limit_wrapping() {
1001 let q = "MATCH (n:Person) RETURN n.name ORDER BY n.name SKIP 5 LIMIT 10";
1003 let ast = parse_cypher_query(q).unwrap();
1004 let mut planner = LogicalPlanner::new();
1005 let logical = planner.plan(&ast).unwrap();
1006 match logical {
1007 LogicalOperator::Limit { input, count } => {
1008 assert_eq!(count, 10);
1009 match *input {
1010 LogicalOperator::Offset {
1011 input: inner,
1012 offset,
1013 } => {
1014 assert_eq!(offset, 5);
1015 match *inner {
1016 LogicalOperator::Sort { input: inner2, .. } => match *inner2 {
1017 LogicalOperator::Project { .. } => {}
1018 _ => panic!("Expected Project under Sort"),
1019 },
1020 _ => panic!("Expected Sort under Offset"),
1021 }
1022 }
1023 _ => panic!("Expected Offset under Limit"),
1024 }
1025 }
1026 _ => panic!("Expected Limit at top level"),
1027 }
1028 }
1029
1030 #[test]
1031 fn test_skip_only_wrapping() {
1032 let q = "MATCH (n:Person) RETURN n.name SKIP 3";
1034 let ast = parse_cypher_query(q).unwrap();
1035 let mut planner = LogicalPlanner::new();
1036 let logical = planner.plan(&ast).unwrap();
1037 match logical {
1038 LogicalOperator::Offset { input, offset } => {
1039 assert_eq!(offset, 3);
1040 match *input {
1041 LogicalOperator::Project { .. } => {}
1042 _ => panic!("Expected Project under Offset"),
1043 }
1044 }
1045 _ => panic!("Expected Offset at top level"),
1046 }
1047 }
1048
1049 #[test]
1050 fn test_relationship_properties_pushed_into_expand() {
1051 let q = "MATCH (a)-[:KNOWS {since: 2020}]->(b) RETURN b";
1052 let ast = parse_cypher_query(q).unwrap();
1053 let mut planner = LogicalPlanner::new();
1054 let logical = planner.plan(&ast).unwrap();
1055 match logical {
1056 LogicalOperator::Project { input, .. } => match *input {
1057 LogicalOperator::Expand { properties, .. } => match properties.get("since") {
1058 Some(PropertyValue::Integer(2020)) => {}
1059 _ => panic!("Expected relationship property since=2020 in Expand"),
1060 },
1061 _ => panic!("Expected Expand under Project"),
1062 },
1063 _ => panic!("Expected Project at top level"),
1064 }
1065 }
1066
1067 #[test]
1068 fn test_multiple_match_clauses_cross_join() {
1069 let q = "MATCH (a:Person) MATCH (b:Company) RETURN a.name, b.name";
1070 let ast = parse_cypher_query(q).unwrap();
1071 let mut planner = LogicalPlanner::new();
1072 let logical = planner.plan(&ast).unwrap();
1073 match logical {
1074 LogicalOperator::Project { input, .. } => match *input {
1075 LogicalOperator::Join {
1076 left,
1077 right,
1078 join_type,
1079 } => {
1080 assert!(matches!(join_type, JoinType::Cross));
1081 match (*left, *right) {
1082 (
1083 LogicalOperator::ScanByLabel {
1084 variable: va,
1085 label: la,
1086 ..
1087 },
1088 LogicalOperator::ScanByLabel {
1089 variable: vb,
1090 label: lb,
1091 ..
1092 },
1093 ) => {
1094 assert_eq!(va, "a");
1095 assert_eq!(la, "Person");
1096 assert_eq!(vb, "b");
1097 assert_eq!(lb, "Company");
1098 }
1099 _ => panic!("Expected two scans under Join"),
1100 }
1101 }
1102 _ => panic!("Expected Join under Project"),
1103 },
1104 _ => panic!("Expected Project at top level"),
1105 }
1106 }
1107
1108 #[test]
1109 fn test_variable_only_node_default_label() {
1110 let q = "MATCH (x) RETURN x";
1111 let ast = parse_cypher_query(q).unwrap();
1112 let mut planner = LogicalPlanner::new();
1113 let logical = planner.plan(&ast).unwrap();
1114 match logical {
1115 LogicalOperator::Project { input, .. } => match *input {
1116 LogicalOperator::ScanByLabel {
1117 variable, label, ..
1118 } => {
1119 assert_eq!(variable, "x");
1120 assert_eq!(label, "Node");
1121 }
1122 _ => panic!("Expected ScanByLabel under Project"),
1123 },
1124 _ => panic!("Expected Project at top level"),
1125 }
1126 }
1127
1128 #[test]
1129 fn test_multi_label_node_uses_first_label() {
1130 let q = "MATCH (n:Person:Employee) RETURN n";
1131 let ast = parse_cypher_query(q).unwrap();
1132 let mut planner = LogicalPlanner::new();
1133 let logical = planner.plan(&ast).unwrap();
1134 match logical {
1135 LogicalOperator::Project { input, .. } => match *input {
1136 LogicalOperator::ScanByLabel { label, .. } => {
1137 assert_eq!(label, "Person");
1138 }
1139 _ => panic!("Expected ScanByLabel under Project"),
1140 },
1141 _ => panic!("Expected Project at top level"),
1142 }
1143 }
1144
1145 #[test]
1146 fn test_open_ended_and_partial_var_length_ranges() {
1147 let q1 = "MATCH (a)-[:R*]->(b) RETURN b";
1149 let ast1 = parse_cypher_query(q1).unwrap();
1150 let mut planner1 = LogicalPlanner::new();
1151 let plan1 = planner1.plan(&ast1).unwrap();
1152 match plan1 {
1153 LogicalOperator::Project { input, .. } => match *input {
1154 LogicalOperator::VariableLengthExpand {
1155 min_length,
1156 max_length,
1157 ..
1158 } => {
1159 assert_eq!(min_length, None);
1160 assert_eq!(max_length, None);
1161 }
1162 _ => panic!("Expected VariableLengthExpand for *"),
1163 },
1164 _ => panic!("Expected Project at top level"),
1165 }
1166
1167 let q2 = "MATCH (a)-[:R*2..]->(b) RETURN b";
1169 let ast2 = parse_cypher_query(q2).unwrap();
1170 let mut planner2 = LogicalPlanner::new();
1171 let plan2 = planner2.plan(&ast2).unwrap();
1172 match plan2 {
1173 LogicalOperator::Project { input, .. } => match *input {
1174 LogicalOperator::VariableLengthExpand {
1175 min_length,
1176 max_length,
1177 ..
1178 } => {
1179 assert_eq!(min_length, Some(2));
1180 assert_eq!(max_length, None);
1181 }
1182 _ => panic!("Expected VariableLengthExpand for *2.."),
1183 },
1184 _ => panic!("Expected Project at top level"),
1185 }
1186
1187 let q3 = "MATCH (a)-[:R*..3]->(b) RETURN b";
1189 let ast3 = parse_cypher_query(q3).unwrap();
1190 let mut planner3 = LogicalPlanner::new();
1191 let plan3 = planner3.plan(&ast3).unwrap();
1192 match plan3 {
1193 LogicalOperator::Project { input, .. } => match *input {
1194 LogicalOperator::VariableLengthExpand {
1195 min_length,
1196 max_length,
1197 ..
1198 } => {
1199 assert_eq!(min_length, None);
1200 assert_eq!(max_length, Some(3));
1201 }
1202 _ => panic!("Expected VariableLengthExpand for *..3"),
1203 },
1204 _ => panic!("Expected Project at top level"),
1205 }
1206 }
1207
1208 #[test]
1209 fn test_variable_reuse_across_patterns() {
1210 let query_text =
1211 "MATCH (a:Person)-[:KNOWS]->(shared:Person), (shared)-[:KNOWS]->(b:Person) RETURN b.name";
1212
1213 let ast = parse_cypher_query(query_text).unwrap();
1214 let mut planner = LogicalPlanner::new();
1215 let logical_plan = planner.plan(&ast).unwrap();
1216
1217 match &logical_plan {
1219 LogicalOperator::Project { input, .. } => match input.as_ref() {
1220 LogicalOperator::Expand {
1221 input: inner,
1222 source_variable,
1223 target_variable,
1224 ..
1225 } => {
1226 assert_eq!(source_variable, "shared");
1227 assert_eq!(target_variable, "b");
1228
1229 match inner.as_ref() {
1230 LogicalOperator::Expand {
1231 source_variable: first_src,
1232 target_variable: first_dst,
1233 ..
1234 } => {
1235 assert_eq!(first_src, "a");
1236 assert_eq!(first_dst, "shared");
1237 }
1238 _ => panic!("Expected first Expand (a->shared)"),
1239 }
1240 }
1241 _ => panic!("Expected second Expand (shared->b)"),
1242 },
1243 _ => panic!("Expected Project at top level"),
1244 }
1245 }
1246
1247 #[test]
1248 fn test_variable_reuse_with_conflicting_labels() {
1249 let query_text =
1250 "MATCH (a:Person)-[:KNOWS]->(shared:Person), (shared:Company)-[:EMPLOYS]->(b:Person) RETURN b.name";
1251
1252 let ast = parse_cypher_query(query_text).unwrap();
1253 let mut planner = LogicalPlanner::new();
1254 let err = planner.plan(&ast).unwrap_err();
1255 let err_msg = err.to_string();
1256
1257 assert!(
1258 err_msg.contains("already has label 'Person'")
1259 && err_msg.contains("cannot redefine as 'Company'"),
1260 "Expected error about label conflict, got: {}",
1261 err_msg
1262 );
1263 }
1264}