1pub mod optimize;
4
5use crate::parser::ast::*;
6use cypherlite_core::LabelRegistry;
7
8#[derive(Debug, Clone, PartialEq)]
10pub enum LogicalPlan {
11 NodeScan {
14 variable: String,
16 label_id: Option<u32>,
18 limit: Option<usize>,
20 },
21 Expand {
23 source: Box<LogicalPlan>,
25 src_var: String,
27 rel_var: Option<String>,
29 target_var: String,
31 rel_type_id: Option<u32>,
33 direction: RelDirection,
35 temporal_filter: Option<TemporalFilterPlan>,
37 },
38 Filter {
40 source: Box<LogicalPlan>,
42 predicate: Expression,
44 },
45 Project {
47 source: Box<LogicalPlan>,
49 items: Vec<ReturnItem>,
51 distinct: bool,
53 },
54 Sort {
56 source: Box<LogicalPlan>,
58 items: Vec<OrderItem>,
60 },
61 Skip {
63 source: Box<LogicalPlan>,
65 count: Expression,
67 },
68 Limit {
70 source: Box<LogicalPlan>,
72 count: Expression,
74 },
75 Aggregate {
77 source: Box<LogicalPlan>,
79 group_keys: Vec<Expression>,
81 aggregates: Vec<(String, AggregateFunc)>,
83 },
84 CreateOp {
86 source: Option<Box<LogicalPlan>>,
88 pattern: Pattern,
90 },
91 DeleteOp {
93 source: Box<LogicalPlan>,
95 exprs: Vec<Expression>,
97 detach: bool,
99 },
100 SetOp {
102 source: Box<LogicalPlan>,
104 items: Vec<SetItem>,
106 },
107 RemoveOp {
109 source: Box<LogicalPlan>,
111 items: Vec<RemoveItem>,
113 },
114 With {
116 source: Box<LogicalPlan>,
118 items: Vec<ReturnItem>,
120 where_clause: Option<Expression>,
122 distinct: bool,
124 },
125 Unwind {
127 source: Box<LogicalPlan>,
129 expr: Expression,
131 variable: String,
133 },
134 OptionalExpand {
137 source: Box<LogicalPlan>,
139 src_var: String,
141 rel_var: Option<String>,
143 target_var: String,
145 rel_type_id: Option<u32>,
147 direction: RelDirection,
149 },
150 MergeOp {
152 source: Option<Box<LogicalPlan>>,
154 pattern: Pattern,
156 on_match: Vec<SetItem>,
158 on_create: Vec<SetItem>,
160 },
161 EmptySource,
163 CreateIndex {
165 name: Option<String>,
167 label: String,
169 property: String,
171 },
172 CreateEdgeIndex {
174 name: Option<String>,
176 rel_type: String,
178 property: String,
180 },
181 DropIndex {
183 name: String,
185 },
186 VarLengthExpand {
188 source: Box<LogicalPlan>,
190 src_var: String,
192 rel_var: Option<String>,
194 target_var: String,
196 rel_type_id: Option<u32>,
198 direction: RelDirection,
200 min_hops: u32,
202 max_hops: u32,
204 temporal_filter: Option<TemporalFilterPlan>,
206 },
207 IndexScan {
211 variable: String,
213 label_id: u32,
215 prop_key: String,
217 lookup_value: Expression,
219 },
220 AsOfScan {
222 source: Box<LogicalPlan>,
224 timestamp_expr: Expression,
226 },
227 TemporalRangeScan {
229 source: Box<LogicalPlan>,
231 start_expr: Expression,
233 end_expr: Expression,
235 },
236 #[cfg(feature = "subgraph")]
238 SubgraphScan {
239 variable: String,
241 },
242 #[cfg(feature = "hypergraph")]
244 HyperEdgeScan {
245 variable: String,
247 },
248 #[cfg(feature = "hypergraph")]
250 CreateHyperedgeOp {
251 source: Option<Box<LogicalPlan>>,
253 variable: Option<String>,
255 labels: Vec<String>,
257 sources: Vec<Expression>,
259 targets: Vec<Expression>,
261 },
262 #[cfg(feature = "subgraph")]
264 CreateSnapshotOp {
265 variable: Option<String>,
267 labels: Vec<String>,
269 properties: Option<MapLiteral>,
271 temporal_anchor: Option<Expression>,
273 sub_plan: Box<LogicalPlan>,
275 return_vars: Vec<String>,
277 },
278}
279
280#[derive(Debug, Clone, PartialEq)]
283pub enum TemporalFilterPlan {
284 AsOf(Expression),
286 Between(Expression, Expression),
288}
289
290#[derive(Debug, Clone, PartialEq)]
292pub enum AggregateFunc {
293 Count {
295 distinct: bool,
297 },
298 CountStar,
300}
301
302#[derive(Debug, Clone, PartialEq)]
304pub struct PlanError {
305 pub message: String,
307}
308
309impl std::fmt::Display for PlanError {
310 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311 write!(f, "Plan error: {}", self.message)
312 }
313}
314
315impl std::error::Error for PlanError {}
316
317pub const DEFAULT_MAX_HOPS: u32 = 10;
319
320fn annotate_temporal_filter(plan: &mut LogicalPlan, tfp: &TemporalFilterPlan) {
324 match plan {
325 LogicalPlan::Expand {
326 source,
327 temporal_filter,
328 ..
329 } => {
330 *temporal_filter = Some(tfp.clone());
331 annotate_temporal_filter(source, tfp);
332 }
333 LogicalPlan::VarLengthExpand {
334 source,
335 temporal_filter,
336 ..
337 } => {
338 *temporal_filter = Some(tfp.clone());
339 annotate_temporal_filter(source, tfp);
340 }
341 LogicalPlan::Filter { source, .. }
342 | LogicalPlan::Project { source, .. }
343 | LogicalPlan::Sort { source, .. }
344 | LogicalPlan::Skip { source, .. }
345 | LogicalPlan::Limit { source, .. }
346 | LogicalPlan::Aggregate { source, .. }
347 | LogicalPlan::SetOp { source, .. }
348 | LogicalPlan::RemoveOp { source, .. }
349 | LogicalPlan::With { source, .. }
350 | LogicalPlan::Unwind { source, .. }
351 | LogicalPlan::DeleteOp { source, .. }
352 | LogicalPlan::OptionalExpand { source, .. }
353 | LogicalPlan::AsOfScan { source, .. }
354 | LogicalPlan::TemporalRangeScan { source, .. } => {
355 annotate_temporal_filter(source, tfp);
356 }
357 LogicalPlan::CreateOp { source, .. } | LogicalPlan::MergeOp { source, .. } => {
358 if let Some(s) = source {
359 annotate_temporal_filter(s, tfp);
360 }
361 }
362 LogicalPlan::NodeScan { .. }
364 | LogicalPlan::IndexScan { .. }
365 | LogicalPlan::EmptySource
366 | LogicalPlan::CreateIndex { .. }
367 | LogicalPlan::CreateEdgeIndex { .. }
368 | LogicalPlan::DropIndex { .. } => {}
369 #[cfg(feature = "subgraph")]
370 LogicalPlan::SubgraphScan { .. } => {}
371 #[cfg(feature = "subgraph")]
372 LogicalPlan::CreateSnapshotOp { .. } => {}
373 #[cfg(feature = "hypergraph")]
374 LogicalPlan::HyperEdgeScan { .. } => {}
375 #[cfg(feature = "hypergraph")]
376 LogicalPlan::CreateHyperedgeOp { .. } => {}
377 }
378}
379
380pub struct LogicalPlanner<'a> {
382 registry: &'a mut dyn LabelRegistry,
383}
384
385impl<'a> LogicalPlanner<'a> {
386 pub fn new(registry: &'a mut dyn LabelRegistry) -> Self {
388 Self { registry }
389 }
390
391 pub fn plan(&mut self, query: &Query) -> Result<LogicalPlan, PlanError> {
393 let mut current: Option<LogicalPlan> = None;
394
395 for clause in &query.clauses {
396 current = Some(self.plan_clause(clause, current)?);
397 }
398
399 current.ok_or_else(|| PlanError {
400 message: "empty query produces no plan".to_string(),
401 })
402 }
403
404 fn plan_clause(
405 &mut self,
406 clause: &Clause,
407 current: Option<LogicalPlan>,
408 ) -> Result<LogicalPlan, PlanError> {
409 match clause {
410 Clause::Match(mc) => self.plan_match(mc, current),
411 Clause::Return(rc) => self.plan_return(rc, current),
412 Clause::Create(cc) => Ok(self.plan_create(cc, current)),
413 Clause::Set(sc) => self.plan_set(sc, current),
414 Clause::Delete(dc) => self.plan_delete(dc, current),
415 Clause::Remove(rc) => self.plan_remove(rc, current),
416 Clause::With(wc) => self.plan_with(wc, current),
417 Clause::Unwind(uc) => self.plan_unwind(uc, current),
418 Clause::Merge(mc) => Ok(self.plan_merge(mc, current)),
419 Clause::CreateIndex(ci) => match &ci.target {
420 crate::parser::ast::IndexTarget::NodeLabel(label) => Ok(LogicalPlan::CreateIndex {
421 name: ci.name.clone(),
422 label: label.clone(),
423 property: ci.property.clone(),
424 }),
425 crate::parser::ast::IndexTarget::RelationshipType(rel_type) => {
426 Ok(LogicalPlan::CreateEdgeIndex {
427 name: ci.name.clone(),
428 rel_type: rel_type.clone(),
429 property: ci.property.clone(),
430 })
431 }
432 },
433 Clause::DropIndex(di) => Ok(LogicalPlan::DropIndex {
434 name: di.name.clone(),
435 }),
436 #[cfg(feature = "subgraph")]
437 Clause::CreateSnapshot(sc) => self.plan_create_snapshot(sc),
438 #[cfg(feature = "hypergraph")]
439 Clause::CreateHyperedge(hc) => Ok(self.plan_create_hyperedge(hc, current)),
440 #[cfg(feature = "hypergraph")]
441 Clause::MatchHyperedge(mhc) => Ok(self.plan_match_hyperedge(mhc)),
442 }
443 }
444
445 fn plan_match(
446 &mut self,
447 mc: &MatchClause,
448 current: Option<LogicalPlan>,
449 ) -> Result<LogicalPlan, PlanError> {
450 if mc.optional {
451 return self.plan_optional_match(mc, current);
452 }
453
454 let chain = mc.pattern.chains.first().ok_or_else(|| PlanError {
457 message: "MATCH clause has no pattern chains".to_string(),
458 })?;
459
460 let mut plan = self.plan_pattern_chain(chain)?;
461
462 if let Some(prev) = current {
466 let _ = prev;
470 }
471
472 if let Some(ref tp) = mc.temporal_predicate {
474 let tfp = match tp {
477 crate::parser::ast::TemporalPredicate::AsOf(expr) => {
478 TemporalFilterPlan::AsOf(expr.clone())
479 }
480 crate::parser::ast::TemporalPredicate::Between(start, end) => {
481 TemporalFilterPlan::Between(start.clone(), end.clone())
482 }
483 };
484 annotate_temporal_filter(&mut plan, &tfp);
485
486 match tp {
487 crate::parser::ast::TemporalPredicate::AsOf(expr) => {
488 plan = LogicalPlan::AsOfScan {
489 source: Box::new(plan),
490 timestamp_expr: expr.clone(),
491 };
492 }
493 crate::parser::ast::TemporalPredicate::Between(start, end) => {
494 plan = LogicalPlan::TemporalRangeScan {
495 source: Box::new(plan),
496 start_expr: start.clone(),
497 end_expr: end.clone(),
498 };
499 }
500 }
501 }
502
503 if let Some(ref predicate) = mc.where_clause {
505 plan = LogicalPlan::Filter {
506 source: Box::new(plan),
507 predicate: predicate.clone(),
508 };
509 }
510
511 Ok(plan)
512 }
513
514 fn plan_optional_match(
517 &mut self,
518 mc: &MatchClause,
519 current: Option<LogicalPlan>,
520 ) -> Result<LogicalPlan, PlanError> {
521 let source = current.ok_or_else(|| PlanError {
522 message: "OPTIONAL MATCH requires a preceding MATCH clause".to_string(),
523 })?;
524
525 let chain = mc.pattern.chains.first().ok_or_else(|| PlanError {
526 message: "OPTIONAL MATCH clause has no pattern chains".to_string(),
527 })?;
528
529 let mut plan = source;
530
531 let mut elements = chain.elements.iter();
533 let first_node = match elements.next() {
534 Some(PatternElement::Node(np)) => np,
535 _ => {
536 return Err(PlanError {
537 message: "OPTIONAL MATCH pattern must start with a node".to_string(),
538 })
539 }
540 };
541
542 let _anchor_var = first_node.variable.clone().unwrap_or_default();
544
545 while let Some(rel_elem) = elements.next() {
547 let rel = match rel_elem {
548 PatternElement::Relationship(rp) => rp,
549 _ => {
550 return Err(PlanError {
551 message: "expected relationship after node in pattern".to_string(),
552 })
553 }
554 };
555
556 let target_node = match elements.next() {
557 Some(PatternElement::Node(np)) => np,
558 _ => {
559 return Err(PlanError {
560 message: "expected node after relationship in pattern".to_string(),
561 })
562 }
563 };
564
565 let src_var = Self::extract_src_var(&plan);
566 let rel_var = rel.variable.clone();
567 let target_var = target_node.variable.clone().unwrap_or_default();
568
569 let rel_type_id = rel
570 .rel_types
571 .first()
572 .map(|name| self.registry.get_or_create_rel_type(name));
573
574 plan = LogicalPlan::OptionalExpand {
575 source: Box::new(plan),
576 src_var,
577 rel_var,
578 target_var,
579 rel_type_id,
580 direction: rel.direction,
581 };
582 }
583
584 if let Some(ref predicate) = mc.where_clause {
586 plan = LogicalPlan::Filter {
587 source: Box::new(plan),
588 predicate: predicate.clone(),
589 };
590 }
591
592 Ok(plan)
593 }
594
595 fn build_inline_property_predicate(
602 variable: &str,
603 properties: &[(String, Expression)],
604 ) -> Option<Expression> {
605 properties
606 .iter()
607 .map(|(key, val_expr)| {
608 Expression::BinaryOp(
609 BinaryOp::Eq,
610 Box::new(Expression::Property(
611 Box::new(Expression::Variable(variable.to_string())),
612 key.clone(),
613 )),
614 Box::new(val_expr.clone()),
615 )
616 })
617 .reduce(|acc, p| Expression::BinaryOp(BinaryOp::And, Box::new(acc), Box::new(p)))
618 }
619
620 fn plan_pattern_chain(&mut self, chain: &PatternChain) -> Result<LogicalPlan, PlanError> {
621 let mut elements = chain.elements.iter();
622
623 let first_node = match elements.next() {
625 Some(PatternElement::Node(np)) => np,
626 _ => {
627 return Err(PlanError {
628 message: "pattern chain must start with a node".to_string(),
629 })
630 }
631 };
632
633 let variable = first_node.variable.clone().unwrap_or_default();
634
635 #[cfg(feature = "subgraph")]
637 let is_subgraph_label = first_node
638 .labels
639 .first()
640 .map(|l| l == "Subgraph")
641 .unwrap_or(false);
642
643 #[cfg(feature = "subgraph")]
644 if is_subgraph_label {
645 let mut plan = LogicalPlan::SubgraphScan {
646 variable: variable.clone(),
647 };
648
649 if let Some(ref props) = first_node.properties {
651 if let Some(pred) = Self::build_inline_property_predicate(&variable, props) {
652 plan = LogicalPlan::Filter {
653 source: Box::new(plan),
654 predicate: pred,
655 };
656 }
657 }
658
659 while let Some(rel_elem) = elements.next() {
661 let rel = match rel_elem {
662 PatternElement::Relationship(rp) => rp,
663 _ => {
664 return Err(PlanError {
665 message: "expected relationship after node in pattern".to_string(),
666 })
667 }
668 };
669
670 let target_node = match elements.next() {
671 Some(PatternElement::Node(np)) => np,
672 _ => {
673 return Err(PlanError {
674 message: "expected node after relationship in pattern".to_string(),
675 })
676 }
677 };
678
679 let src_var = Self::extract_src_var(&plan);
680 let target_var = target_node.variable.clone().unwrap_or_default();
681
682 let rel_type_id = rel
683 .rel_types
684 .first()
685 .map(|name| self.registry.get_or_create_rel_type(name));
686
687 let has_rel_props = rel.properties.as_ref().is_some_and(|p| !p.is_empty());
689 let rel_var = if rel.variable.is_some() {
690 rel.variable.clone()
691 } else if has_rel_props {
692 Some("_anon_rel".to_string())
693 } else {
694 None
695 };
696
697 plan = LogicalPlan::Expand {
698 source: Box::new(plan),
699 src_var,
700 rel_var: rel_var.clone(),
701 target_var: target_var.clone(),
702 rel_type_id,
703 direction: rel.direction,
704 temporal_filter: None,
705 };
706
707 if let Some(ref props) = rel.properties {
709 if let Some(ref rv) = rel_var {
710 if let Some(pred) = Self::build_inline_property_predicate(rv, props) {
711 plan = LogicalPlan::Filter {
712 source: Box::new(plan),
713 predicate: pred,
714 };
715 }
716 }
717 }
718
719 if let Some(ref props) = target_node.properties {
721 if let Some(pred) = Self::build_inline_property_predicate(&target_var, props) {
722 plan = LogicalPlan::Filter {
723 source: Box::new(plan),
724 predicate: pred,
725 };
726 }
727 }
728 }
729
730 return Ok(plan);
731 }
732
733 let label_id = first_node
734 .labels
735 .first()
736 .map(|name| self.registry.get_or_create_label(name));
737
738 let mut plan = LogicalPlan::NodeScan {
739 variable: variable.clone(),
740 label_id,
741 limit: None,
742 };
743
744 if let Some(ref props) = first_node.properties {
746 if let Some(pred) = Self::build_inline_property_predicate(&variable, props) {
747 plan = LogicalPlan::Filter {
748 source: Box::new(plan),
749 predicate: pred,
750 };
751 }
752 }
753
754 while let Some(rel_elem) = elements.next() {
756 let rel = match rel_elem {
757 PatternElement::Relationship(rp) => rp,
758 _ => {
759 return Err(PlanError {
760 message: "expected relationship after node in pattern".to_string(),
761 })
762 }
763 };
764
765 let target_node = match elements.next() {
766 Some(PatternElement::Node(np)) => np,
767 _ => {
768 return Err(PlanError {
769 message: "expected node after relationship in pattern".to_string(),
770 })
771 }
772 };
773
774 let src_var = Self::extract_src_var(&plan);
775 let target_var = target_node.variable.clone().unwrap_or_default();
776
777 let rel_type_id = rel
778 .rel_types
779 .first()
780 .map(|name| self.registry.get_or_create_rel_type(name));
781
782 let has_rel_props = rel.properties.as_ref().is_some_and(|p| !p.is_empty());
785 let rel_var = if rel.variable.is_some() {
786 rel.variable.clone()
787 } else if has_rel_props {
788 Some("_anon_rel".to_string())
789 } else {
790 None
791 };
792
793 if rel.min_hops.is_some() {
794 let min = rel.min_hops.unwrap_or(1);
796 let max = rel.max_hops.unwrap_or(DEFAULT_MAX_HOPS);
797 plan = LogicalPlan::VarLengthExpand {
798 source: Box::new(plan),
799 src_var,
800 rel_var: rel_var.clone(),
801 target_var: target_var.clone(),
802 rel_type_id,
803 direction: rel.direction,
804 min_hops: min,
805 max_hops: max,
806 temporal_filter: None,
807 };
808 } else {
809 plan = LogicalPlan::Expand {
810 source: Box::new(plan),
811 src_var,
812 rel_var: rel_var.clone(),
813 target_var: target_var.clone(),
814 rel_type_id,
815 direction: rel.direction,
816 temporal_filter: None,
817 };
818 }
819
820 if let Some(ref props) = rel.properties {
822 if let Some(ref rv) = rel_var {
823 if let Some(pred) = Self::build_inline_property_predicate(rv, props) {
824 plan = LogicalPlan::Filter {
825 source: Box::new(plan),
826 predicate: pred,
827 };
828 }
829 }
830 }
831
832 if let Some(ref props) = target_node.properties {
834 if let Some(pred) = Self::build_inline_property_predicate(&target_var, props) {
835 plan = LogicalPlan::Filter {
836 source: Box::new(plan),
837 predicate: pred,
838 };
839 }
840 }
841 }
842
843 Ok(plan)
844 }
845
846 fn extract_src_var(plan: &LogicalPlan) -> String {
848 match plan {
849 LogicalPlan::NodeScan { variable, .. } => variable.clone(),
850 LogicalPlan::Expand { target_var, .. } => target_var.clone(),
851 LogicalPlan::VarLengthExpand { target_var, .. } => target_var.clone(),
852 LogicalPlan::OptionalExpand { target_var, .. } => target_var.clone(),
853 LogicalPlan::Filter { source, .. } => Self::extract_src_var(source),
854 LogicalPlan::AsOfScan { source, .. } => Self::extract_src_var(source),
855 LogicalPlan::TemporalRangeScan { source, .. } => Self::extract_src_var(source),
856 #[cfg(feature = "subgraph")]
857 LogicalPlan::SubgraphScan { variable, .. } => variable.clone(),
858 #[cfg(feature = "hypergraph")]
859 LogicalPlan::HyperEdgeScan { variable, .. } => variable.clone(),
860 _ => String::new(),
861 }
862 }
863
864 fn plan_return(
865 &self,
866 rc: &ReturnClause,
867 current: Option<LogicalPlan>,
868 ) -> Result<LogicalPlan, PlanError> {
869 let source = current.ok_or_else(|| PlanError {
870 message: "RETURN clause requires a preceding data source".to_string(),
871 })?;
872
873 let has_aggregate = rc
876 .items
877 .iter()
878 .any(|item| Self::is_aggregate_expr(&item.expr));
879
880 let mut plan = if has_aggregate {
881 let mut group_keys = Vec::new();
882 let mut aggregates = Vec::new();
883
884 for item in &rc.items {
885 if Self::is_aggregate_expr(&item.expr) {
886 let alias = item
887 .alias
888 .clone()
889 .unwrap_or_else(|| Self::default_agg_name(&item.expr));
890 let func = Self::extract_aggregate_func(&item.expr)?;
891 aggregates.push((alias, func));
892 } else {
893 group_keys.push(item.expr.clone());
894 }
895 }
896
897 LogicalPlan::Aggregate {
898 source: Box::new(source),
899 group_keys,
900 aggregates,
901 }
902 } else {
903 LogicalPlan::Project {
904 source: Box::new(source),
905 items: rc.items.clone(),
906 distinct: rc.distinct,
907 }
908 };
909
910 if let Some(ref order_items) = rc.order_by {
912 plan = LogicalPlan::Sort {
913 source: Box::new(plan),
914 items: order_items.clone(),
915 };
916 }
917
918 if let Some(ref skip_expr) = rc.skip {
920 plan = LogicalPlan::Skip {
921 source: Box::new(plan),
922 count: skip_expr.clone(),
923 };
924 }
925
926 if let Some(ref limit_expr) = rc.limit {
928 plan = LogicalPlan::Limit {
929 source: Box::new(plan),
930 count: limit_expr.clone(),
931 };
932 }
933
934 Ok(plan)
935 }
936
937 fn plan_create(&self, cc: &CreateClause, current: Option<LogicalPlan>) -> LogicalPlan {
938 LogicalPlan::CreateOp {
939 source: current.map(Box::new),
940 pattern: cc.pattern.clone(),
941 }
942 }
943
944 fn plan_merge(&self, mc: &MergeClause, current: Option<LogicalPlan>) -> LogicalPlan {
945 LogicalPlan::MergeOp {
946 source: current.map(Box::new),
947 pattern: mc.pattern.clone(),
948 on_match: mc.on_match.clone(),
949 on_create: mc.on_create.clone(),
950 }
951 }
952
953 fn plan_set(
954 &self,
955 sc: &SetClause,
956 current: Option<LogicalPlan>,
957 ) -> Result<LogicalPlan, PlanError> {
958 let source = current.ok_or_else(|| PlanError {
959 message: "SET clause requires a preceding data source".to_string(),
960 })?;
961
962 Ok(LogicalPlan::SetOp {
963 source: Box::new(source),
964 items: sc.items.clone(),
965 })
966 }
967
968 fn plan_delete(
969 &self,
970 dc: &DeleteClause,
971 current: Option<LogicalPlan>,
972 ) -> Result<LogicalPlan, PlanError> {
973 let source = current.ok_or_else(|| PlanError {
974 message: "DELETE clause requires a preceding data source".to_string(),
975 })?;
976
977 Ok(LogicalPlan::DeleteOp {
978 source: Box::new(source),
979 exprs: dc.exprs.clone(),
980 detach: dc.detach,
981 })
982 }
983
984 fn plan_with(
985 &self,
986 wc: &WithClause,
987 current: Option<LogicalPlan>,
988 ) -> Result<LogicalPlan, PlanError> {
989 let source = current.ok_or_else(|| PlanError {
990 message: "WITH clause requires a preceding data source".to_string(),
991 })?;
992
993 let has_aggregate = wc
996 .items
997 .iter()
998 .any(|item| Self::is_aggregate_expr(&item.expr));
999
1000 if has_aggregate {
1001 let mut group_keys = Vec::new();
1002 let mut aggregates = Vec::new();
1003
1004 for item in &wc.items {
1005 if Self::is_aggregate_expr(&item.expr) {
1006 let alias = item
1007 .alias
1008 .clone()
1009 .unwrap_or_else(|| Self::default_agg_name(&item.expr));
1010 let func = Self::extract_aggregate_func(&item.expr)?;
1011 aggregates.push((alias, func));
1012 } else {
1013 group_keys.push(item.expr.clone());
1014 }
1015 }
1016
1017 let mut plan = LogicalPlan::Aggregate {
1018 source: Box::new(source),
1019 group_keys,
1020 aggregates,
1021 };
1022
1023 if let Some(ref predicate) = wc.where_clause {
1025 plan = LogicalPlan::Filter {
1026 source: Box::new(plan),
1027 predicate: predicate.clone(),
1028 };
1029 }
1030
1031 Ok(plan)
1032 } else {
1033 Ok(LogicalPlan::With {
1034 source: Box::new(source),
1035 items: wc.items.clone(),
1036 where_clause: wc.where_clause.clone(),
1037 distinct: wc.distinct,
1038 })
1039 }
1040 }
1041
1042 fn is_aggregate_expr(expr: &Expression) -> bool {
1044 match expr {
1045 Expression::CountStar => true,
1046 Expression::FunctionCall { name, .. } => {
1047 matches!(
1048 name.to_lowercase().as_str(),
1049 "count" | "sum" | "avg" | "min" | "max" | "collect"
1050 )
1051 }
1052 _ => false,
1053 }
1054 }
1055
1056 fn extract_aggregate_func(expr: &Expression) -> Result<AggregateFunc, PlanError> {
1058 match expr {
1059 Expression::CountStar => Ok(AggregateFunc::CountStar),
1060 Expression::FunctionCall { name, distinct, .. } => match name.to_lowercase().as_str() {
1061 "count" => Ok(AggregateFunc::Count {
1062 distinct: *distinct,
1063 }),
1064 other => Err(PlanError {
1065 message: format!("unsupported aggregate function: {}", other),
1066 }),
1067 },
1068 _ => Err(PlanError {
1069 message: "not an aggregate expression".to_string(),
1070 }),
1071 }
1072 }
1073
1074 fn default_agg_name(expr: &Expression) -> String {
1076 match expr {
1077 Expression::CountStar => "count(*)".to_string(),
1078 Expression::FunctionCall { name, .. } => format!("{}(..)", name),
1079 _ => "agg".to_string(),
1080 }
1081 }
1082
1083 fn plan_unwind(
1084 &self,
1085 uc: &UnwindClause,
1086 current: Option<LogicalPlan>,
1087 ) -> Result<LogicalPlan, PlanError> {
1088 let source = current.unwrap_or(LogicalPlan::EmptySource);
1089 Ok(LogicalPlan::Unwind {
1090 source: Box::new(source),
1091 expr: uc.expr.clone(),
1092 variable: uc.variable.clone(),
1093 })
1094 }
1095
1096 fn plan_remove(
1097 &self,
1098 rc: &RemoveClause,
1099 current: Option<LogicalPlan>,
1100 ) -> Result<LogicalPlan, PlanError> {
1101 let source = current.ok_or_else(|| PlanError {
1102 message: "REMOVE clause requires a preceding data source".to_string(),
1103 })?;
1104
1105 Ok(LogicalPlan::RemoveOp {
1106 source: Box::new(source),
1107 items: rc.items.clone(),
1108 })
1109 }
1110
1111 #[cfg(feature = "subgraph")]
1114 #[cfg(feature = "hypergraph")]
1116 fn plan_create_hyperedge(
1117 &mut self,
1118 hc: &crate::parser::ast::CreateHyperedgeClause,
1119 current: Option<LogicalPlan>,
1120 ) -> LogicalPlan {
1121 LogicalPlan::CreateHyperedgeOp {
1122 source: current.map(Box::new),
1123 variable: hc.variable.clone(),
1124 labels: hc.labels.clone(),
1125 sources: hc.sources.clone(),
1126 targets: hc.targets.clone(),
1127 }
1128 }
1129
1130 #[cfg(feature = "hypergraph")]
1132 fn plan_match_hyperedge(
1133 &mut self,
1134 mhc: &crate::parser::ast::MatchHyperedgeClause,
1135 ) -> LogicalPlan {
1136 let variable = mhc.variable.clone().unwrap_or_default();
1137 let mut plan = LogicalPlan::HyperEdgeScan {
1138 variable: variable.clone(),
1139 };
1140
1141 if let Some(label) = mhc.labels.first() {
1143 let _rel_type_id = self.registry.get_or_create_rel_type(label);
1144 let _ = plan;
1149 plan = LogicalPlan::HyperEdgeScan {
1150 variable: variable.clone(),
1151 };
1152 }
1153
1154 plan
1155 }
1156
1157 #[cfg(feature = "subgraph")]
1158 fn plan_create_snapshot(
1159 &mut self,
1160 sc: &crate::parser::ast::CreateSnapshotClause,
1161 ) -> Result<LogicalPlan, PlanError> {
1162 let chain = sc
1164 .from_match
1165 .pattern
1166 .chains
1167 .first()
1168 .ok_or_else(|| PlanError {
1169 message: "CREATE SNAPSHOT FROM MATCH clause has no pattern chains".to_string(),
1170 })?;
1171 let mut sub_plan = self.plan_pattern_chain(chain)?;
1172
1173 if let Some(ref predicate) = sc.from_match.where_clause {
1175 sub_plan = LogicalPlan::Filter {
1176 source: Box::new(sub_plan),
1177 predicate: predicate.clone(),
1178 };
1179 }
1180
1181 sub_plan = LogicalPlan::Project {
1183 source: Box::new(sub_plan),
1184 items: sc.from_return.clone(),
1185 distinct: false,
1186 };
1187
1188 let return_vars: Vec<String> = sc
1190 .from_return
1191 .iter()
1192 .map(|item| {
1193 if let Some(ref alias) = item.alias {
1194 alias.clone()
1195 } else if let Expression::Variable(name) = &item.expr {
1196 name.clone()
1197 } else {
1198 String::new()
1199 }
1200 })
1201 .collect();
1202
1203 Ok(LogicalPlan::CreateSnapshotOp {
1204 variable: sc.variable.clone(),
1205 labels: sc.labels.clone(),
1206 properties: sc.properties.clone(),
1207 temporal_anchor: sc.temporal_anchor.clone(),
1208 sub_plan: Box::new(sub_plan),
1209 return_vars,
1210 })
1211 }
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216 use super::*;
1217 use crate::parser::parse_query;
1218 use cypherlite_storage::catalog::Catalog;
1219
1220 fn plan_query(input: &str) -> LogicalPlan {
1222 let query = parse_query(input).expect("should parse");
1223 let mut catalog = Catalog::default();
1224 let mut planner = LogicalPlanner::new(&mut catalog);
1225 planner.plan(&query).expect("should plan")
1226 }
1227
1228 fn plan_query_with_catalog(input: &str) -> (LogicalPlan, Catalog) {
1230 let query = parse_query(input).expect("should parse");
1231 let mut catalog = Catalog::default();
1232 let plan = {
1233 let mut planner = LogicalPlanner::new(&mut catalog);
1234 planner.plan(&query).expect("should plan")
1235 };
1236 (plan, catalog)
1237 }
1238
1239 #[test]
1245 fn test_plan_single_node_match_return() {
1246 let (plan, catalog) = plan_query_with_catalog("MATCH (n:Person) RETURN n");
1247 let person_id = catalog.label_id("Person").expect("Person label exists");
1248
1249 match &plan {
1251 LogicalPlan::Project {
1252 source, distinct, ..
1253 } => {
1254 assert!(!distinct);
1255 match source.as_ref() {
1256 LogicalPlan::NodeScan {
1257 variable, label_id, ..
1258 } => {
1259 assert_eq!(variable, "n");
1260 assert_eq!(*label_id, Some(person_id));
1261 }
1262 other => panic!("expected NodeScan, got {:?}", other),
1263 }
1264 }
1265 other => panic!("expected Project, got {:?}", other),
1266 }
1267 }
1268
1269 #[test]
1272 fn test_plan_2hop_match() {
1273 let (plan, catalog) =
1274 plan_query_with_catalog("MATCH (a)-[:KNOWS]->(b)-[:KNOWS]->(c) RETURN c");
1275 let knows_id = catalog.rel_type_id("KNOWS").expect("KNOWS rel type exists");
1276
1277 let project_source = match &plan {
1279 LogicalPlan::Project { source, .. } => source.as_ref(),
1280 other => panic!("expected Project, got {:?}", other),
1281 };
1282
1283 let expand1_source = match project_source {
1285 LogicalPlan::Expand {
1286 src_var,
1287 target_var,
1288 rel_type_id,
1289 direction,
1290 source,
1291 ..
1292 } => {
1293 assert_eq!(src_var, "b");
1294 assert_eq!(target_var, "c");
1295 assert_eq!(*rel_type_id, Some(knows_id));
1296 assert_eq!(*direction, RelDirection::Outgoing);
1297 source.as_ref()
1298 }
1299 other => panic!("expected Expand, got {:?}", other),
1300 };
1301
1302 let scan = match expand1_source {
1304 LogicalPlan::Expand {
1305 src_var,
1306 target_var,
1307 rel_type_id,
1308 direction,
1309 source,
1310 ..
1311 } => {
1312 assert_eq!(src_var, "a");
1313 assert_eq!(target_var, "b");
1314 assert_eq!(*rel_type_id, Some(knows_id));
1315 assert_eq!(*direction, RelDirection::Outgoing);
1316 source.as_ref()
1317 }
1318 other => panic!("expected Expand, got {:?}", other),
1319 };
1320
1321 match scan {
1323 LogicalPlan::NodeScan {
1324 variable, label_id, ..
1325 } => {
1326 assert_eq!(variable, "a");
1327 assert_eq!(*label_id, None); }
1329 other => panic!("expected NodeScan, got {:?}", other),
1330 }
1331 }
1332
1333 #[test]
1335 fn test_plan_match_where_return() {
1336 let plan = plan_query("MATCH (n:Person) WHERE n.age > 30 RETURN n");
1337
1338 let project_source = match &plan {
1340 LogicalPlan::Project { source, .. } => source.as_ref(),
1341 other => panic!("expected Project, got {:?}", other),
1342 };
1343
1344 let filter_source = match project_source {
1346 LogicalPlan::Filter {
1347 source, predicate, ..
1348 } => {
1349 match predicate {
1351 Expression::BinaryOp(BinaryOp::Gt, lhs, rhs) => {
1352 assert_eq!(
1353 **lhs,
1354 Expression::Property(
1355 Box::new(Expression::Variable("n".to_string())),
1356 "age".to_string()
1357 )
1358 );
1359 assert_eq!(**rhs, Expression::Literal(Literal::Integer(30)));
1360 }
1361 other => panic!("expected BinaryOp Gt, got {:?}", other),
1362 }
1363 source.as_ref()
1364 }
1365 other => panic!("expected Filter, got {:?}", other),
1366 };
1367
1368 match filter_source {
1370 LogicalPlan::NodeScan {
1371 variable, label_id, ..
1372 } => {
1373 assert_eq!(variable, "n");
1374 assert!(label_id.is_some());
1375 }
1376 other => panic!("expected NodeScan, got {:?}", other),
1377 }
1378 }
1379
1380 #[test]
1382 fn test_plan_match_create() {
1383 let plan = plan_query("MATCH (n) CREATE (m:Person {name: 'Alice'})");
1384
1385 match &plan {
1386 LogicalPlan::CreateOp {
1387 source, pattern, ..
1388 } => {
1389 let src = source.as_ref().expect("should have source");
1391 match src.as_ref() {
1392 LogicalPlan::NodeScan {
1393 variable, label_id, ..
1394 } => {
1395 assert_eq!(variable, "n");
1396 assert_eq!(*label_id, None);
1397 }
1398 other => panic!("expected NodeScan, got {:?}", other),
1399 }
1400 assert!(!pattern.chains.is_empty());
1402 }
1403 other => panic!("expected CreateOp, got {:?}", other),
1404 }
1405 }
1406
1407 #[test]
1409 fn test_plan_create_only() {
1410 let plan = plan_query("CREATE (n:Person)");
1411
1412 match &plan {
1413 LogicalPlan::CreateOp { source, pattern } => {
1414 assert!(source.is_none());
1415 assert!(!pattern.chains.is_empty());
1416 }
1417 other => panic!("expected CreateOp, got {:?}", other),
1418 }
1419 }
1420
1421 #[test]
1424 fn test_plan_match_set_return() {
1425 let plan = plan_query("MATCH (n:Person) SET n.name = 'Bob' RETURN n");
1426
1427 let project_source = match &plan {
1429 LogicalPlan::Project { source, .. } => source.as_ref(),
1430 other => panic!("expected Project, got {:?}", other),
1431 };
1432
1433 let set_source = match project_source {
1435 LogicalPlan::SetOp { source, items } => {
1436 assert_eq!(items.len(), 1);
1437 source.as_ref()
1438 }
1439 other => panic!("expected SetOp, got {:?}", other),
1440 };
1441
1442 match set_source {
1444 LogicalPlan::NodeScan { variable, .. } => {
1445 assert_eq!(variable, "n");
1446 }
1447 other => panic!("expected NodeScan, got {:?}", other),
1448 }
1449 }
1450
1451 #[test]
1453 fn test_plan_match_delete() {
1454 let plan = plan_query("MATCH (n) DELETE n");
1455
1456 match &plan {
1457 LogicalPlan::DeleteOp {
1458 source,
1459 exprs,
1460 detach,
1461 } => {
1462 assert!(!detach);
1463 assert_eq!(exprs.len(), 1);
1464 assert_eq!(exprs[0], Expression::Variable("n".to_string()));
1465 match source.as_ref() {
1466 LogicalPlan::NodeScan { variable, .. } => {
1467 assert_eq!(variable, "n");
1468 }
1469 other => panic!("expected NodeScan, got {:?}", other),
1470 }
1471 }
1472 other => panic!("expected DeleteOp, got {:?}", other),
1473 }
1474 }
1475
1476 #[test]
1479 fn test_plan_return_with_order_skip_limit() {
1480 let plan = plan_query("MATCH (n) RETURN n ORDER BY n.name SKIP 5 LIMIT 10");
1481
1482 let limit_source = match &plan {
1484 LogicalPlan::Limit { source, count } => {
1485 assert_eq!(*count, Expression::Literal(Literal::Integer(10)));
1486 source.as_ref()
1487 }
1488 other => panic!("expected Limit, got {:?}", other),
1489 };
1490
1491 let skip_source = match limit_source {
1493 LogicalPlan::Skip { source, count } => {
1494 assert_eq!(*count, Expression::Literal(Literal::Integer(5)));
1495 source.as_ref()
1496 }
1497 other => panic!("expected Skip, got {:?}", other),
1498 };
1499
1500 let sort_source = match skip_source {
1502 LogicalPlan::Sort { source, items } => {
1503 assert_eq!(items.len(), 1);
1504 source.as_ref()
1505 }
1506 other => panic!("expected Sort, got {:?}", other),
1507 };
1508
1509 match sort_source {
1511 LogicalPlan::Project { source, .. } => match source.as_ref() {
1512 LogicalPlan::NodeScan { variable, .. } => {
1513 assert_eq!(variable, "n");
1514 }
1515 other => panic!("expected NodeScan, got {:?}", other),
1516 },
1517 other => panic!("expected Project, got {:?}", other),
1518 }
1519 }
1520
1521 #[test]
1524 fn test_plan_match_remove() {
1525 let plan = plan_query("MATCH (n:Person) REMOVE n.email, n:Temp");
1526
1527 match &plan {
1528 LogicalPlan::RemoveOp { source, items } => {
1529 assert_eq!(items.len(), 2);
1530 match source.as_ref() {
1531 LogicalPlan::NodeScan { variable, .. } => {
1532 assert_eq!(variable, "n");
1533 }
1534 other => panic!("expected NodeScan, got {:?}", other),
1535 }
1536 }
1537 other => panic!("expected RemoveOp, got {:?}", other),
1538 }
1539 }
1540
1541 #[test]
1543 fn test_plan_error_display() {
1544 let err = PlanError {
1545 message: "test error".to_string(),
1546 };
1547 assert_eq!(err.to_string(), "Plan error: test error");
1548 }
1549
1550 #[test]
1552 fn test_plan_return_without_source_fails() {
1553 let query = parse_query("MATCH (n) RETURN n").expect("should parse");
1554 let return_only = Query {
1556 clauses: vec![query.clauses.into_iter().nth(1).expect("has RETURN")],
1557 };
1558 let mut catalog = Catalog::default();
1559 let mut planner = LogicalPlanner::new(&mut catalog);
1560 let result = planner.plan(&return_only);
1561 assert!(result.is_err());
1562 assert!(result
1563 .expect_err("should fail")
1564 .message
1565 .contains("requires a preceding data source"));
1566 }
1567
1568 #[test]
1574 fn test_plan_with_simple() {
1575 let plan = plan_query("MATCH (n:Person) WITH n RETURN n");
1576
1577 let project_source = match &plan {
1579 LogicalPlan::Project { source, .. } => source.as_ref(),
1580 other => panic!("expected Project, got {:?}", other),
1581 };
1582
1583 let with_source = match project_source {
1585 LogicalPlan::With {
1586 source,
1587 items,
1588 where_clause,
1589 distinct,
1590 } => {
1591 assert_eq!(items.len(), 1);
1592 assert!(where_clause.is_none());
1593 assert!(!distinct);
1594 source.as_ref()
1595 }
1596 other => panic!("expected With, got {:?}", other),
1597 };
1598
1599 match with_source {
1601 LogicalPlan::NodeScan { variable, .. } => {
1602 assert_eq!(variable, "n");
1603 }
1604 other => panic!("expected NodeScan, got {:?}", other),
1605 }
1606 }
1607
1608 #[test]
1610 fn test_plan_with_where() {
1611 let plan = plan_query("MATCH (n:Person) WITH n WHERE n.age > 30 RETURN n");
1612
1613 let project_source = match &plan {
1614 LogicalPlan::Project { source, .. } => source.as_ref(),
1615 other => panic!("expected Project, got {:?}", other),
1616 };
1617
1618 match project_source {
1619 LogicalPlan::With {
1620 where_clause,
1621 items,
1622 ..
1623 } => {
1624 assert_eq!(items.len(), 1);
1625 assert!(where_clause.is_some());
1626 }
1627 other => panic!("expected With, got {:?}", other),
1628 }
1629 }
1630
1631 #[test]
1633 fn test_plan_with_without_source_fails() {
1634 let query = parse_query("MATCH (n) WITH n RETURN n").expect("should parse");
1635 let with_only = Query {
1637 clauses: vec![query.clauses.into_iter().nth(1).expect("has WITH")],
1638 };
1639 let mut catalog = Catalog::default();
1640 let mut planner = LogicalPlanner::new(&mut catalog);
1641 let result = planner.plan(&with_only);
1642 assert!(result.is_err());
1643 assert!(result
1644 .expect_err("should fail")
1645 .message
1646 .contains("requires a preceding data source"));
1647 }
1648
1649 #[test]
1655 fn test_plan_with_distinct() {
1656 let plan = plan_query("MATCH (n:Person) WITH DISTINCT n.name AS name RETURN name");
1657
1658 let project_source = match &plan {
1659 LogicalPlan::Project { source, .. } => source.as_ref(),
1660 other => panic!("expected Project, got {:?}", other),
1661 };
1662
1663 match project_source {
1664 LogicalPlan::With {
1665 distinct, items, ..
1666 } => {
1667 assert!(distinct);
1668 assert_eq!(items.len(), 1);
1669 assert_eq!(items[0].alias, Some("name".to_string()));
1670 }
1671 other => panic!("expected With, got {:?}", other),
1672 }
1673 }
1674
1675 #[test]
1682 fn test_plan_with_count_star_aggregation() {
1683 let plan = plan_query("MATCH (n:Person) WITH n, count(*) AS cnt RETURN n, cnt");
1684
1685 let project_source = match &plan {
1687 LogicalPlan::Project { source, .. } => source.as_ref(),
1688 other => panic!("expected Project, got {:?}", other),
1689 };
1690
1691 match project_source {
1693 LogicalPlan::Aggregate {
1694 group_keys,
1695 aggregates,
1696 source,
1697 ..
1698 } => {
1699 assert_eq!(group_keys.len(), 1);
1701 assert_eq!(group_keys[0], Expression::Variable("n".to_string()));
1702 assert_eq!(aggregates.len(), 1);
1704 assert_eq!(aggregates[0].0, "cnt");
1705 assert_eq!(aggregates[0].1, AggregateFunc::CountStar);
1706 match source.as_ref() {
1708 LogicalPlan::NodeScan { variable, .. } => {
1709 assert_eq!(variable, "n");
1710 }
1711 other => panic!("expected NodeScan, got {:?}", other),
1712 }
1713 }
1714 other => panic!("expected Aggregate, got {:?}", other),
1715 }
1716 }
1717
1718 #[test]
1721 fn test_plan_with_count_star_no_group_key() {
1722 let plan = plan_query("MATCH (n:Person) WITH count(*) AS total RETURN total");
1723
1724 let project_source = match &plan {
1725 LogicalPlan::Project { source, .. } => source.as_ref(),
1726 other => panic!("expected Project, got {:?}", other),
1727 };
1728
1729 match project_source {
1730 LogicalPlan::Aggregate {
1731 group_keys,
1732 aggregates,
1733 ..
1734 } => {
1735 assert!(group_keys.is_empty());
1736 assert_eq!(aggregates.len(), 1);
1737 assert_eq!(aggregates[0].0, "total");
1738 assert_eq!(aggregates[0].1, AggregateFunc::CountStar);
1739 }
1740 other => panic!("expected Aggregate, got {:?}", other),
1741 }
1742 }
1743
1744 #[test]
1746 fn test_optimizer_passthrough() {
1747 let plan = plan_query("MATCH (n:Person) WHERE n.age > 30 RETURN n");
1748 let optimized = optimize::optimize(plan.clone());
1749 assert_eq!(plan, optimized);
1750 }
1751
1752 #[test]
1758 fn test_plan_unwind_list_literal() {
1759 let plan = plan_query("UNWIND [1, 2, 3] AS x RETURN x");
1760
1761 let project_source = match &plan {
1763 LogicalPlan::Project { source, .. } => source.as_ref(),
1764 other => panic!("expected Project, got {:?}", other),
1765 };
1766
1767 match project_source {
1769 LogicalPlan::Unwind {
1770 source,
1771 expr,
1772 variable,
1773 } => {
1774 assert_eq!(variable, "x");
1775 assert!(matches!(expr, Expression::ListLiteral(_)));
1776 match source.as_ref() {
1778 LogicalPlan::EmptySource => {}
1779 other => panic!("expected EmptySource, got {:?}", other),
1780 }
1781 }
1782 other => panic!("expected Unwind, got {:?}", other),
1783 }
1784 }
1785
1786 #[test]
1789 fn test_plan_match_unwind() {
1790 let plan = plan_query("MATCH (n:Person) UNWIND n.hobbies AS h RETURN h");
1791
1792 let project_source = match &plan {
1793 LogicalPlan::Project { source, .. } => source.as_ref(),
1794 other => panic!("expected Project, got {:?}", other),
1795 };
1796
1797 match project_source {
1798 LogicalPlan::Unwind {
1799 source,
1800 variable,
1801 expr,
1802 } => {
1803 assert_eq!(variable, "h");
1804 assert_eq!(
1805 *expr,
1806 Expression::Property(
1807 Box::new(Expression::Variable("n".to_string())),
1808 "hobbies".to_string(),
1809 )
1810 );
1811 match source.as_ref() {
1812 LogicalPlan::NodeScan { variable, .. } => {
1813 assert_eq!(variable, "n");
1814 }
1815 other => panic!("expected NodeScan, got {:?}", other),
1816 }
1817 }
1818 other => panic!("expected Unwind, got {:?}", other),
1819 }
1820 }
1821
1822 #[test]
1829 fn test_plan_optional_match_basic() {
1830 let (plan, catalog) = plan_query_with_catalog(
1831 "MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b) RETURN a, b",
1832 );
1833 let knows_id = catalog.rel_type_id("KNOWS").expect("KNOWS rel type");
1834
1835 let project_source = match &plan {
1837 LogicalPlan::Project { source, .. } => source.as_ref(),
1838 other => panic!("expected Project, got {:?}", other),
1839 };
1840
1841 let opt_source = match project_source {
1843 LogicalPlan::OptionalExpand {
1844 src_var,
1845 rel_var,
1846 target_var,
1847 rel_type_id,
1848 direction,
1849 source,
1850 } => {
1851 assert_eq!(src_var, "a");
1852 assert!(rel_var.is_none());
1853 assert_eq!(target_var, "b");
1854 assert_eq!(*rel_type_id, Some(knows_id));
1855 assert_eq!(*direction, RelDirection::Outgoing);
1856 source.as_ref()
1857 }
1858 other => panic!("expected OptionalExpand, got {:?}", other),
1859 };
1860
1861 match opt_source {
1863 LogicalPlan::NodeScan {
1864 variable, label_id, ..
1865 } => {
1866 assert_eq!(variable, "a");
1867 assert!(label_id.is_some());
1868 }
1869 other => panic!("expected NodeScan, got {:?}", other),
1870 }
1871 }
1872
1873 #[test]
1875 fn test_plan_optional_match_without_source_fails() {
1876 let query =
1877 parse_query("OPTIONAL MATCH (a)-[:KNOWS]->(b) RETURN a, b").expect("should parse");
1878 let mut catalog = Catalog::default();
1879 let mut planner = LogicalPlanner::new(&mut catalog);
1880 let result = planner.plan(&query);
1881 assert!(result.is_err());
1882 assert!(result
1883 .expect_err("should fail")
1884 .message
1885 .contains("requires a preceding MATCH"));
1886 }
1887
1888 #[test]
1891 fn test_plan_optional_match_with_where() {
1892 let plan = plan_query(
1893 "MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b) WHERE b.age > 20 RETURN a, b",
1894 );
1895
1896 let project_source = match &plan {
1898 LogicalPlan::Project { source, .. } => source.as_ref(),
1899 other => panic!("expected Project, got {:?}", other),
1900 };
1901
1902 let filter_source = match project_source {
1904 LogicalPlan::Filter { source, .. } => source.as_ref(),
1905 other => panic!("expected Filter, got {:?}", other),
1906 };
1907
1908 match filter_source {
1910 LogicalPlan::OptionalExpand { target_var, .. } => {
1911 assert_eq!(target_var, "b");
1912 }
1913 other => panic!("expected OptionalExpand, got {:?}", other),
1914 }
1915 }
1916
1917 #[test]
1920 fn test_plan_optional_match_with_rel_var() {
1921 let plan = plan_query("MATCH (a:Person) OPTIONAL MATCH (a)-[r:KNOWS]->(b) RETURN a, r, b");
1922
1923 let project_source = match &plan {
1924 LogicalPlan::Project { source, .. } => source.as_ref(),
1925 other => panic!("expected Project, got {:?}", other),
1926 };
1927
1928 match project_source {
1929 LogicalPlan::OptionalExpand {
1930 rel_var,
1931 target_var,
1932 ..
1933 } => {
1934 assert_eq!(*rel_var, Some("r".to_string()));
1935 assert_eq!(target_var, "b");
1936 }
1937 other => panic!("expected OptionalExpand, got {:?}", other),
1938 }
1939 }
1940
1941 #[test]
1944 fn test_plan_var_length_bounded() {
1945 let plan = plan_query("MATCH (a)-[*1..3]->(b) RETURN b");
1946 match &plan {
1948 LogicalPlan::Project { source, .. } => match source.as_ref() {
1949 LogicalPlan::VarLengthExpand {
1950 src_var,
1951 target_var,
1952 min_hops,
1953 max_hops,
1954 ..
1955 } => {
1956 assert_eq!(src_var, "a");
1957 assert_eq!(target_var, "b");
1958 assert_eq!(*min_hops, 1);
1959 assert_eq!(*max_hops, 3);
1960 }
1961 other => panic!("expected VarLengthExpand, got {:?}", other),
1962 },
1963 other => panic!("expected Project, got {:?}", other),
1964 }
1965 }
1966
1967 #[test]
1968 fn test_plan_var_length_unbounded_gets_default_max() {
1969 let plan = plan_query("MATCH (a)-[*]->(b) RETURN b");
1970 match &plan {
1971 LogicalPlan::Project { source, .. } => match source.as_ref() {
1972 LogicalPlan::VarLengthExpand {
1973 min_hops, max_hops, ..
1974 } => {
1975 assert_eq!(*min_hops, 1);
1976 assert_eq!(*max_hops, DEFAULT_MAX_HOPS);
1977 }
1978 other => panic!("expected VarLengthExpand, got {:?}", other),
1979 },
1980 other => panic!("expected Project, got {:?}", other),
1981 }
1982 }
1983
1984 #[test]
1985 fn test_plan_var_length_typed() {
1986 let (plan, catalog) = plan_query_with_catalog("MATCH (a)-[:KNOWS*2..4]->(b) RETURN b");
1987 let knows_id = catalog.rel_type_id("KNOWS").expect("KNOWS exists");
1988 match &plan {
1989 LogicalPlan::Project { source, .. } => match source.as_ref() {
1990 LogicalPlan::VarLengthExpand {
1991 rel_type_id,
1992 min_hops,
1993 max_hops,
1994 ..
1995 } => {
1996 assert_eq!(*rel_type_id, Some(knows_id));
1997 assert_eq!(*min_hops, 2);
1998 assert_eq!(*max_hops, 4);
1999 }
2000 other => panic!("expected VarLengthExpand, got {:?}", other),
2001 },
2002 other => panic!("expected Project, got {:?}", other),
2003 }
2004 }
2005
2006 #[test]
2007 fn test_plan_regular_expand_unchanged() {
2008 let plan = plan_query("MATCH (a)-[:KNOWS]->(b) RETURN b");
2009 match &plan {
2010 LogicalPlan::Project { source, .. } => match source.as_ref() {
2011 LogicalPlan::Expand { .. } => {} other => panic!("expected Expand, got {:?}", other),
2013 },
2014 other => panic!("expected Project, got {:?}", other),
2015 }
2016 }
2017
2018 #[test]
2019 fn test_plan_var_length_exact_hop() {
2020 let plan = plan_query("MATCH (a)-[*2]->(b) RETURN b");
2021 match &plan {
2022 LogicalPlan::Project { source, .. } => match source.as_ref() {
2023 LogicalPlan::VarLengthExpand {
2024 min_hops, max_hops, ..
2025 } => {
2026 assert_eq!(*min_hops, 2);
2027 assert_eq!(*max_hops, 2);
2028 }
2029 other => panic!("expected VarLengthExpand, got {:?}", other),
2030 },
2031 other => panic!("expected Project, got {:?}", other),
2032 }
2033 }
2034
2035 #[test]
2036 fn test_plan_var_length_open_end_gets_default() {
2037 let plan = plan_query("MATCH (a)-[*3..]->(b) RETURN b");
2038 match &plan {
2039 LogicalPlan::Project { source, .. } => match source.as_ref() {
2040 LogicalPlan::VarLengthExpand {
2041 min_hops, max_hops, ..
2042 } => {
2043 assert_eq!(*min_hops, 3);
2044 assert_eq!(*max_hops, DEFAULT_MAX_HOPS);
2045 }
2046 other => panic!("expected VarLengthExpand, got {:?}", other),
2047 },
2048 other => panic!("expected Project, got {:?}", other),
2049 }
2050 }
2051
2052 #[test]
2053 fn test_plan_var_length_with_variable() {
2054 let plan = plan_query("MATCH (a)-[r:KNOWS*1..2]->(b) RETURN b");
2055 match &plan {
2056 LogicalPlan::Project { source, .. } => match source.as_ref() {
2057 LogicalPlan::VarLengthExpand {
2058 rel_var,
2059 min_hops,
2060 max_hops,
2061 ..
2062 } => {
2063 assert_eq!(*rel_var, Some("r".to_string()));
2064 assert_eq!(*min_hops, 1);
2065 assert_eq!(*max_hops, 2);
2066 }
2067 other => panic!("expected VarLengthExpand, got {:?}", other),
2068 },
2069 other => panic!("expected Project, got {:?}", other),
2070 }
2071 }
2072
2073 #[cfg(feature = "hypergraph")]
2078 mod hypergraph_planner_tests {
2079 use super::*;
2080
2081 #[test]
2083 fn plan_create_hyperedge_basic() {
2084 let plan = plan_query("CREATE HYPEREDGE (h:GroupMigration) FROM (a, b) TO (c)");
2085 match plan {
2086 LogicalPlan::CreateHyperedgeOp {
2087 source,
2088 variable,
2089 labels,
2090 sources,
2091 targets,
2092 } => {
2093 assert!(
2094 source.is_none(),
2095 "standalone CREATE HYPEREDGE has no source"
2096 );
2097 assert_eq!(variable, Some("h".to_string()));
2098 assert_eq!(labels, vec!["GroupMigration".to_string()]);
2099 assert_eq!(sources.len(), 2);
2100 assert_eq!(targets.len(), 1);
2101 }
2102 other => panic!("expected CreateHyperedgeOp, got {:?}", other),
2103 }
2104 }
2105
2106 #[test]
2108 fn plan_match_hyperedge_basic() {
2109 let plan = plan_query("MATCH HYPEREDGE (h:GroupMigration) RETURN h");
2110 match plan {
2112 LogicalPlan::Project { source, .. } => match *source {
2113 LogicalPlan::HyperEdgeScan { variable } => {
2114 assert_eq!(variable, "h");
2115 }
2116 other => panic!("expected HyperEdgeScan, got {:?}", other),
2117 },
2118 other => panic!("expected Project, got {:?}", other),
2119 }
2120 }
2121 }
2122}