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 Filter {
28 input: Box<LogicalOperator>,
29 predicate: BooleanExpression,
30 },
31
32 Expand {
36 input: Box<LogicalOperator>,
38 source_variable: String,
40 target_variable: String,
42 target_label: String,
45 relationship_types: Vec<String>,
47 direction: RelationshipDirection,
49 relationship_variable: Option<String>,
51 properties: HashMap<String, PropertyValue>,
53 target_properties: HashMap<String, PropertyValue>,
55 },
56
57 VariableLengthExpand {
62 input: Box<LogicalOperator>,
64 source_variable: String,
66 target_variable: String,
68 relationship_types: Vec<String>,
70 direction: RelationshipDirection,
72 relationship_variable: Option<String>,
74 min_length: Option<u32>,
76 max_length: Option<u32>,
78 target_properties: HashMap<String, PropertyValue>,
80 },
81
82 Project {
84 input: Box<LogicalOperator>,
85 projections: Vec<ProjectionItem>,
86 },
87
88 Join {
90 left: Box<LogicalOperator>,
91 right: Box<LogicalOperator>,
92 join_type: JoinType,
93 },
94
95 Distinct { input: Box<LogicalOperator> },
97
98 Sort {
100 input: Box<LogicalOperator>,
101 sort_items: Vec<SortItem>,
102 },
103
104 Offset {
106 input: Box<LogicalOperator>,
107 offset: u64,
108 },
109
110 Limit {
112 input: Box<LogicalOperator>,
113 count: u64,
114 },
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct ProjectionItem {
120 pub expression: ValueExpression,
121 pub alias: Option<String>,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub enum JoinType {
127 Inner,
128 Left,
129 Right,
130 Full,
131 Cross,
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct SortItem {
137 pub expression: ValueExpression,
138 pub direction: SortDirection,
139}
140
141pub struct LogicalPlanner {
143 variables: HashMap<String, String>, }
146
147impl LogicalPlanner {
148 pub fn new() -> Self {
149 Self {
150 variables: HashMap::new(),
151 }
152 }
153
154 pub fn plan(&mut self, query: &CypherQuery) -> Result<LogicalOperator> {
156 let mut plan = self.plan_match_clauses(&query.match_clauses)?;
158
159 if let Some(where_clause) = &query.where_clause {
161 plan = LogicalOperator::Filter {
162 input: Box::new(plan),
163 predicate: where_clause.expression.clone(),
164 };
165 }
166
167 plan = self.plan_return_clause(&query.return_clause, plan)?;
169
170 if let Some(order_by) = &query.order_by {
172 plan = LogicalOperator::Sort {
173 input: Box::new(plan),
174 sort_items: order_by
175 .items
176 .iter()
177 .map(|item| SortItem {
178 expression: item.expression.clone(),
179 direction: item.direction.clone(),
180 })
181 .collect(),
182 };
183 }
184
185 if let Some(skip) = query.skip {
187 plan = LogicalOperator::Offset {
188 input: Box::new(plan),
189 offset: skip,
190 };
191 }
192
193 if let Some(limit) = query.limit {
195 plan = LogicalOperator::Limit {
196 input: Box::new(plan),
197 count: limit,
198 };
199 }
200
201 Ok(plan)
202 }
203
204 fn plan_match_clauses(&mut self, match_clauses: &[MatchClause]) -> Result<LogicalOperator> {
206 if match_clauses.is_empty() {
207 return Err(GraphError::PlanError {
208 message: "Query must have at least one MATCH clause".to_string(),
209 location: snafu::Location::new(file!(), line!(), column!()),
210 });
211 }
212
213 let plan = match_clauses.iter().try_fold(None, |plan, clause| {
214 self.plan_match_clause_with_base(plan, clause).map(Some)
215 })?;
216
217 plan.ok_or_else(|| GraphError::PlanError {
218 message: "Failed to plan MATCH clauses".to_string(),
219 location: snafu::Location::new(file!(), line!(), column!()),
220 })
221 }
222
223 fn plan_match_clause_with_base(
225 &mut self,
226 base: Option<LogicalOperator>,
227 match_clause: &MatchClause,
228 ) -> Result<LogicalOperator> {
229 if match_clause.patterns.is_empty() {
230 return Err(GraphError::PlanError {
231 message: "MATCH clause must have at least one pattern".to_string(),
232 location: snafu::Location::new(file!(), line!(), column!()),
233 });
234 }
235
236 let mut plan = base;
237 for pattern in &match_clause.patterns {
238 match pattern {
239 GraphPattern::Node(node) => {
240 let already_bound = node
241 .variable
242 .as_deref()
243 .is_some_and(|v| self.variables.contains_key(v));
244
245 match (already_bound, plan.as_ref()) {
246 (true, _) => { }
247 (false, None) => plan = Some(self.plan_node_scan(node)?),
248 (false, Some(_)) => {
249 let right = self.plan_node_scan(node)?;
250 plan = Some(LogicalOperator::Join {
251 left: Box::new(plan.unwrap()),
252 right: Box::new(right),
253 join_type: JoinType::Cross, });
255 }
256 }
257 }
258 GraphPattern::Path(path) => plan = Some(self.plan_path(plan, path)?),
259 }
260 }
261
262 plan.ok_or_else(|| GraphError::PlanError {
263 message: "Failed to plan MATCH clause".to_string(),
264 location: snafu::Location::new(file!(), line!(), column!()),
265 })
266 }
267
268 fn plan_node_scan(&mut self, node: &NodePattern) -> Result<LogicalOperator> {
270 let variable = node
271 .variable
272 .clone()
273 .unwrap_or_else(|| format!("_node_{}", self.variables.len()));
274
275 let label = node
276 .labels
277 .first()
278 .cloned()
279 .unwrap_or_else(|| "Node".to_string());
280
281 self.variables.insert(variable.clone(), label.clone());
283
284 Ok(LogicalOperator::ScanByLabel {
285 variable,
286 label,
287 properties: node.properties.clone(),
288 })
289 }
290
291 fn plan_path(
295 &mut self,
296 base: Option<LogicalOperator>,
297 path: &PathPattern,
298 ) -> Result<LogicalOperator> {
299 let mut plan = if let Some(p) = base {
301 p
302 } else {
303 self.plan_node_scan(&path.start_node)?
304 };
305
306 let mut current_src = match &path.start_node.variable {
308 Some(var) => var.clone(),
309 None => self.extract_variable_from_plan(&plan)?,
310 };
311
312 for segment in &path.segments {
314 let target_variable = segment
316 .end_node
317 .variable
318 .clone()
319 .unwrap_or_else(|| format!("_node_{}", self.variables.len()));
320
321 let target_label = segment
322 .end_node
323 .labels
324 .first()
325 .cloned()
326 .unwrap_or_else(|| "Node".to_string());
327 self.variables
328 .insert(target_variable.clone(), target_label.clone());
329
330 let next_plan = match segment.relationship.length.as_ref() {
332 Some(length_range)
333 if length_range.min == Some(1) && length_range.max == Some(1) =>
334 {
335 LogicalOperator::Expand {
336 input: Box::new(plan),
337 source_variable: current_src.clone(),
338 target_variable: target_variable.clone(),
339 target_label: target_label.clone(),
340 relationship_types: segment.relationship.types.clone(),
341 direction: segment.relationship.direction.clone(),
342 relationship_variable: segment.relationship.variable.clone(),
343 properties: segment.relationship.properties.clone(),
344 target_properties: segment.end_node.properties.clone(),
345 }
346 }
347 Some(length_range) => LogicalOperator::VariableLengthExpand {
348 input: Box::new(plan),
349 source_variable: current_src.clone(),
350 target_variable: target_variable.clone(),
351 relationship_types: segment.relationship.types.clone(),
352 direction: segment.relationship.direction.clone(),
353 relationship_variable: segment.relationship.variable.clone(),
354 min_length: length_range.min,
355 max_length: length_range.max,
356 target_properties: segment.end_node.properties.clone(),
357 },
358 None => LogicalOperator::Expand {
359 input: Box::new(plan),
360 source_variable: current_src.clone(),
361 target_variable: target_variable.clone(),
362 target_label: target_label.clone(),
363 relationship_types: segment.relationship.types.clone(),
364 direction: segment.relationship.direction.clone(),
365 relationship_variable: segment.relationship.variable.clone(),
366 properties: segment.relationship.properties.clone(),
367 target_properties: segment.end_node.properties.clone(),
368 },
369 };
370
371 plan = next_plan;
372 current_src = target_variable;
373 }
374
375 Ok(plan)
376 }
377
378 #[allow(clippy::only_used_in_recursion)]
380 fn extract_variable_from_plan(&self, plan: &LogicalOperator) -> Result<String> {
381 match plan {
382 LogicalOperator::ScanByLabel { variable, .. } => Ok(variable.clone()),
383 LogicalOperator::Expand {
384 target_variable, ..
385 } => Ok(target_variable.clone()),
386 LogicalOperator::VariableLengthExpand {
387 target_variable, ..
388 } => Ok(target_variable.clone()),
389 LogicalOperator::Filter { input, .. } => self.extract_variable_from_plan(input),
390 LogicalOperator::Project { input, .. } => self.extract_variable_from_plan(input),
391 LogicalOperator::Distinct { input } => self.extract_variable_from_plan(input),
392 LogicalOperator::Sort { input, .. } => self.extract_variable_from_plan(input),
393 LogicalOperator::Offset { input, .. } => self.extract_variable_from_plan(input),
394 LogicalOperator::Limit { input, .. } => self.extract_variable_from_plan(input),
395 LogicalOperator::Join { left, right, .. } => {
396 self.extract_variable_from_plan(right)
398 .or_else(|_| self.extract_variable_from_plan(left))
399 }
400 }
401 }
402
403 fn plan_return_clause(
405 &self,
406 return_clause: &ReturnClause,
407 input: LogicalOperator,
408 ) -> Result<LogicalOperator> {
409 let projections = return_clause
410 .items
411 .iter()
412 .map(|item| ProjectionItem {
413 expression: item.expression.clone(),
414 alias: item.alias.clone(),
415 })
416 .collect();
417
418 let mut plan = LogicalOperator::Project {
419 input: Box::new(input),
420 projections,
421 };
422
423 if return_clause.distinct {
425 plan = LogicalOperator::Distinct {
426 input: Box::new(plan),
427 };
428 }
429
430 Ok(plan)
431 }
432}
433
434impl Default for LogicalPlanner {
435 fn default() -> Self {
436 Self::new()
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use crate::parser::parse_cypher_query;
444
445 #[test]
446 fn test_relationship_query_logical_plan_structure() {
447 let query_text = r#"MATCH (p:Person {name: "Alice"})-[:KNOWS]->(f:Person) WHERE f.age > 30 RETURN f.name"#;
448
449 let ast = parse_cypher_query(query_text).unwrap();
451
452 let mut planner = LogicalPlanner::new();
454 let logical_plan = planner.plan(&ast).unwrap();
455
456 match &logical_plan {
458 LogicalOperator::Project { input, projections } => {
459 assert_eq!(projections.len(), 1);
461 match &projections[0].expression {
462 ValueExpression::Property(prop_ref) => {
463 assert_eq!(prop_ref.variable, "f");
464 assert_eq!(prop_ref.property, "name");
465 }
466 _ => panic!("Expected property reference for f.name"),
467 }
468
469 match input.as_ref() {
471 LogicalOperator::Filter {
472 predicate,
473 input: filter_input,
474 } => {
475 match predicate {
477 BooleanExpression::Comparison {
478 left,
479 operator,
480 right,
481 } => {
482 match left {
483 ValueExpression::Property(prop_ref) => {
484 assert_eq!(prop_ref.variable, "f");
485 assert_eq!(prop_ref.property, "age");
486 }
487 _ => panic!("Expected property reference for f.age"),
488 }
489 assert_eq!(*operator, ComparisonOperator::GreaterThan);
490 match right {
491 ValueExpression::Literal(PropertyValue::Integer(val)) => {
492 assert_eq!(*val, 30);
493 }
494 _ => panic!("Expected integer literal 30"),
495 }
496 }
497 _ => panic!("Expected comparison expression"),
498 }
499
500 match filter_input.as_ref() {
502 LogicalOperator::Expand {
503 input: expand_input,
504 source_variable,
505 target_variable,
506 relationship_types,
507 direction,
508 ..
509 } => {
510 assert_eq!(source_variable, "p");
511 assert_eq!(target_variable, "f");
512 assert_eq!(relationship_types, &vec!["KNOWS".to_string()]);
513 assert_eq!(*direction, RelationshipDirection::Outgoing);
514
515 match expand_input.as_ref() {
517 LogicalOperator::ScanByLabel {
518 variable,
519 label,
520 properties,
521 } => {
522 assert_eq!(variable, "p");
523 assert_eq!(label, "Person");
524
525 assert_eq!(properties.len(), 1);
527 match properties.get("name") {
528 Some(PropertyValue::String(val)) => {
529 assert_eq!(val, "Alice");
530 }
531 _ => {
532 panic!("Expected name property with value 'Alice'")
533 }
534 }
535 }
536 _ => panic!("Expected ScanByLabel with properties for Person"),
537 }
538 }
539 _ => panic!("Expected Expand operation"),
540 }
541 }
542 _ => panic!("Expected Filter for f.age > 30"),
543 }
544 }
545 _ => panic!("Expected Project at the top level"),
546 }
547 }
548
549 #[test]
550 fn test_simple_node_query_logical_plan() {
551 let query_text = "MATCH (n:Person) RETURN n.name";
552
553 let ast = parse_cypher_query(query_text).unwrap();
554 let mut planner = LogicalPlanner::new();
555 let logical_plan = planner.plan(&ast).unwrap();
556
557 match &logical_plan {
559 LogicalOperator::Project { input, projections } => {
560 assert_eq!(projections.len(), 1);
561 match input.as_ref() {
562 LogicalOperator::ScanByLabel {
563 variable, label, ..
564 } => {
565 assert_eq!(variable, "n");
566 assert_eq!(label, "Person");
567 }
568 _ => panic!("Expected ScanByLabel"),
569 }
570 }
571 _ => panic!("Expected Project"),
572 }
573 }
574
575 #[test]
576 fn test_node_with_properties_logical_plan() {
577 let query_text = "MATCH (n:Person {age: 25}) RETURN n.name";
578
579 let ast = parse_cypher_query(query_text).unwrap();
580 let mut planner = LogicalPlanner::new();
581 let logical_plan = planner.plan(&ast).unwrap();
582
583 match &logical_plan {
586 LogicalOperator::Project { input, .. } => {
587 match input.as_ref() {
588 LogicalOperator::ScanByLabel {
589 variable,
590 label,
591 properties,
592 } => {
593 assert_eq!(variable, "n");
594 assert_eq!(label, "Person");
595
596 assert_eq!(properties.len(), 1);
598 match properties.get("age") {
599 Some(PropertyValue::Integer(25)) => {}
600 _ => panic!("Expected age property with value 25"),
601 }
602 }
603 _ => panic!("Expected ScanByLabel with properties"),
604 }
605 }
606 _ => panic!("Expected Project"),
607 }
608 }
609
610 #[test]
611 fn test_variable_length_path_logical_plan() {
612 let query_text = "MATCH (a:Person)-[:KNOWS*1..2]->(b:Person) RETURN b.name";
613
614 let ast = parse_cypher_query(query_text).unwrap();
615 let mut planner = LogicalPlanner::new();
616 let logical_plan = planner.plan(&ast).unwrap();
617
618 match &logical_plan {
620 LogicalOperator::Project { input, .. } => match input.as_ref() {
621 LogicalOperator::VariableLengthExpand {
622 input: expand_input,
623 source_variable,
624 target_variable,
625 relationship_types,
626 min_length,
627 max_length,
628 ..
629 } => {
630 assert_eq!(source_variable, "a");
631 assert_eq!(target_variable, "b");
632 assert_eq!(relationship_types, &vec!["KNOWS".to_string()]);
633 assert_eq!(*min_length, Some(1));
634 assert_eq!(*max_length, Some(2));
635
636 match expand_input.as_ref() {
637 LogicalOperator::ScanByLabel {
638 variable, label, ..
639 } => {
640 assert_eq!(variable, "a");
641 assert_eq!(label, "Person");
642 }
643 _ => panic!("Expected ScanByLabel"),
644 }
645 }
646 _ => panic!("Expected VariableLengthExpand"),
647 },
648 _ => panic!("Expected Project"),
649 }
650 }
651
652 #[test]
653 fn test_where_clause_logical_plan() {
654 let query_text = r#"MATCH (n:Person) WHERE n.age > 25 RETURN n.name"#;
656
657 let ast = parse_cypher_query(query_text).unwrap();
658 let mut planner = LogicalPlanner::new();
659 let logical_plan = planner.plan(&ast).unwrap();
660
661 match &logical_plan {
663 LogicalOperator::Project { input, .. } => {
664 match input.as_ref() {
665 LogicalOperator::Filter {
666 predicate,
667 input: scan_input,
668 } => {
669 match predicate {
671 BooleanExpression::Comparison {
672 left,
673 operator,
674 right: _,
675 } => {
676 match left {
677 ValueExpression::Property(prop_ref) => {
678 assert_eq!(prop_ref.variable, "n");
679 assert_eq!(prop_ref.property, "age");
680 }
681 _ => panic!("Expected property reference for age"),
682 }
683 assert_eq!(*operator, ComparisonOperator::GreaterThan);
684 }
685 _ => panic!("Expected comparison expression"),
686 }
687
688 match scan_input.as_ref() {
689 LogicalOperator::ScanByLabel { .. } => {}
690 _ => panic!("Expected ScanByLabel"),
691 }
692 }
693 _ => panic!("Expected Filter"),
694 }
695 }
696 _ => panic!("Expected Project"),
697 }
698 }
699
700 #[test]
701 fn test_multiple_node_patterns_join_in_match() {
702 let query_text = "MATCH (a:Person), (b:Company) RETURN a.name, b.name";
703
704 let ast = parse_cypher_query(query_text).unwrap();
705 let mut planner = LogicalPlanner::new();
706 let logical_plan = planner.plan(&ast).unwrap();
707
708 match &logical_plan {
710 LogicalOperator::Project { input, projections } => {
711 assert_eq!(projections.len(), 2);
712 match input.as_ref() {
713 LogicalOperator::Join {
714 left,
715 right,
716 join_type,
717 } => {
718 assert!(matches!(join_type, JoinType::Cross));
719 match left.as_ref() {
720 LogicalOperator::ScanByLabel {
721 variable, label, ..
722 } => {
723 assert_eq!(variable, "a");
724 assert_eq!(label, "Person");
725 }
726 _ => panic!("Expected left ScanByLabel for a:Person"),
727 }
728 match right.as_ref() {
729 LogicalOperator::ScanByLabel {
730 variable, label, ..
731 } => {
732 assert_eq!(variable, "b");
733 assert_eq!(label, "Company");
734 }
735 _ => panic!("Expected right ScanByLabel for b:Company"),
736 }
737 }
738 _ => panic!("Expected Join after Project"),
739 }
740 }
741 _ => panic!("Expected Project at top level"),
742 }
743 }
744
745 #[test]
746 fn test_shared_variable_chained_paths_in_match() {
747 let query_text =
748 "MATCH (a:Person)-[:KNOWS]->(b:Person), (b)-[:LIKES]->(c:Thing) RETURN c.name";
749
750 let ast = parse_cypher_query(query_text).unwrap();
751 let mut planner = LogicalPlanner::new();
752 let logical_plan = planner.plan(&ast).unwrap();
753
754 match &logical_plan {
756 LogicalOperator::Project { input, .. } => match input.as_ref() {
757 LogicalOperator::Expand {
758 source_variable: src2,
759 target_variable: tgt2,
760 input: inner2,
761 ..
762 } => {
763 assert_eq!(src2, "b");
764 assert_eq!(tgt2, "c");
765 match inner2.as_ref() {
766 LogicalOperator::Expand {
767 source_variable: src1,
768 target_variable: tgt1,
769 input: inner1,
770 ..
771 } => {
772 assert_eq!(src1, "a");
773 assert_eq!(tgt1, "b");
774 match inner1.as_ref() {
775 LogicalOperator::ScanByLabel {
776 variable, label, ..
777 } => {
778 assert_eq!(variable, "a");
779 assert_eq!(label, "Person");
780 }
781 _ => panic!("Expected ScanByLabel for a:Person"),
782 }
783 }
784 _ => panic!("Expected first Expand a->b"),
785 }
786 }
787 _ => panic!("Expected second Expand b->c at top of input"),
788 },
789 _ => panic!("Expected Project at top level"),
790 }
791 }
792
793 #[test]
794 fn test_fixed_length_variable_path_is_expand() {
795 let query_text = "MATCH (a:Person)-[:KNOWS*1..1]->(b:Person) RETURN b.name";
796
797 let ast = parse_cypher_query(query_text).unwrap();
798 let mut planner = LogicalPlanner::new();
799 let logical_plan = planner.plan(&ast).unwrap();
800
801 match &logical_plan {
802 LogicalOperator::Project { input, .. } => match input.as_ref() {
803 LogicalOperator::Expand {
804 source_variable,
805 target_variable,
806 ..
807 } => {
808 assert_eq!(source_variable, "a");
809 assert_eq!(target_variable, "b");
810 }
811 _ => panic!("Expected Expand for fixed-length *1..1"),
812 },
813 _ => panic!("Expected Project at top level"),
814 }
815 }
816
817 #[test]
818 fn test_distinct_and_order_limit_wrapping() {
819 let q1 = "MATCH (n:Person) RETURN DISTINCT n.name";
821 let ast1 = parse_cypher_query(q1).unwrap();
822 let mut planner = LogicalPlanner::new();
823 let logical1 = planner.plan(&ast1).unwrap();
824 match logical1 {
825 LogicalOperator::Distinct { input } => match *input {
826 LogicalOperator::Project { .. } => {}
827 _ => panic!("Expected Project under Distinct"),
828 },
829 _ => panic!("Expected Distinct at top level"),
830 }
831
832 let q2 = "MATCH (n:Person) RETURN n.name ORDER BY n.name LIMIT 10";
834 let ast2 = parse_cypher_query(q2).unwrap();
835 let mut planner2 = LogicalPlanner::new();
836 let logical2 = planner2.plan(&ast2).unwrap();
837 match logical2 {
838 LogicalOperator::Limit { input, count } => {
839 assert_eq!(count, 10);
840 match *input {
841 LogicalOperator::Sort { input: inner, .. } => match *inner {
842 LogicalOperator::Project { .. } => {}
843 _ => panic!("Expected Project under Sort"),
844 },
845 _ => panic!("Expected Sort under Limit"),
846 }
847 }
848 _ => panic!("Expected Limit at top level"),
849 }
850 }
851
852 #[test]
853 fn test_order_skip_limit_wrapping() {
854 let q = "MATCH (n:Person) RETURN n.name ORDER BY n.name SKIP 5 LIMIT 10";
856 let ast = parse_cypher_query(q).unwrap();
857 let mut planner = LogicalPlanner::new();
858 let logical = planner.plan(&ast).unwrap();
859 match logical {
860 LogicalOperator::Limit { input, count } => {
861 assert_eq!(count, 10);
862 match *input {
863 LogicalOperator::Offset {
864 input: inner,
865 offset,
866 } => {
867 assert_eq!(offset, 5);
868 match *inner {
869 LogicalOperator::Sort { input: inner2, .. } => match *inner2 {
870 LogicalOperator::Project { .. } => {}
871 _ => panic!("Expected Project under Sort"),
872 },
873 _ => panic!("Expected Sort under Offset"),
874 }
875 }
876 _ => panic!("Expected Offset under Limit"),
877 }
878 }
879 _ => panic!("Expected Limit at top level"),
880 }
881 }
882
883 #[test]
884 fn test_skip_only_wrapping() {
885 let q = "MATCH (n:Person) RETURN n.name SKIP 3";
887 let ast = parse_cypher_query(q).unwrap();
888 let mut planner = LogicalPlanner::new();
889 let logical = planner.plan(&ast).unwrap();
890 match logical {
891 LogicalOperator::Offset { input, offset } => {
892 assert_eq!(offset, 3);
893 match *input {
894 LogicalOperator::Project { .. } => {}
895 _ => panic!("Expected Project under Offset"),
896 }
897 }
898 _ => panic!("Expected Offset at top level"),
899 }
900 }
901
902 #[test]
903 fn test_relationship_properties_pushed_into_expand() {
904 let q = "MATCH (a)-[:KNOWS {since: 2020}]->(b) RETURN b";
905 let ast = parse_cypher_query(q).unwrap();
906 let mut planner = LogicalPlanner::new();
907 let logical = planner.plan(&ast).unwrap();
908 match logical {
909 LogicalOperator::Project { input, .. } => match *input {
910 LogicalOperator::Expand { properties, .. } => match properties.get("since") {
911 Some(PropertyValue::Integer(2020)) => {}
912 _ => panic!("Expected relationship property since=2020 in Expand"),
913 },
914 _ => panic!("Expected Expand under Project"),
915 },
916 _ => panic!("Expected Project at top level"),
917 }
918 }
919
920 #[test]
921 fn test_multiple_match_clauses_cross_join() {
922 let q = "MATCH (a:Person) MATCH (b:Company) RETURN a.name, b.name";
923 let ast = parse_cypher_query(q).unwrap();
924 let mut planner = LogicalPlanner::new();
925 let logical = planner.plan(&ast).unwrap();
926 match logical {
927 LogicalOperator::Project { input, .. } => match *input {
928 LogicalOperator::Join {
929 left,
930 right,
931 join_type,
932 } => {
933 assert!(matches!(join_type, JoinType::Cross));
934 match (*left, *right) {
935 (
936 LogicalOperator::ScanByLabel {
937 variable: va,
938 label: la,
939 ..
940 },
941 LogicalOperator::ScanByLabel {
942 variable: vb,
943 label: lb,
944 ..
945 },
946 ) => {
947 assert_eq!(va, "a");
948 assert_eq!(la, "Person");
949 assert_eq!(vb, "b");
950 assert_eq!(lb, "Company");
951 }
952 _ => panic!("Expected two scans under Join"),
953 }
954 }
955 _ => panic!("Expected Join under Project"),
956 },
957 _ => panic!("Expected Project at top level"),
958 }
959 }
960
961 #[test]
962 fn test_variable_only_node_default_label() {
963 let q = "MATCH (x) RETURN x";
964 let ast = parse_cypher_query(q).unwrap();
965 let mut planner = LogicalPlanner::new();
966 let logical = planner.plan(&ast).unwrap();
967 match logical {
968 LogicalOperator::Project { input, .. } => match *input {
969 LogicalOperator::ScanByLabel {
970 variable, label, ..
971 } => {
972 assert_eq!(variable, "x");
973 assert_eq!(label, "Node");
974 }
975 _ => panic!("Expected ScanByLabel under Project"),
976 },
977 _ => panic!("Expected Project at top level"),
978 }
979 }
980
981 #[test]
982 fn test_multi_label_node_uses_first_label() {
983 let q = "MATCH (n:Person:Employee) RETURN n";
984 let ast = parse_cypher_query(q).unwrap();
985 let mut planner = LogicalPlanner::new();
986 let logical = planner.plan(&ast).unwrap();
987 match logical {
988 LogicalOperator::Project { input, .. } => match *input {
989 LogicalOperator::ScanByLabel { label, .. } => {
990 assert_eq!(label, "Person");
991 }
992 _ => panic!("Expected ScanByLabel under Project"),
993 },
994 _ => panic!("Expected Project at top level"),
995 }
996 }
997
998 #[test]
999 fn test_open_ended_and_partial_var_length_ranges() {
1000 let q1 = "MATCH (a)-[:R*]->(b) RETURN b";
1002 let ast1 = parse_cypher_query(q1).unwrap();
1003 let mut planner1 = LogicalPlanner::new();
1004 let plan1 = planner1.plan(&ast1).unwrap();
1005 match plan1 {
1006 LogicalOperator::Project { input, .. } => match *input {
1007 LogicalOperator::VariableLengthExpand {
1008 min_length,
1009 max_length,
1010 ..
1011 } => {
1012 assert_eq!(min_length, None);
1013 assert_eq!(max_length, None);
1014 }
1015 _ => panic!("Expected VariableLengthExpand for *"),
1016 },
1017 _ => panic!("Expected Project at top level"),
1018 }
1019
1020 let q2 = "MATCH (a)-[:R*2..]->(b) RETURN b";
1022 let ast2 = parse_cypher_query(q2).unwrap();
1023 let mut planner2 = LogicalPlanner::new();
1024 let plan2 = planner2.plan(&ast2).unwrap();
1025 match plan2 {
1026 LogicalOperator::Project { input, .. } => match *input {
1027 LogicalOperator::VariableLengthExpand {
1028 min_length,
1029 max_length,
1030 ..
1031 } => {
1032 assert_eq!(min_length, Some(2));
1033 assert_eq!(max_length, None);
1034 }
1035 _ => panic!("Expected VariableLengthExpand for *2.."),
1036 },
1037 _ => panic!("Expected Project at top level"),
1038 }
1039
1040 let q3 = "MATCH (a)-[:R*..3]->(b) RETURN b";
1042 let ast3 = parse_cypher_query(q3).unwrap();
1043 let mut planner3 = LogicalPlanner::new();
1044 let plan3 = planner3.plan(&ast3).unwrap();
1045 match plan3 {
1046 LogicalOperator::Project { input, .. } => match *input {
1047 LogicalOperator::VariableLengthExpand {
1048 min_length,
1049 max_length,
1050 ..
1051 } => {
1052 assert_eq!(min_length, None);
1053 assert_eq!(max_length, Some(3));
1054 }
1055 _ => panic!("Expected VariableLengthExpand for *..3"),
1056 },
1057 _ => panic!("Expected Project at top level"),
1058 }
1059 }
1060}