1use super::ast::{
8 ret_val_label, AggArg, AggFunc, Expr, LimitSkip, NodePat, Operand, OptionalClause, OrderItem,
9 OrderTarget, Pattern, Query, RelDir, RelPat, RetItem, RetVal, UnwindExpr, WithStage,
10};
11use crate::filter::CmpOp;
12use std::collections::BTreeSet;
13
14#[derive(Debug, Clone, PartialEq)]
18pub enum PlanOp {
19 ScanLabel {
21 var: String,
22 label: Option<String>,
23 },
24 ScanKey {
27 var: String,
28 key: Operand,
29 label: Option<String>,
30 },
31 IndexScan {
37 var: String,
38 label: Option<String>,
39 field: String,
40 value: Operand,
41 },
42 IndexIntersect {
53 var: String,
54 label: Option<String>,
55 equalities: Vec<(String, Operand)>,
56 },
57 LookupProps {
59 var: String,
60 props: Vec<(String, Operand)>,
61 },
62 Expand {
69 from: String,
70 rel_var: Option<String>,
71 etypes: Vec<String>,
72 dir: RelDir,
73 to: String,
74 to_label: Option<String>,
75 to_props: Vec<(String, Operand)>,
76 },
77 JoinBound {
79 var: String,
80 label: Option<String>,
81 props: Vec<(String, Operand)>,
82 },
83 Filter {
84 expr: Expr,
85 },
86 Project {
87 items: Vec<RetItem>,
88 },
89 Distinct,
93 OrderBy {
97 items: Vec<OrderItem>,
98 },
99 Skip(LimitSkip),
100 Limit(LimitSkip),
101 Aggregate {
109 func: AggFunc,
110 arg: AggArg,
111 column: String,
114 },
115 GroupAggregate {
129 keys: Vec<(String, RetItem)>,
131 aggs: Vec<(AggFunc, AggArg, String)>,
133 },
134 VarExpand {
145 from: String,
146 rel_var: Option<String>,
147 etypes: Vec<String>,
148 dir: RelDir,
149 to: String,
150 min: u8,
151 max: u8,
152 },
153 ShortestPath {
165 from: String,
166 rel_var: Option<String>,
167 etypes: Vec<String>,
168 dir: RelDir,
169 to: String,
170 max_hops: u8,
171 },
172 With {
179 items: Vec<RetItem>,
180 where_expr: Option<Expr>,
181 order_by: Vec<OrderItem>,
182 skip: Option<LimitSkip>,
183 limit: Option<LimitSkip>,
184 },
185 Unwind {
196 expr: UnwindExpr,
197 alias: String,
198 },
199 LeftOuterApply {
215 inner: Vec<PlanOp>,
216 optional_vars: Vec<String>,
217 },
218}
219
220pub fn row_bound(ops: &[PlanOp]) -> Option<usize> {
248 if ops
250 .iter()
251 .any(|op| matches!(op, PlanOp::OrderBy { .. } | PlanOp::Distinct))
252 {
253 return None;
254 }
255 if ops.iter().any(|op| matches!(op, PlanOp::Aggregate { .. })) {
257 return None;
258 }
259 if ops
262 .iter()
263 .any(|op| matches!(op, PlanOp::GroupAggregate { .. }))
264 {
265 return None;
266 }
267 if ops
270 .iter()
271 .any(|op| matches!(op, PlanOp::VarExpand { .. } | PlanOp::ShortestPath { .. }))
272 {
273 return None;
274 }
275 if ops.iter().any(|op| {
278 matches!(
279 op,
280 PlanOp::With { .. } | PlanOp::Unwind { .. } | PlanOp::LeftOuterApply { .. }
281 )
282 }) {
283 return None;
284 }
285 let limit_n = ops.iter().rev().find_map(|op| match op {
286 PlanOp::Limit(LimitSkip::Exact(n)) => Some(*n),
287 PlanOp::Limit(LimitSkip::Param(_)) => None, _ => None,
289 })?;
290 if ops
292 .iter()
293 .any(|op| matches!(op, PlanOp::Skip(LimitSkip::Param(_))))
294 {
295 return None;
296 }
297 let skip_n = ops
298 .iter()
299 .rev()
300 .find_map(|op| match op {
301 PlanOp::Skip(LimitSkip::Exact(n)) => Some(*n),
302 _ => None,
303 })
304 .unwrap_or(0);
305 Some((skip_n as usize).saturating_add(limit_n as usize))
306}
307
308pub fn is_subscribable(ops: &[PlanOp]) -> bool {
323 ops.iter().all(|op| {
327 matches!(
328 op,
329 PlanOp::ScanLabel { .. }
330 | PlanOp::ScanKey { .. }
331 | PlanOp::IndexScan { .. }
332 | PlanOp::IndexIntersect { .. }
333 | PlanOp::LookupProps { .. }
334 | PlanOp::Expand { .. }
335 | PlanOp::Filter { .. }
336 | PlanOp::Project { .. }
337 | PlanOp::Limit(_)
338 )
339 })
340 && ops.iter().any(|op| {
342 matches!(
343 op,
344 PlanOp::ScanLabel { .. }
345 | PlanOp::ScanKey { .. }
346 | PlanOp::IndexScan { .. }
347 | PlanOp::IndexIntersect { .. }
348 )
349 })
350 && ops.iter().any(|op| matches!(op, PlanOp::Project { .. }))
352 && ops
354 .iter()
355 .filter(|op| matches!(op, PlanOp::Expand { .. }))
356 .count()
357 <= 1
358}
359
360pub fn plan(q: &Query) -> Result<Vec<PlanOp>, String> {
362 let mut bound = BTreeSet::new();
363 let mut rel_bound = BTreeSet::new();
364 let mut ops = Vec::new();
365 let mut node_anon = 0u32;
366 let mut rel_anon = 0u32;
367
368 for pat in &q.matches {
369 compile_pattern(
370 pat,
371 &mut ops,
372 &mut bound,
373 &mut rel_bound,
374 &mut node_anon,
375 &mut rel_anon,
376 )?;
377 }
378
379 for oc in &q.optional_clauses {
381 compile_optional_clause(
382 oc,
383 &mut ops,
384 &mut bound,
385 &mut rel_bound,
386 &mut node_anon,
387 &mut rel_anon,
388 )?;
389 }
390
391 for uw in &q.unwinds {
393 check_unwind_bound(&uw.list, &bound)?;
394 bound.insert(uw.alias.clone());
395 ops.push(PlanOp::Unwind {
396 expr: uw.list.clone(),
397 alias: uw.alias.clone(),
398 });
399 }
400
401 if let Some(expr) = &q.where_expr {
402 check_expr_bound(expr, &bound)?;
403 ops.push(PlanOp::Filter { expr: expr.clone() });
404 }
405 ops = fold_where_equalities(ops);
408
409 if let Some(expr) = &q.post_unwind_where {
411 check_expr_bound(expr, &bound)?;
412 ops.push(PlanOp::Filter { expr: expr.clone() });
413 }
414
415 for stage in &q.stages {
417 compile_with_stage(
418 stage,
419 &mut ops,
420 &mut bound,
421 &mut rel_bound,
422 &mut node_anon,
423 &mut rel_anon,
424 )?;
425 }
426
427 check_return_bound(&q.returns, &bound, &rel_bound)?;
428 check_duplicate_aliases(&q.returns)?;
429 check_duplicate_columns(&q.returns)?;
430 if q.distinct
431 && q.returns
432 .iter()
433 .any(|r| matches!(&r.value, RetVal::Agg { .. }))
434 {
435 return Err(
436 "RETURN DISTINCT is not supported with aggregate functions; use grouping".to_string(),
437 );
438 }
439
440 let is_pipeline = !q.stages.is_empty()
444 || !q.unwinds.is_empty()
445 || q.post_unwind_where.is_some()
446 || !q.optional_clauses.is_empty();
447 let agg_count = q
448 .returns
449 .iter()
450 .filter(|r| matches!(&r.value, RetVal::Agg { .. }))
451 .count();
452
453 if agg_count == 1 && q.returns.len() == 1 && !is_pipeline {
454 let item = &q.returns[0];
456 let (func, arg) = match &item.value {
457 RetVal::Agg { func, arg } => (func.clone(), arg.clone()),
458 _ => unreachable!(),
459 };
460 if let (AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max, AggArg::Star) =
462 (&func, &arg)
463 {
464 return Err(format!(
465 "{name} does not accept '*'; use a property expression like `{name}(n.prop)`",
466 name = func_name(&func),
467 ));
468 }
469 let column = item
470 .alias
471 .clone()
472 .unwrap_or_else(|| agg_column_name(&func, &arg));
473 ops.push(PlanOp::Aggregate { func, arg, column });
474 return Ok(ops);
477 }
478
479 if agg_count > 0 {
480 let mut keys: Vec<(String, RetItem)> = Vec::new();
483 let mut aggs: Vec<(AggFunc, AggArg, String)> = Vec::new();
484 for item in &q.returns {
485 match &item.value {
486 RetVal::Agg { func, arg } => {
487 if let (
489 AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max,
490 AggArg::Star,
491 ) = (func, arg)
492 {
493 return Err(format!(
494 "{name} does not accept '*'; use a property expression like `{name}(n.prop)`",
495 name = func_name(func),
496 ));
497 }
498 let column = item
499 .alias
500 .clone()
501 .unwrap_or_else(|| agg_column_name(func, arg));
502 aggs.push((func.clone(), arg.clone(), column));
503 }
504 _ => {
505 keys.push((column_name(item), item.clone()));
506 }
507 }
508 }
509 ops.push(PlanOp::GroupAggregate { keys, aggs });
510 if !q.order_by.is_empty() {
512 let mut items = Vec::with_capacity(q.order_by.len());
513 for item in &q.order_by {
514 items.push(rewrite_order_item(item, &q.returns, &bound, &rel_bound)?);
515 }
516 ops.push(PlanOp::OrderBy { items });
517 }
518 if let Some(ls) = &q.skip {
519 ops.push(PlanOp::Skip(ls.clone()));
520 }
521 if let Some(ls) = &q.limit {
522 ops.push(PlanOp::Limit(ls.clone()));
523 }
524 return Ok(ops);
525 }
526
527 ops.push(PlanOp::Project {
528 items: q.returns.clone(),
529 });
530 if q.distinct {
531 ops.push(PlanOp::Distinct);
532 }
533
534 if !q.order_by.is_empty() {
535 let mut items = Vec::with_capacity(q.order_by.len());
536 for item in &q.order_by {
537 items.push(rewrite_order_item(item, &q.returns, &bound, &rel_bound)?);
538 }
539 ops.push(PlanOp::OrderBy { items });
540 }
541
542 if let Some(ls) = &q.skip {
543 ops.push(PlanOp::Skip(ls.clone()));
544 }
545 if let Some(ls) = &q.limit {
546 ops.push(PlanOp::Limit(ls.clone()));
547 }
548
549 Ok(ops)
550}
551
552fn compile_with_stage(
554 stage: &WithStage,
555 ops: &mut Vec<PlanOp>,
556 bound: &mut BTreeSet<String>,
557 rel_bound: &mut BTreeSet<String>,
558 node_anon: &mut u32,
559 rel_anon: &mut u32,
560) -> Result<(), String> {
561 let agg_count = stage
562 .items
563 .iter()
564 .filter(|r| matches!(&r.value, RetVal::Agg { .. }))
565 .count();
566
567 if agg_count > 0 {
568 let mut keys: Vec<(String, RetItem)> = Vec::new();
570 let mut aggs: Vec<(AggFunc, AggArg, String)> = Vec::new();
571 for item in &stage.items {
572 match &item.value {
573 RetVal::Agg { func, arg } => {
574 if let (
575 AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max,
576 AggArg::Star,
577 ) = (func, arg)
578 {
579 return Err(format!(
580 "{name} does not accept '*'; use a property expression like `{name}(n.prop)`",
581 name = func_name(func),
582 ));
583 }
584 let col = item
585 .alias
586 .clone()
587 .unwrap_or_else(|| agg_column_name(func, arg));
588 aggs.push((func.clone(), arg.clone(), col));
589 }
590 _ => {
591 keys.push((column_name(item), item.clone()));
592 }
593 }
594 }
595 ops.push(PlanOp::GroupAggregate {
596 keys: keys.clone(),
597 aggs: aggs.clone(),
598 });
599
600 bound.clear();
602 rel_bound.clear();
603 for (col, _) in &keys {
604 bound.insert(col.clone());
605 }
606 for (_, _, col) in &aggs {
607 bound.insert(col.clone());
608 }
609
610 if let Some(expr) = &stage.where_expr {
612 check_expr_bound(expr, bound)?;
613 ops.push(PlanOp::Filter { expr: expr.clone() });
614 }
615 if !stage.order_by.is_empty() {
618 for item in &stage.order_by {
619 match &item.target {
620 OrderTarget::Prop { var, .. } | OrderTarget::Var(var) => {
621 require_bound(var, bound, "ORDER BY in aggregate WITH")?;
622 }
623 OrderTarget::Alias(name) => {
624 require_bound(name, bound, "ORDER BY in aggregate WITH")?;
625 }
626 }
627 }
628 ops.push(PlanOp::OrderBy {
629 items: stage.order_by.clone(),
630 });
631 }
632 if let Some(ls) = &stage.skip {
633 ops.push(PlanOp::Skip(ls.clone()));
634 }
635 if let Some(ls) = &stage.limit {
636 ops.push(PlanOp::Limit(ls.clone()));
637 }
638 } else {
639 check_return_bound(&stage.items, bound, rel_bound)?;
641
642 let with_col_names: BTreeSet<String> = stage.items.iter().map(column_name).collect();
653 let with_scope: BTreeSet<String> = bound.union(&with_col_names).cloned().collect();
654 if let Some(expr) = &stage.where_expr {
655 check_expr_bound(expr, &with_scope)?;
656 }
657 for item in &stage.order_by {
662 match &item.target {
663 OrderTarget::Prop { var, .. } | OrderTarget::Var(var) => {
664 if !bound.contains(var.as_str()) && !with_col_names.contains(var.as_str()) {
665 return Err(format!("unbound variable `{var}` in ORDER BY in WITH"));
666 }
667 }
668 OrderTarget::Alias(name) => {
669 if !bound.contains(name.as_str()) && !with_col_names.contains(name.as_str()) {
670 return Err(format!("unbound variable `{name}` in ORDER BY in WITH"));
671 }
672 }
673 }
674 }
675 ops.push(PlanOp::With {
676 items: stage.items.clone(),
677 where_expr: stage.where_expr.clone(),
678 order_by: stage.order_by.clone(),
679 skip: stage.skip.clone(),
680 limit: stage.limit.clone(),
681 });
682
683 let mut new_bound: BTreeSet<String> = BTreeSet::new();
685 let mut new_rel_bound: BTreeSet<String> = BTreeSet::new();
686 for item in &stage.items {
687 let col = column_name(item);
688 new_bound.insert(col.clone());
689 match &item.value {
691 RetVal::Var(v) if rel_bound.contains(v.as_str()) => {
692 new_rel_bound.insert(col);
693 }
694 _ => {}
695 }
696 }
697 *bound = new_bound;
698 *rel_bound = new_rel_bound;
699 }
700
701 for pat in &stage.matches {
703 compile_pattern(pat, ops, bound, rel_bound, node_anon, rel_anon)?;
704 }
705 for oc in &stage.optional_clauses {
707 compile_optional_clause(oc, ops, bound, rel_bound, node_anon, rel_anon)?;
708 }
709 for uw in &stage.unwinds {
711 check_unwind_bound(&uw.list, bound)?;
712 bound.insert(uw.alias.clone());
713 ops.push(PlanOp::Unwind {
714 expr: uw.list.clone(),
715 alias: uw.alias.clone(),
716 });
717 }
718 if let Some(expr) = &stage.post_where {
720 check_expr_bound(expr, bound)?;
721 ops.push(PlanOp::Filter { expr: expr.clone() });
722 }
723
724 Ok(())
725}
726
727fn id_lookup(props: &[(String, Operand)]) -> Option<&Operand> {
728 if props.len() == 1 && props[0].0 == "id" {
729 Some(&props[0].1)
730 } else {
731 None
732 }
733}
734
735fn index_lookup(props: &[(String, Operand)]) -> Option<(&str, &Operand)> {
738 if props.len() == 1
739 && props[0].0 != "id"
740 && matches!(props[0].1, Operand::Lit(_) | Operand::Param(_))
741 {
742 Some((props[0].0.as_str(), &props[0].1))
743 } else {
744 None
745 }
746}
747
748fn multi_index_lookup(props: &[(String, Operand)]) -> Option<Vec<(String, Operand)>> {
752 if props.len() < 2 {
753 return None;
754 }
755 if props
756 .iter()
757 .any(|(f, v)| f == "id" || !matches!(v, Operand::Lit(_) | Operand::Param(_)))
758 {
759 return None;
760 }
761 Some(props.to_vec())
762}
763
764pub(super) fn split_and(expr: Expr) -> Vec<Expr> {
767 match expr {
768 Expr::And(l, r) => {
769 let mut v = split_and(*l);
770 v.extend(split_and(*r));
771 v
772 }
773 other => vec![other],
774 }
775}
776
777pub(super) fn join_and(mut exprs: Vec<Expr>) -> Option<Expr> {
780 if exprs.is_empty() {
781 return None;
782 }
783 let mut result = exprs.remove(0);
784 for e in exprs {
785 result = Expr::And(Box::new(result), Box::new(e));
786 }
787 Some(result)
788}
789
790pub(super) fn fold_where_equalities(mut ops: Vec<PlanOp>) -> Vec<PlanOp> {
813 let Some(scan_pos) = ops
817 .iter()
818 .position(|op| matches!(op, PlanOp::ScanLabel { .. } | PlanOp::IndexScan { .. }))
819 else {
820 return ops;
821 };
822
823 let (scan_var, scan_label, existing_eq) = match &ops[scan_pos] {
826 PlanOp::ScanLabel { var, label } => (var.clone(), label.clone(), None),
827 PlanOp::IndexScan {
828 var,
829 label,
830 field,
831 value,
832 } => (
833 var.clone(),
834 label.clone(),
835 Some((field.clone(), value.clone())),
836 ),
837 _ => unreachable!(),
838 };
839
840 let Some(rel_pos) = ops[scan_pos + 1..]
842 .iter()
843 .position(|op| matches!(op, PlanOp::Filter { .. }))
844 else {
845 return ops;
846 };
847 let filter_pos = scan_pos + 1 + rel_pos;
848
849 if ops[scan_pos + 1..filter_pos]
851 .iter()
852 .any(|op| matches!(op, PlanOp::Expand { .. }))
853 {
854 return ops;
855 }
856
857 let filter_expr = match &ops[filter_pos] {
858 PlanOp::Filter { expr } => expr.clone(),
859 _ => unreachable!(),
860 };
861
862 let mut terms = split_and(filter_expr);
864 let mut extracted: Vec<(String, Operand)> = Vec::new();
865 let mut i = 0;
866 while i < terms.len() {
867 if matches!(
868 &terms[i],
869 Expr::Cmp {
870 lhs: Operand::Prop { var, .. },
871 op: CmpOp::Eq,
872 rhs: Operand::Lit(_) | Operand::Param(_),
873 } if var == &scan_var
874 ) {
875 let term = terms.remove(i);
876 match term {
877 Expr::Cmp {
878 lhs: Operand::Prop { field, .. },
879 rhs,
880 ..
881 } => extracted.push((field, rhs)),
882 _ => unreachable!(),
883 }
884 } else {
885 i += 1;
886 }
887 }
888
889 if extracted.is_empty() {
890 return ops;
891 }
892
893 let mut all_equalities: Vec<(String, Operand)> = Vec::new();
895 if let Some(eq) = existing_eq {
896 all_equalities.push(eq);
897 }
898 all_equalities.extend(extracted);
899
900 ops[scan_pos] = if all_equalities.len() == 1 {
902 let (field, value) = all_equalities.remove(0);
903 PlanOp::IndexScan {
904 var: scan_var,
905 label: scan_label,
906 field,
907 value,
908 }
909 } else {
910 PlanOp::IndexIntersect {
911 var: scan_var,
912 label: scan_label,
913 equalities: all_equalities,
914 }
915 };
916
917 match join_and(terms) {
919 Some(residual) => ops[filter_pos] = PlanOp::Filter { expr: residual },
920 None => {
921 ops.remove(filter_pos);
922 }
923 }
924
925 ops
926}
927
928fn invert_dir(d: RelDir) -> RelDir {
929 match d {
930 RelDir::Right => RelDir::Left,
931 RelDir::Left => RelDir::Right,
932 RelDir::Undirected => RelDir::Undirected,
933 }
934}
935
936fn compile_pattern(
937 pat: &Pattern,
938 ops: &mut Vec<PlanOp>,
939 bound: &mut BTreeSet<String>,
940 rel_bound: &mut BTreeSet<String>,
941 node_anon: &mut u32,
942 rel_anon: &mut u32,
943) -> Result<(), String> {
944 let start = name_node(&pat.start, node_anon, bound);
945 if pat.shortest {
946 if !bound.contains(&start) {
948 return Err(format!(
949 "shortestPath: source node `{start}` is not bound; \
950 bind both endpoints before shortestPath"
951 ));
952 }
953 ops.push(PlanOp::JoinBound {
954 var: start.clone(),
955 label: pat.start.label.clone(),
956 props: pat.start.props.clone(),
957 });
958 } else if bound.contains(&start) {
959 ops.push(PlanOp::JoinBound {
960 var: start.clone(),
961 label: pat.start.label.clone(),
962 props: pat.start.props.clone(),
963 });
964 } else if pat.chain.len() == 1
965 && pat.chain[0].0.hops.is_none()
966 && pat.chain[0]
967 .1
968 .var
969 .as_ref()
970 .is_some_and(|v| bound.contains(v))
971 {
972 let (rel, dest) = &pat.chain[0];
977 let dest_name = name_node(dest, node_anon, bound);
978 let rel_name = name_rel(rel, rel_anon, bound);
979 bound.insert(rel_name.clone());
980 rel_bound.insert(rel_name.clone());
981 if dest.label.is_some() || !dest.props.is_empty() {
982 ops.push(PlanOp::JoinBound {
983 var: dest_name.clone(),
984 label: dest.label.clone(),
985 props: dest.props.clone(),
986 });
987 }
988 ops.push(PlanOp::Expand {
989 from: dest_name,
990 rel_var: Some(rel_name),
991 etypes: rel.etypes.clone(),
992 dir: invert_dir(rel.dir),
993 to: start.clone(),
994 to_label: pat.start.label.clone(),
995 to_props: pat.start.props.clone(),
996 });
997 bound.insert(start);
998 return Ok(());
999 } else if let Some(key) = id_lookup(&pat.start.props) {
1000 ops.push(PlanOp::ScanKey {
1001 var: start.clone(),
1002 key: key.clone(),
1003 label: pat.start.label.clone(),
1004 });
1005 bound.insert(start.clone());
1006 } else if let Some((field, value)) = index_lookup(&pat.start.props) {
1007 ops.push(PlanOp::IndexScan {
1008 var: start.clone(),
1009 label: pat.start.label.clone(),
1010 field: field.to_string(),
1011 value: value.clone(),
1012 });
1013 bound.insert(start.clone());
1014 } else if let Some(equalities) = multi_index_lookup(&pat.start.props) {
1015 ops.push(PlanOp::IndexIntersect {
1016 var: start.clone(),
1017 label: pat.start.label.clone(),
1018 equalities,
1019 });
1020 bound.insert(start.clone());
1021 } else {
1022 ops.push(PlanOp::ScanLabel {
1023 var: start.clone(),
1024 label: pat.start.label.clone(),
1025 });
1026 if !pat.start.props.is_empty() {
1027 ops.push(PlanOp::LookupProps {
1028 var: start.clone(),
1029 props: pat.start.props.clone(),
1030 });
1031 }
1032 bound.insert(start.clone());
1033 }
1034
1035 let mut from = start;
1036 for (rel, dest) in &pat.chain {
1037 let rel_name = name_rel(rel, rel_anon, bound);
1038 bound.insert(rel_name.clone());
1039 rel_bound.insert(rel_name.clone());
1040 let to = name_node(dest, node_anon, bound);
1041
1042 if let Some(hops) = rel.hops {
1043 if pat.shortest {
1044 if !bound.contains(&to) {
1046 return Err(format!(
1047 "shortestPath: destination node `{to}` is not bound; \
1048 bind both endpoints before shortestPath"
1049 ));
1050 }
1051 if hops.min > 1 {
1055 return Err(format!(
1056 "shortestPath does not support a minimum hop count \
1057 (got min={}); use a plain variable-length pattern \
1058 if you need a minimum",
1059 hops.min
1060 ));
1061 }
1062 ops.push(PlanOp::ShortestPath {
1063 from: from.clone(),
1064 rel_var: Some(rel_name),
1065 etypes: rel.etypes.clone(),
1066 dir: rel.dir,
1067 to: to.clone(),
1068 max_hops: hops.max,
1069 });
1070 } else {
1071 ops.push(PlanOp::VarExpand {
1072 from: from.clone(),
1073 rel_var: Some(rel_name),
1074 etypes: rel.etypes.clone(),
1075 dir: rel.dir,
1076 to: to.clone(),
1077 min: hops.min,
1078 max: hops.max,
1079 });
1080 bound.insert(to.clone());
1081 }
1082 } else {
1083 ops.push(PlanOp::Expand {
1084 from: from.clone(),
1085 rel_var: Some(rel_name),
1086 etypes: rel.etypes.clone(),
1087 dir: rel.dir,
1088 to: to.clone(),
1089 to_label: dest.label.clone(),
1090 to_props: dest.props.clone(),
1091 });
1092 bound.insert(to.clone());
1093 }
1094 from = to;
1095 }
1096 Ok(())
1097}
1098
1099fn compile_optional_clause(
1106 oc: &OptionalClause,
1107 ops: &mut Vec<PlanOp>,
1108 bound: &mut BTreeSet<String>,
1109 rel_bound: &mut BTreeSet<String>,
1110 node_anon: &mut u32,
1111 rel_anon: &mut u32,
1112) -> Result<(), String> {
1113 let mut inner_bound = bound.clone();
1115 let mut inner_rel_bound = rel_bound.clone();
1116 let mut inner_ops: Vec<PlanOp> = Vec::new();
1117
1118 for pat in &oc.patterns {
1119 compile_pattern(
1120 pat,
1121 &mut inner_ops,
1122 &mut inner_bound,
1123 &mut inner_rel_bound,
1124 node_anon,
1125 rel_anon,
1126 )?;
1127 }
1128 if let Some(expr) = &oc.where_expr {
1129 check_expr_bound(expr, &inner_bound)?;
1130 inner_ops.push(PlanOp::Filter { expr: expr.clone() });
1131 }
1132
1133 let optional_vars: Vec<String> = inner_bound
1135 .difference(bound)
1136 .chain(inner_rel_bound.difference(rel_bound))
1137 .cloned()
1138 .collect();
1139
1140 for v in &optional_vars {
1143 bound.insert(v.clone());
1144 }
1145 for v in inner_rel_bound
1146 .difference(&*rel_bound)
1147 .cloned()
1148 .collect::<Vec<_>>()
1149 {
1150 rel_bound.insert(v);
1151 }
1152
1153 ops.push(PlanOp::LeftOuterApply {
1154 inner: inner_ops,
1155 optional_vars,
1156 });
1157 Ok(())
1158}
1159
1160fn name_node(node: &NodePat, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1161 match &node.var {
1162 Some(v) => v.clone(),
1163 None => fresh("_n", counter, bound),
1164 }
1165}
1166
1167fn name_rel(rel: &RelPat, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1168 match &rel.var {
1169 Some(v) => v.clone(),
1170 None => fresh("_r", counter, bound),
1171 }
1172}
1173
1174fn fresh(prefix: &str, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1177 for _ in 0..=u32::MAX {
1178 let name = format!("{prefix}{counter}");
1179 *counter = counter.wrapping_add(1);
1180 if !bound.contains(&name) {
1181 return name;
1182 }
1183 }
1184 format!("{prefix}x")
1185}
1186
1187fn check_expr_bound(expr: &Expr, bound: &BTreeSet<String>) -> Result<(), String> {
1188 match expr {
1189 Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => {
1190 check_expr_bound(lhs, bound)?;
1191 check_expr_bound(rhs, bound)
1192 }
1193 Expr::Not(inner) => check_expr_bound(inner, bound),
1194 Expr::Cmp { lhs, rhs, .. } => {
1195 check_operand_bound(lhs, bound, "WHERE")?;
1196 check_operand_bound(rhs, bound, "WHERE")
1197 }
1198 Expr::Truthy(op) => check_operand_bound(op, bound, "WHERE"),
1199 Expr::IsNull(op) | Expr::IsNotNull(op) => check_operand_bound(op, bound, "WHERE"),
1200 Expr::In { expr, list } => {
1201 check_operand_bound(expr, bound, "WHERE")?;
1202 for item in list {
1203 check_operand_bound(item, bound, "WHERE")?;
1204 }
1205 Ok(())
1206 }
1207 }
1208}
1209
1210fn check_operand_bound(
1211 operand: &Operand,
1212 bound: &BTreeSet<String>,
1213 clause: &str,
1214) -> Result<(), String> {
1215 match operand {
1216 Operand::Prop { var, .. } => require_bound(var, bound, clause),
1217 Operand::Lit(_) | Operand::Param(_) => Ok(()),
1218 Operand::Var(name) => require_bound(name, bound, clause),
1219 Operand::BinArith { left, right, .. } => {
1220 check_operand_bound(left, bound, clause)?;
1221 check_operand_bound(right, bound, clause)
1222 }
1223 Operand::Index { base, index } => {
1224 check_operand_bound(base, bound, clause)?;
1225 check_operand_bound(index, bound, clause)
1226 }
1227 Operand::FuncCall { args, .. } => {
1228 for arg in args {
1229 check_operand_bound(arg, bound, clause)?;
1230 }
1231 Ok(())
1232 }
1233 Operand::Case { branches, default } => {
1234 for (cond, value) in branches {
1235 check_expr_bound(cond, bound)?;
1236 check_operand_bound(value, bound, clause)?;
1237 }
1238 if let Some(d) = default {
1239 check_operand_bound(d, bound, clause)?;
1240 }
1241 Ok(())
1242 }
1243 }
1244}
1245
1246fn check_unwind_bound(expr: &UnwindExpr, bound: &BTreeSet<String>) -> Result<(), String> {
1248 match expr {
1249 UnwindExpr::Lit(_) => Ok(()),
1250 UnwindExpr::Prop { var, .. } => require_bound(var, bound, "UNWIND"),
1251 UnwindExpr::Var(name) => require_bound(name, bound, "UNWIND"),
1252 }
1253}
1254
1255fn require_bound(var: &str, bound: &BTreeSet<String>, clause: &str) -> Result<(), String> {
1256 if bound.contains(var) {
1257 Ok(())
1258 } else {
1259 Err(format!("unbound variable `{var}` in {clause}"))
1260 }
1261}
1262
1263fn reject_bare_rel(var: &str, rel_bound: &BTreeSet<String>) -> Result<(), String> {
1264 if rel_bound.contains(var) {
1265 Err(format!(
1266 "cannot return relationship variable '{var}' bare; return its properties ({var}.field) instead"
1267 ))
1268 } else {
1269 Ok(())
1270 }
1271}
1272
1273fn check_return_bound(
1274 items: &[RetItem],
1275 bound: &BTreeSet<String>,
1276 rel_bound: &BTreeSet<String>,
1277) -> Result<(), String> {
1278 for item in items {
1279 match &item.value {
1280 RetVal::Var(v) => {
1281 require_bound(v, bound, "RETURN")?;
1282 reject_bare_rel(v, rel_bound)?;
1283 }
1284 RetVal::Prop { var, .. } => {
1285 require_bound(var, bound, "RETURN")?;
1286 }
1287 RetVal::Agg { arg, .. } => check_agg_arg_bound(arg, bound)?,
1288 RetVal::FuncCall { args, .. } => {
1289 for arg in args {
1290 check_operand_bound(arg, bound, "RETURN")?;
1291 }
1292 }
1293 RetVal::ScalarExpr(op) => {
1294 check_operand_bound(op, bound, "RETURN")?;
1295 }
1296 }
1297 }
1298 Ok(())
1299}
1300
1301fn check_duplicate_aliases(items: &[RetItem]) -> Result<(), String> {
1302 let mut seen = BTreeSet::new();
1303 for item in items {
1304 if let Some(alias) = &item.alias {
1305 if !seen.insert(alias.clone()) {
1306 return Err(format!("duplicate RETURN alias `{alias}`"));
1307 }
1308 }
1309 }
1310 Ok(())
1311}
1312
1313fn check_duplicate_columns(items: &[RetItem]) -> Result<(), String> {
1314 let mut seen = BTreeSet::new();
1315 for item in items {
1316 let col = column_name(item);
1317 if !seen.insert(col.clone()) {
1318 return Err(format!("duplicate RETURN column `{col}`"));
1319 }
1320 }
1321 Ok(())
1322}
1323
1324fn column_name(item: &RetItem) -> String {
1327 if let Some(alias) = &item.alias {
1328 return alias.clone();
1329 }
1330 ret_val_label(&item.value).unwrap_or_else(|| match &item.value {
1331 RetVal::Agg { func, arg } => agg_column_name(func, arg),
1332 _ => unreachable!("ret_val_label names every non-aggregate item"),
1333 })
1334}
1335
1336fn check_agg_arg_bound(arg: &AggArg, bound: &BTreeSet<String>) -> Result<(), String> {
1339 match arg {
1340 AggArg::Star => Ok(()),
1341 AggArg::Var(v) => require_bound(v, bound, "RETURN"),
1342 AggArg::Prop { var, .. } => require_bound(var, bound, "RETURN"),
1343 AggArg::Distinct(inner) => check_agg_arg_bound(inner, bound),
1344 }
1345}
1346
1347fn agg_column_name(func: &AggFunc, arg: &AggArg) -> String {
1350 let f = func_name(func);
1351 format!("{f}({})", agg_arg_name(arg))
1352}
1353
1354fn agg_arg_name(arg: &AggArg) -> String {
1355 match arg {
1356 AggArg::Star => "*".to_string(),
1357 AggArg::Var(v) => v.clone(),
1358 AggArg::Prop { var, field } => format!("{var}.{field}"),
1359 AggArg::Distinct(inner) => format!("DISTINCT {}", agg_arg_name(inner)),
1360 }
1361}
1362
1363fn func_name(func: &AggFunc) -> &'static str {
1364 match func {
1365 AggFunc::Count => "COUNT",
1366 AggFunc::Sum => "SUM",
1367 AggFunc::Avg => "AVG",
1368 AggFunc::Min => "MIN",
1369 AggFunc::Max => "MAX",
1370 AggFunc::Collect => "COLLECT",
1371 }
1372}
1373
1374fn rewrite_order_item(
1375 item: &OrderItem,
1376 returns: &[RetItem],
1377 bound: &BTreeSet<String>,
1378 rel_bound: &BTreeSet<String>,
1379) -> Result<OrderItem, String> {
1380 let column = match &item.target {
1381 OrderTarget::Alias(name) => {
1382 if returns
1383 .iter()
1384 .any(|r| r.alias.as_deref() == Some(name.as_str()))
1385 {
1386 name.clone()
1387 } else {
1388 return Err(format!("ORDER BY target `{name}` is not present in RETURN"));
1389 }
1390 }
1391 OrderTarget::Var(v) => {
1392 require_bound(v, bound, "ORDER BY")?;
1393 reject_bare_rel(v, rel_bound)?;
1394 match returns
1395 .iter()
1396 .find(|r| matches!(&r.value, RetVal::Var(x) if x == v))
1397 {
1398 Some(r) => column_name(r),
1399 None => {
1400 return Err(format!("ORDER BY target `{v}` is not present in RETURN"));
1401 }
1402 }
1403 }
1404 OrderTarget::Prop { var, field } => {
1405 require_bound(var, bound, "ORDER BY")?;
1406 match returns.iter().find(
1407 |r| matches!(&r.value, RetVal::Prop { var: v, field: f } if v == var && f == field),
1408 ) {
1409 Some(r) => column_name(r),
1410 None => {
1411 return Err(format!(
1412 "ORDER BY target `{var}.{field}` is not present in RETURN"
1413 ));
1414 }
1415 }
1416 }
1417 };
1418 Ok(OrderItem {
1419 target: OrderTarget::Alias(column),
1420 descending: item.descending,
1421 })
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426 use super::{plan, PlanOp};
1427 use crate::cypher::ast::{Expr, LimitSkip, Operand, OrderItem, OrderTarget, RetItem, RetVal};
1428 use crate::cypher::{lex, parse, RelDir};
1429 use crate::filter::CmpOp;
1430 use core_storage::Value;
1431
1432 fn plan_src(src: &str) -> Result<Vec<PlanOp>, String> {
1433 plan(&parse(&lex(src)?)?)
1434 }
1435
1436 fn assert_plan_err(src: &str, needle: &str) -> String {
1437 let result = std::panic::catch_unwind(|| plan_src(src));
1438 assert!(result.is_ok(), "plan({src:?}) panicked");
1439 let err = result
1440 .unwrap()
1441 .expect_err(&format!("plan({src:?}) must be Err"));
1442 assert!(
1443 err.contains(needle),
1444 "error must mention {needle:?}, got: {err}"
1445 );
1446 err
1447 }
1448
1449 #[test]
1455 fn dogfood_query_exact_plan() {
1456 let src = "\
1457MATCH (t:Talent {id: $tid}) \
1458MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
1459MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
1460WHERE i.score >= 0.5 AND s.score >= 0.5 \
1461RETURN c, i.score AS industry, s.score AS specialty \
1462ORDER BY industry DESC, specialty DESC \
1463LIMIT 10";
1464 let got = plan_src(src).expect("dogfood query must plan");
1465 let expected = vec![
1466 PlanOp::ScanKey {
1467 var: "t".into(),
1468 key: Operand::Param("tid".into()),
1469 label: Some("Talent".into()),
1470 },
1471 PlanOp::Expand {
1472 from: "t".into(),
1473 rel_var: Some("i".into()),
1474 etypes: vec!["INDUSTRY_ALIGNMENT".into()],
1475 dir: RelDir::Left,
1476 to: "c".into(),
1477 to_label: Some("Company".into()),
1478 to_props: vec![],
1479 },
1480 PlanOp::JoinBound {
1481 var: "c".into(),
1482 label: None,
1483 props: vec![],
1484 },
1485 PlanOp::Expand {
1486 from: "c".into(),
1487 rel_var: Some("s".into()),
1488 etypes: vec!["SPECIALTY_MATCH".into()],
1489 dir: RelDir::Right,
1490 to: "t".into(),
1491 to_label: None,
1492 to_props: vec![],
1493 },
1494 PlanOp::Filter {
1495 expr: Expr::And(
1496 Box::new(Expr::Cmp {
1497 lhs: Operand::Prop {
1498 var: "i".into(),
1499 field: "score".into(),
1500 },
1501 op: CmpOp::Ge,
1502 rhs: Operand::Lit(Value::Float(0.5)),
1503 }),
1504 Box::new(Expr::Cmp {
1505 lhs: Operand::Prop {
1506 var: "s".into(),
1507 field: "score".into(),
1508 },
1509 op: CmpOp::Ge,
1510 rhs: Operand::Lit(Value::Float(0.5)),
1511 }),
1512 ),
1513 },
1514 PlanOp::Project {
1515 items: vec![
1516 RetItem {
1517 value: RetVal::Var("c".into()),
1518 alias: None,
1519 },
1520 RetItem {
1521 value: RetVal::Prop {
1522 var: "i".into(),
1523 field: "score".into(),
1524 },
1525 alias: Some("industry".into()),
1526 },
1527 RetItem {
1528 value: RetVal::Prop {
1529 var: "s".into(),
1530 field: "score".into(),
1531 },
1532 alias: Some("specialty".into()),
1533 },
1534 ],
1535 },
1536 PlanOp::OrderBy {
1537 items: vec![
1538 OrderItem {
1539 target: OrderTarget::Alias("industry".into()),
1540 descending: true,
1541 },
1542 OrderItem {
1543 target: OrderTarget::Alias("specialty".into()),
1544 descending: true,
1545 },
1546 ],
1547 },
1548 PlanOp::Limit(LimitSkip::Exact(10)),
1549 ];
1550 assert_eq!(got, expected);
1551 }
1552
1553 #[test]
1558 fn anonymous_node_and_rel_names_are_stable() {
1559 let got = plan_src("MATCH ()-[]->(a) MATCH ()-[]->(a) RETURN a").unwrap();
1560 assert_eq!(
1561 got,
1562 vec![
1563 PlanOp::ScanLabel {
1564 var: "_n0".into(),
1565 label: None,
1566 },
1567 PlanOp::Expand {
1568 from: "_n0".into(),
1569 rel_var: Some("_r0".into()),
1570 etypes: vec![],
1571 dir: RelDir::Right,
1572 to: "a".into(),
1573 to_label: None,
1574 to_props: vec![],
1575 },
1576 PlanOp::Expand {
1577 from: "a".into(),
1578 rel_var: Some("_r1".into()),
1579 etypes: vec![],
1580 dir: RelDir::Left,
1581 to: "_n1".into(),
1582 to_label: None,
1583 to_props: vec![],
1584 },
1585 PlanOp::Project {
1586 items: vec![RetItem {
1587 value: RetVal::Var("a".into()),
1588 alias: None,
1589 }],
1590 },
1591 ]
1592 );
1593 }
1594
1595 #[test]
1596 fn props_on_scan_node_emit_scan_then_lookup() {
1597 let got = plan_src("MATCH (t:Talent {id: $tid}) RETURN t").unwrap();
1598 assert_eq!(
1599 got,
1600 vec![
1601 PlanOp::ScanKey {
1602 var: "t".into(),
1603 key: Operand::Param("tid".into()),
1604 label: Some("Talent".into()),
1605 },
1606 PlanOp::Project {
1607 items: vec![RetItem {
1608 value: RetVal::Var("t".into()),
1609 alias: None,
1610 }],
1611 },
1612 ]
1613 );
1614 }
1615
1616 #[test]
1617 fn mixed_id_map_stays_scan_label_then_lookup() {
1618 let got = plan_src("MATCH (t:Talent {id: $k, name: 'x'}) RETURN t").unwrap();
1619 assert_eq!(
1620 got,
1621 vec![
1622 PlanOp::ScanLabel {
1623 var: "t".into(),
1624 label: Some("Talent".into()),
1625 },
1626 PlanOp::LookupProps {
1627 var: "t".into(),
1628 props: vec![
1629 ("id".into(), Operand::Param("k".into())),
1630 ("name".into(), Operand::Lit(Value::Str("x".into()))),
1631 ],
1632 },
1633 PlanOp::Project {
1634 items: vec![RetItem {
1635 value: RetVal::Var("t".into()),
1636 alias: None,
1637 }],
1638 },
1639 ]
1640 );
1641 }
1642
1643 #[test]
1644 fn plan_id_map_is_scan_key() {
1645 let toks = crate::cypher::lex("MATCH (n:Person {id: $k}) RETURN n").unwrap();
1646 let q = crate::cypher::parse(&toks).unwrap();
1647 let ops = plan(&q).unwrap();
1648 assert!(matches!(ops[0], PlanOp::ScanKey { .. }), "{ops:?}");
1649 }
1650
1651 #[test]
1652 fn plan_expands_from_bound_key() {
1653 let cy =
1654 "MATCH (t:Talent {id: $tid}) MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) RETURN c";
1655 let ops = plan(&crate::cypher::parse(&crate::cypher::lex(cy).unwrap()).unwrap()).unwrap();
1656 assert!(matches!(&ops[0], PlanOp::ScanKey { var, .. } if var == "t"));
1658 match &ops[1] {
1659 PlanOp::Expand { from, dir, to, .. } => {
1660 assert_eq!(from, "t");
1661 assert_eq!(to, "c");
1662 assert_eq!(*dir, RelDir::Left);
1663 }
1664 other => panic!("{other:?}"),
1665 }
1666 }
1667
1668 #[test]
1672 fn plan_does_not_reverse_variable_length_from_bound() {
1673 let cy = "MATCH (t {id: $tid}) MATCH (c:Company)-[*1..2]->(t) RETURN c";
1674 let ops = plan_src(cy).unwrap();
1675 assert!(
1676 matches!(&ops[0], PlanOp::ScanKey { var, .. } if var == "t"),
1677 "{ops:?}"
1678 );
1679 assert!(
1680 matches!(&ops[1], PlanOp::ScanLabel { var, label } if var == "c" && label.as_deref() == Some("Company")),
1681 "{ops:?}"
1682 );
1683 match &ops[2] {
1684 PlanOp::VarExpand {
1685 from,
1686 dir,
1687 to,
1688 min,
1689 max,
1690 ..
1691 } => {
1692 assert_eq!(from, "c");
1693 assert_eq!(to, "t");
1694 assert_eq!(*dir, RelDir::Right);
1695 assert_eq!(*min, 1);
1696 assert_eq!(*max, 2);
1697 }
1698 other => panic!("{other:?}"),
1699 }
1700 }
1701
1702 #[test]
1703 fn unbound_var_in_where_is_err() {
1704 let err = assert_plan_err("MATCH (a) WHERE b.x = 1 RETURN a", "b");
1705 assert!(
1706 err.to_ascii_lowercase().contains("unbound")
1707 && err.to_ascii_lowercase().contains("where"),
1708 "expected unbound-in-WHERE context, got: {err}"
1709 );
1710 }
1711
1712 #[test]
1713 fn unbound_var_in_return_is_err() {
1714 let err = assert_plan_err("MATCH (a) RETURN b", "b");
1715 assert!(
1716 err.to_ascii_lowercase().contains("unbound")
1717 && err.to_ascii_lowercase().contains("return"),
1718 "expected unbound-in-RETURN context, got: {err}"
1719 );
1720 }
1721
1722 #[test]
1723 fn unbound_var_in_order_by_is_err() {
1724 let err = assert_plan_err("MATCH (a) RETURN a ORDER BY b", "b");
1725 assert!(
1726 err.to_ascii_lowercase().contains("unbound")
1727 && (err.to_ascii_lowercase().contains("order")),
1728 "expected unbound-in-ORDER context, got: {err}"
1729 );
1730 }
1731
1732 #[test]
1733 fn duplicate_alias_is_err() {
1734 let err = assert_plan_err("MATCH (a) RETURN a AS x, a.id AS x", "x");
1735 assert!(
1736 err.to_ascii_lowercase().contains("duplicate")
1737 && err.to_ascii_lowercase().contains("alias"),
1738 "expected duplicate-alias context, got: {err}"
1739 );
1740 }
1741
1742 #[test]
1743 fn duplicate_column_name_is_err() {
1744 let err = assert_plan_err("MATCH (a) RETURN a, a", "a");
1745 assert!(
1746 err.to_ascii_lowercase().contains("duplicate")
1747 && err.to_ascii_lowercase().contains("column"),
1748 "expected duplicate-column context, got: {err}"
1749 );
1750 }
1751
1752 #[test]
1753 fn order_by_target_absent_from_return_is_err() {
1754 let err = assert_plan_err("MATCH (a) RETURN a ORDER BY a.x", "a.x");
1756 assert!(
1757 err.to_ascii_lowercase().contains("return"),
1758 "expected ORDER BY target-not-in-RETURN context, got: {err}"
1759 );
1760 }
1761
1762 #[test]
1765 fn order_by_targets_rewrite_to_projected_column_names() {
1766 let got = plan_src(
1767 "MATCH (a)-[r]->(b) \
1768 RETURN a, a.name AS nm, b.age \
1769 ORDER BY nm DESC, a ASC, b.age",
1770 )
1771 .unwrap();
1772 let order = got
1773 .iter()
1774 .find_map(|op| match op {
1775 PlanOp::OrderBy { items } => Some(items),
1776 _ => None,
1777 })
1778 .expect("plan must contain OrderBy");
1779 assert_eq!(
1780 order,
1781 &vec![
1782 OrderItem {
1783 target: OrderTarget::Alias("nm".into()),
1784 descending: true,
1785 },
1786 OrderItem {
1787 target: OrderTarget::Alias("a".into()),
1788 descending: false,
1789 },
1790 OrderItem {
1791 target: OrderTarget::Alias("b.age".into()),
1792 descending: false,
1793 },
1794 ]
1795 );
1796
1797 let aliased_var = plan_src("MATCH (a) RETURN a AS person ORDER BY a").unwrap();
1798 let order = aliased_var
1799 .iter()
1800 .find_map(|op| match op {
1801 PlanOp::OrderBy { items } => Some(items),
1802 _ => None,
1803 })
1804 .expect("plan must contain OrderBy");
1805 assert_eq!(
1806 order,
1807 &vec![OrderItem {
1808 target: OrderTarget::Alias("person".into()),
1809 descending: false,
1810 }]
1811 );
1812 }
1813
1814 #[test]
1815 fn bound_pattern_start_is_join_bound_then_expand() {
1816 let got = plan_src("MATCH (a:L) MATCH (a)-[r:T]->(b) RETURN a, b").unwrap();
1817 assert_eq!(
1818 got,
1819 vec![
1820 PlanOp::ScanLabel {
1821 var: "a".into(),
1822 label: Some("L".into()),
1823 },
1824 PlanOp::JoinBound {
1825 var: "a".into(),
1826 label: None,
1827 props: vec![],
1828 },
1829 PlanOp::Expand {
1830 from: "a".into(),
1831 rel_var: Some("r".into()),
1832 etypes: vec!["T".into()],
1833 dir: RelDir::Right,
1834 to: "b".into(),
1835 to_label: None,
1836 to_props: vec![],
1837 },
1838 PlanOp::Project {
1839 items: vec![
1840 RetItem {
1841 value: RetVal::Var("a".into()),
1842 alias: None,
1843 },
1844 RetItem {
1845 value: RetVal::Var("b".into()),
1846 alias: None,
1847 },
1848 ],
1849 },
1850 ]
1851 );
1852 }
1853
1854 #[test]
1855 fn bound_dest_extra_checks_ride_on_expand() {
1856 let got = plan_src("MATCH (t:Talent) MATCH (c)-[r]->(t:Talent {id: 1}) RETURN t").unwrap();
1857 assert_eq!(
1858 got,
1859 vec![
1860 PlanOp::ScanLabel {
1861 var: "t".into(),
1862 label: Some("Talent".into()),
1863 },
1864 PlanOp::JoinBound {
1865 var: "t".into(),
1866 label: Some("Talent".into()),
1867 props: vec![("id".into(), Operand::Lit(Value::Int(1)))],
1868 },
1869 PlanOp::Expand {
1870 from: "t".into(),
1871 rel_var: Some("r".into()),
1872 etypes: vec![],
1873 dir: RelDir::Left,
1874 to: "c".into(),
1875 to_label: None,
1876 to_props: vec![],
1877 },
1878 PlanOp::Project {
1879 items: vec![RetItem {
1880 value: RetVal::Var("t".into()),
1881 alias: None,
1882 }],
1883 },
1884 ]
1885 );
1886 }
1887
1888 #[test]
1889 fn return_distinct_emits_distinct_after_project() {
1890 let ops = plan_src("MATCH (n) RETURN DISTINCT n").expect("DISTINCT must plan");
1891 let proj = ops
1892 .iter()
1893 .position(|op| matches!(op, PlanOp::Project { .. }))
1894 .expect("Project");
1895 assert!(
1896 matches!(ops.get(proj + 1), Some(PlanOp::Distinct)),
1897 "DISTINCT must follow Project, got: {ops:?}"
1898 );
1899 let bounded = plan_src("MATCH (n) RETURN DISTINCT n LIMIT 1").unwrap();
1900 assert!(
1901 super::row_bound(&bounded).is_none(),
1902 "DISTINCT + LIMIT must not push LIMIT into producers"
1903 );
1904 }
1905
1906 #[test]
1907 fn skip_then_limit_follow_project() {
1908 let got = plan_src("MATCH (a) RETURN a SKIP 2 LIMIT 3").unwrap();
1909 assert_eq!(
1910 got,
1911 vec![
1912 PlanOp::ScanLabel {
1913 var: "a".into(),
1914 label: None,
1915 },
1916 PlanOp::Project {
1917 items: vec![RetItem {
1918 value: RetVal::Var("a".into()),
1919 alias: None,
1920 }],
1921 },
1922 PlanOp::Skip(LimitSkip::Exact(2)),
1923 PlanOp::Limit(LimitSkip::Exact(3)),
1924 ]
1925 );
1926 }
1927
1928 #[test]
1929 fn aliased_prop_order_by_rewrites_to_alias_column() {
1930 let got = plan_src("MATCH (a) RETURN a.name AS nm ORDER BY a.name").unwrap();
1931 let order = got
1932 .iter()
1933 .find_map(|op| match op {
1934 PlanOp::OrderBy { items } => Some(items),
1935 _ => None,
1936 })
1937 .unwrap();
1938 assert_eq!(
1939 order,
1940 &vec![OrderItem {
1941 target: OrderTarget::Alias("nm".into()),
1942 descending: false,
1943 }]
1944 );
1945 }
1946
1947 #[test]
1948 fn plan_never_panics_on_hand_built_query() {
1949 use crate::cypher::ast::{NodePat, Pattern, Query};
1950 let q = Query {
1951 matches: vec![],
1952 optional_clauses: vec![],
1953 where_expr: None,
1954 unwinds: vec![],
1955 post_unwind_where: None,
1956 stages: vec![],
1957 returns: vec![],
1958 order_by: vec![],
1959 distinct: false,
1960 skip: None,
1961 limit: None,
1962 };
1963 let result = std::panic::catch_unwind(|| plan(&q));
1964 assert!(result.is_ok(), "plan panicked on empty Query");
1965 let _ = result.unwrap();
1966
1967 let q = Query {
1968 matches: vec![Pattern {
1969 start: NodePat {
1970 var: None,
1971 label: None,
1972 props: vec![],
1973 },
1974 chain: vec![],
1975 shortest: false,
1976 }],
1977 optional_clauses: vec![],
1978 where_expr: Some(Expr::Not(Box::new(Expr::Cmp {
1979 lhs: Operand::Param("p".into()),
1980 op: CmpOp::Eq,
1981 rhs: Operand::Lit(Value::Int(1)),
1982 }))),
1983 unwinds: vec![],
1984 post_unwind_where: None,
1985 stages: vec![],
1986 returns: vec![],
1987 distinct: false,
1988 order_by: vec![OrderItem {
1989 target: OrderTarget::Alias("missing".into()),
1990 descending: true,
1991 }],
1992 skip: Some(LimitSkip::Exact(0)),
1993 limit: Some(LimitSkip::Exact(0)),
1994 };
1995 let result = std::panic::catch_unwind(|| plan(&q));
1996 assert!(result.is_ok(), "plan panicked on hand-built Query");
1997 let _ = result.unwrap();
1998 }
1999
2000 #[test]
2001 fn bare_relationship_var_in_return_is_err() {
2002 let err = assert_plan_err("MATCH (a)-[r:T]->(b) RETURN r", "r");
2003 assert!(
2004 err.to_ascii_lowercase().contains("relationship"),
2005 "expected bare-rel RETURN guidance, got: {err}"
2006 );
2007 }
2008
2009 #[test]
2010 fn relationship_prop_in_return_is_ok() {
2011 plan_src("MATCH (a)-[r:T]->(b) RETURN r.w").expect("rel prop RETURN must plan");
2012 }
2013
2014 #[test]
2015 fn bare_relationship_var_in_order_by_is_err() {
2016 let err = assert_plan_err("MATCH (a)-[r:T]->(b) RETURN r.w ORDER BY r", "r");
2019 assert!(
2020 err.to_ascii_lowercase().contains("relationship"),
2021 "expected bare-rel ORDER BY guidance, got: {err}"
2022 );
2023 }
2024
2025 #[test]
2026 fn relationship_prop_in_order_by_is_ok() {
2027 plan_src("MATCH (a)-[r:T]->(b) RETURN r.w ORDER BY r.w")
2028 .expect("rel prop ORDER BY must plan");
2029 }
2030
2031 #[test]
2034 fn var_expand_op_emitted_for_star_rel() {
2035 use super::row_bound;
2036 let ops = plan_src("MATCH (a)-[r:T*2..4]->(b) RETURN b").unwrap();
2037 let has_var = ops
2038 .iter()
2039 .any(|op| matches!(op, PlanOp::VarExpand { min: 2, max: 4, .. }));
2040 assert!(has_var, "expected VarExpand(2..4) in plan, got: {ops:?}");
2041 assert_eq!(
2043 row_bound(&ops),
2044 None,
2045 "VarExpand plan must not use pull path"
2046 );
2047 }
2048
2049 #[test]
2050 fn var_expand_with_limit_still_takes_staged_path() {
2051 use super::row_bound;
2052 let ops = plan_src("MATCH (a)-[r:T*1..3]->(b) RETURN b LIMIT 5").unwrap();
2053 assert_eq!(
2055 row_bound(&ops),
2056 None,
2057 "VarExpand + LIMIT must still use staged path"
2058 );
2059 let has_var = ops.iter().any(|op| matches!(op, PlanOp::VarExpand { .. }));
2060 assert!(has_var, "plan must contain VarExpand");
2061 let has_limit = ops
2062 .iter()
2063 .any(|op| matches!(op, PlanOp::Limit(LimitSkip::Exact(5))));
2064 assert!(has_limit, "plan must still emit Limit op");
2065 }
2066
2067 #[test]
2068 fn shortest_path_op_emitted_for_shortest_path_clause() {
2069 let ops =
2070 plan_src("MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*..3]->(b)) RETURN a")
2071 .unwrap();
2072 let has_sp = ops
2073 .iter()
2074 .any(|op| matches!(op, PlanOp::ShortestPath { max_hops: 3, .. }));
2075 assert!(
2076 has_sp,
2077 "expected ShortestPath op with max_hops=3, got: {ops:?}"
2078 );
2079 }
2080
2081 #[test]
2082 fn shortest_path_unbound_endpoint_is_err() {
2083 let err = assert_plan_err(
2084 "MATCH shortestPath((a)-[r:T*..3]->(b)) RETURN a",
2085 "shortestPath",
2086 );
2087 assert!(
2088 err.contains("not bound") || err.contains("bound"),
2089 "error must mention binding, got: {err}"
2090 );
2091 }
2092
2093 #[test]
2094 fn var_expand_rel_var_is_in_rel_bound() {
2095 plan_src("MATCH (a)-[r:T*1..3]->(b) RETURN r.length").expect("r.length must plan");
2097 assert_plan_err("MATCH (a)-[r:T*1..3]->(b) RETURN r", "r");
2099 }
2100
2101 #[test]
2102 fn shortest_path_min_gt_1_is_plan_err() {
2103 let err = assert_plan_err(
2105 "MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*2..5]->(b)) RETURN r.length",
2106 "shortestPath",
2107 );
2108 assert!(
2109 err.contains("minimum"),
2110 "error must mention minimum hop count, got: {err}"
2111 );
2112 }
2113
2114 fn subscribable(src: &str) -> bool {
2117 let ops = plan_src(src).expect("must plan");
2118 super::is_subscribable(&ops)
2119 }
2120
2121 #[test]
2122 fn is_subscribable_passes_simple_label_scan() {
2123 assert!(subscribable("MATCH (n:Person) RETURN n"));
2124 assert!(subscribable("MATCH (n:Person) WHERE n.age > 18 RETURN n"));
2125 assert!(subscribable("MATCH (n:Person) RETURN n LIMIT 100"));
2126 }
2127
2128 #[test]
2129 fn is_subscribable_passes_single_hop_expand() {
2130 assert!(subscribable(
2131 "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a"
2132 ));
2133 assert!(subscribable(
2134 "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a LIMIT 50"
2135 ));
2136 }
2137
2138 #[test]
2139 fn is_subscribable_rejects_multi_hop_expand() {
2140 assert!(
2142 !subscribable("MATCH (a:Person)-[r1:KNOWS]->(b:Person)-[r2:LIKES]->(c:Thing) RETURN a"),
2143 "two-hop chain must be rejected"
2144 );
2145 }
2146
2147 #[test]
2148 fn is_subscribable_rejects_skip() {
2149 assert!(
2151 !subscribable("MATCH (n:Person) RETURN n SKIP 10 LIMIT 50"),
2152 "SKIP must be rejected"
2153 );
2154 assert!(
2155 !subscribable("MATCH (n:Person) RETURN n SKIP 10"),
2156 "bare SKIP must be rejected"
2157 );
2158 }
2159
2160 #[test]
2161 fn is_subscribable_rejects_order_by() {
2162 assert!(!subscribable("MATCH (n:Person) RETURN n ORDER BY n"));
2163 }
2164
2165 #[test]
2166 fn is_subscribable_rejects_aggregates() {
2167 assert!(!subscribable("MATCH (n:Person) RETURN COUNT(*)"));
2168 }
2169
2170 #[test]
2171 fn is_subscribable_rejects_var_expand() {
2172 assert!(!subscribable(
2173 "MATCH (a:Person)-[r:KNOWS*1..3]->(b) RETURN b"
2174 ));
2175 }
2176
2177 #[test]
2180 fn where_equality_folds_to_index_scan() {
2181 let ops = plan_src("MATCH (n:Person) WHERE n.city = 'austin' RETURN n.key").unwrap();
2182 assert!(
2183 matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2184 "WHERE single equality must fold to IndexScan, got {:?}",
2185 ops[0]
2186 );
2187 assert!(
2188 !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2189 "consumed predicate must not remain as Filter"
2190 );
2191 }
2192
2193 #[test]
2194 fn where_equality_param_folds_to_index_scan() {
2195 let ops = plan_src("MATCH (n:Person) WHERE n.city = $c RETURN n.key").unwrap();
2196 assert!(
2197 matches!(&ops[0], PlanOp::IndexScan { .. }),
2198 "param WHERE equality must fold to IndexScan, got {:?}",
2199 ops[0]
2200 );
2201 }
2202
2203 #[test]
2204 fn where_and_keeps_residual_filter() {
2205 let ops = plan_src("MATCH (n:Person) WHERE n.city = 'austin' AND n.age > 30 RETURN n.key")
2206 .unwrap();
2207 assert!(
2208 matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2209 "equality must fold to IndexScan, got {:?}",
2210 ops[0]
2211 );
2212 assert!(
2213 ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2214 "n.age > 30 must remain as residual Filter"
2215 );
2216 }
2217
2218 #[test]
2219 fn where_on_expanded_var_does_not_fold() {
2220 let ops =
2221 plan_src("MATCH (a:Person)-[:KNOWS]->(b:Person) WHERE b.city = 'austin' RETURN a.key")
2222 .unwrap();
2223 assert!(
2224 matches!(&ops[0], PlanOp::ScanLabel { .. } | PlanOp::IndexScan { .. }),
2225 "first op must be a scan, got {:?}",
2226 ops[0]
2227 );
2228 assert!(
2229 ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2230 "b.city filter must remain"
2231 );
2232 }
2233
2234 #[test]
2235 fn where_inline_prop_and_where_equality_both_usable() {
2236 let ops = plan_src("MATCH (n:Person {team: 'core'}) WHERE n.city = 'austin' RETURN n.key")
2238 .unwrap();
2239 assert!(
2240 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2241 "inline+WHERE equalities must merge to IndexIntersect(2), got {:?}",
2242 ops[0]
2243 );
2244 assert!(
2245 !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2246 "both equalities fully folded; no residual Filter expected"
2247 );
2248 }
2249
2250 #[test]
2253 fn single_equality_inline_stays_index_scan() {
2254 let ops = plan_src("MATCH (n:Person {city: 'austin'}) RETURN n").unwrap();
2256 assert!(
2257 matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2258 "single-equality inline prop must emit IndexScan, got {:?}",
2259 ops[0]
2260 );
2261 }
2262
2263 #[test]
2264 fn compound_inline_props_emit_index_intersect() {
2265 let ops = plan_src("MATCH (n:Doc {namespace: 'a', status: 'live'}) RETURN n.key").unwrap();
2266 assert!(
2267 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2268 "two inline props must emit IndexIntersect(2), got {:?}",
2269 ops[0]
2270 );
2271 }
2272
2273 #[test]
2274 fn where_two_equalities_emit_index_intersect() {
2275 let ops = plan_src("MATCH (n:Doc) WHERE n.namespace = 'a' AND n.status = $s RETURN n.key")
2276 .unwrap();
2277 assert!(
2278 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2279 "two WHERE equalities must emit IndexIntersect(2), got {:?}",
2280 ops[0]
2281 );
2282 assert!(
2283 !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2284 "both equalities fully consumed; no residual Filter expected"
2285 );
2286 }
2287
2288 #[test]
2289 fn mixed_inline_and_where_equalities_merge() {
2290 let ops = plan_src("MATCH (n:Doc {namespace: 'a'}) WHERE n.status = 'live' RETURN n.key")
2291 .unwrap();
2292 assert!(
2293 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2294 "inline+WHERE equalities must merge to IndexIntersect(2), got {:?}",
2295 ops[0]
2296 );
2297 }
2298
2299 #[test]
2300 fn where_three_equalities_emit_index_intersect() {
2301 let ops = plan_src(
2302 "MATCH (n:Doc) WHERE n.namespace = 'a' AND n.status = 'live' AND n.kind = $k RETURN n",
2303 )
2304 .unwrap();
2305 assert!(
2306 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 3),
2307 "three WHERE equalities must emit IndexIntersect(3), got {:?}",
2308 ops[0]
2309 );
2310 assert!(
2311 !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2312 "all three equalities fully consumed; no residual Filter expected"
2313 );
2314 }
2315}