1use super::ast::{
8 AggArg, AggFunc, Expr, LimitSkip, NodePat, Operand, OptionalClause, OrderItem, OrderTarget,
9 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 if let Some(expr) = &stage.where_expr {
646 check_expr_bound(expr, bound)?;
647 }
648 let with_col_names: BTreeSet<String> = stage.items.iter().map(column_name).collect();
653 for item in &stage.order_by {
654 match &item.target {
655 OrderTarget::Prop { var, .. } | OrderTarget::Var(var) => {
656 if !bound.contains(var.as_str()) && !with_col_names.contains(var.as_str()) {
657 return Err(format!("unbound variable `{var}` in ORDER BY in WITH"));
658 }
659 }
660 OrderTarget::Alias(name) => {
661 if !bound.contains(name.as_str()) && !with_col_names.contains(name.as_str()) {
662 return Err(format!("unbound variable `{name}` in ORDER BY in WITH"));
663 }
664 }
665 }
666 }
667 ops.push(PlanOp::With {
668 items: stage.items.clone(),
669 where_expr: stage.where_expr.clone(),
670 order_by: stage.order_by.clone(),
671 skip: stage.skip.clone(),
672 limit: stage.limit.clone(),
673 });
674
675 let mut new_bound: BTreeSet<String> = BTreeSet::new();
677 let mut new_rel_bound: BTreeSet<String> = BTreeSet::new();
678 for item in &stage.items {
679 let col = column_name(item);
680 new_bound.insert(col.clone());
681 match &item.value {
683 RetVal::Var(v) if rel_bound.contains(v.as_str()) => {
684 new_rel_bound.insert(col);
685 }
686 _ => {}
687 }
688 }
689 *bound = new_bound;
690 *rel_bound = new_rel_bound;
691 }
692
693 for pat in &stage.matches {
695 compile_pattern(pat, ops, bound, rel_bound, node_anon, rel_anon)?;
696 }
697 for oc in &stage.optional_clauses {
699 compile_optional_clause(oc, ops, bound, rel_bound, node_anon, rel_anon)?;
700 }
701 for uw in &stage.unwinds {
703 check_unwind_bound(&uw.list, bound)?;
704 bound.insert(uw.alias.clone());
705 ops.push(PlanOp::Unwind {
706 expr: uw.list.clone(),
707 alias: uw.alias.clone(),
708 });
709 }
710 if let Some(expr) = &stage.post_where {
712 check_expr_bound(expr, bound)?;
713 ops.push(PlanOp::Filter { expr: expr.clone() });
714 }
715
716 Ok(())
717}
718
719fn id_lookup(props: &[(String, Operand)]) -> Option<&Operand> {
720 if props.len() == 1 && props[0].0 == "id" {
721 Some(&props[0].1)
722 } else {
723 None
724 }
725}
726
727fn index_lookup(props: &[(String, Operand)]) -> Option<(&str, &Operand)> {
730 if props.len() == 1
731 && props[0].0 != "id"
732 && matches!(props[0].1, Operand::Lit(_) | Operand::Param(_))
733 {
734 Some((props[0].0.as_str(), &props[0].1))
735 } else {
736 None
737 }
738}
739
740fn multi_index_lookup(props: &[(String, Operand)]) -> Option<Vec<(String, Operand)>> {
744 if props.len() < 2 {
745 return None;
746 }
747 if props
748 .iter()
749 .any(|(f, v)| f == "id" || !matches!(v, Operand::Lit(_) | Operand::Param(_)))
750 {
751 return None;
752 }
753 Some(props.to_vec())
754}
755
756pub(super) fn split_and(expr: Expr) -> Vec<Expr> {
759 match expr {
760 Expr::And(l, r) => {
761 let mut v = split_and(*l);
762 v.extend(split_and(*r));
763 v
764 }
765 other => vec![other],
766 }
767}
768
769pub(super) fn join_and(mut exprs: Vec<Expr>) -> Option<Expr> {
772 if exprs.is_empty() {
773 return None;
774 }
775 let mut result = exprs.remove(0);
776 for e in exprs {
777 result = Expr::And(Box::new(result), Box::new(e));
778 }
779 Some(result)
780}
781
782pub(super) fn fold_where_equalities(mut ops: Vec<PlanOp>) -> Vec<PlanOp> {
805 let Some(scan_pos) = ops
809 .iter()
810 .position(|op| matches!(op, PlanOp::ScanLabel { .. } | PlanOp::IndexScan { .. }))
811 else {
812 return ops;
813 };
814
815 let (scan_var, scan_label, existing_eq) = match &ops[scan_pos] {
818 PlanOp::ScanLabel { var, label } => (var.clone(), label.clone(), None),
819 PlanOp::IndexScan {
820 var,
821 label,
822 field,
823 value,
824 } => (
825 var.clone(),
826 label.clone(),
827 Some((field.clone(), value.clone())),
828 ),
829 _ => unreachable!(),
830 };
831
832 let Some(rel_pos) = ops[scan_pos + 1..]
834 .iter()
835 .position(|op| matches!(op, PlanOp::Filter { .. }))
836 else {
837 return ops;
838 };
839 let filter_pos = scan_pos + 1 + rel_pos;
840
841 if ops[scan_pos + 1..filter_pos]
843 .iter()
844 .any(|op| matches!(op, PlanOp::Expand { .. }))
845 {
846 return ops;
847 }
848
849 let filter_expr = match &ops[filter_pos] {
850 PlanOp::Filter { expr } => expr.clone(),
851 _ => unreachable!(),
852 };
853
854 let mut terms = split_and(filter_expr);
856 let mut extracted: Vec<(String, Operand)> = Vec::new();
857 let mut i = 0;
858 while i < terms.len() {
859 if matches!(
860 &terms[i],
861 Expr::Cmp {
862 lhs: Operand::Prop { var, .. },
863 op: CmpOp::Eq,
864 rhs: Operand::Lit(_) | Operand::Param(_),
865 } if var == &scan_var
866 ) {
867 let term = terms.remove(i);
868 match term {
869 Expr::Cmp {
870 lhs: Operand::Prop { field, .. },
871 rhs,
872 ..
873 } => extracted.push((field, rhs)),
874 _ => unreachable!(),
875 }
876 } else {
877 i += 1;
878 }
879 }
880
881 if extracted.is_empty() {
882 return ops;
883 }
884
885 let mut all_equalities: Vec<(String, Operand)> = Vec::new();
887 if let Some(eq) = existing_eq {
888 all_equalities.push(eq);
889 }
890 all_equalities.extend(extracted);
891
892 ops[scan_pos] = if all_equalities.len() == 1 {
894 let (field, value) = all_equalities.remove(0);
895 PlanOp::IndexScan {
896 var: scan_var,
897 label: scan_label,
898 field,
899 value,
900 }
901 } else {
902 PlanOp::IndexIntersect {
903 var: scan_var,
904 label: scan_label,
905 equalities: all_equalities,
906 }
907 };
908
909 match join_and(terms) {
911 Some(residual) => ops[filter_pos] = PlanOp::Filter { expr: residual },
912 None => {
913 ops.remove(filter_pos);
914 }
915 }
916
917 ops
918}
919
920fn invert_dir(d: RelDir) -> RelDir {
921 match d {
922 RelDir::Right => RelDir::Left,
923 RelDir::Left => RelDir::Right,
924 RelDir::Undirected => RelDir::Undirected,
925 }
926}
927
928fn compile_pattern(
929 pat: &Pattern,
930 ops: &mut Vec<PlanOp>,
931 bound: &mut BTreeSet<String>,
932 rel_bound: &mut BTreeSet<String>,
933 node_anon: &mut u32,
934 rel_anon: &mut u32,
935) -> Result<(), String> {
936 let start = name_node(&pat.start, node_anon, bound);
937 if pat.shortest {
938 if !bound.contains(&start) {
940 return Err(format!(
941 "shortestPath: source node `{start}` is not bound; \
942 bind both endpoints before shortestPath"
943 ));
944 }
945 ops.push(PlanOp::JoinBound {
946 var: start.clone(),
947 label: pat.start.label.clone(),
948 props: pat.start.props.clone(),
949 });
950 } else if bound.contains(&start) {
951 ops.push(PlanOp::JoinBound {
952 var: start.clone(),
953 label: pat.start.label.clone(),
954 props: pat.start.props.clone(),
955 });
956 } else if pat.chain.len() == 1
957 && pat.chain[0].0.hops.is_none()
958 && pat.chain[0]
959 .1
960 .var
961 .as_ref()
962 .is_some_and(|v| bound.contains(v))
963 {
964 let (rel, dest) = &pat.chain[0];
969 let dest_name = name_node(dest, node_anon, bound);
970 let rel_name = name_rel(rel, rel_anon, bound);
971 bound.insert(rel_name.clone());
972 rel_bound.insert(rel_name.clone());
973 if dest.label.is_some() || !dest.props.is_empty() {
974 ops.push(PlanOp::JoinBound {
975 var: dest_name.clone(),
976 label: dest.label.clone(),
977 props: dest.props.clone(),
978 });
979 }
980 ops.push(PlanOp::Expand {
981 from: dest_name,
982 rel_var: Some(rel_name),
983 etypes: rel.etypes.clone(),
984 dir: invert_dir(rel.dir),
985 to: start.clone(),
986 to_label: pat.start.label.clone(),
987 to_props: pat.start.props.clone(),
988 });
989 bound.insert(start);
990 return Ok(());
991 } else if let Some(key) = id_lookup(&pat.start.props) {
992 ops.push(PlanOp::ScanKey {
993 var: start.clone(),
994 key: key.clone(),
995 label: pat.start.label.clone(),
996 });
997 bound.insert(start.clone());
998 } else if let Some((field, value)) = index_lookup(&pat.start.props) {
999 ops.push(PlanOp::IndexScan {
1000 var: start.clone(),
1001 label: pat.start.label.clone(),
1002 field: field.to_string(),
1003 value: value.clone(),
1004 });
1005 bound.insert(start.clone());
1006 } else if let Some(equalities) = multi_index_lookup(&pat.start.props) {
1007 ops.push(PlanOp::IndexIntersect {
1008 var: start.clone(),
1009 label: pat.start.label.clone(),
1010 equalities,
1011 });
1012 bound.insert(start.clone());
1013 } else {
1014 ops.push(PlanOp::ScanLabel {
1015 var: start.clone(),
1016 label: pat.start.label.clone(),
1017 });
1018 if !pat.start.props.is_empty() {
1019 ops.push(PlanOp::LookupProps {
1020 var: start.clone(),
1021 props: pat.start.props.clone(),
1022 });
1023 }
1024 bound.insert(start.clone());
1025 }
1026
1027 let mut from = start;
1028 for (rel, dest) in &pat.chain {
1029 let rel_name = name_rel(rel, rel_anon, bound);
1030 bound.insert(rel_name.clone());
1031 rel_bound.insert(rel_name.clone());
1032 let to = name_node(dest, node_anon, bound);
1033
1034 if let Some(hops) = rel.hops {
1035 if pat.shortest {
1036 if !bound.contains(&to) {
1038 return Err(format!(
1039 "shortestPath: destination node `{to}` is not bound; \
1040 bind both endpoints before shortestPath"
1041 ));
1042 }
1043 if hops.min > 1 {
1047 return Err(format!(
1048 "shortestPath does not support a minimum hop count \
1049 (got min={}); use a plain variable-length pattern \
1050 if you need a minimum",
1051 hops.min
1052 ));
1053 }
1054 ops.push(PlanOp::ShortestPath {
1055 from: from.clone(),
1056 rel_var: Some(rel_name),
1057 etypes: rel.etypes.clone(),
1058 dir: rel.dir,
1059 to: to.clone(),
1060 max_hops: hops.max,
1061 });
1062 } else {
1063 ops.push(PlanOp::VarExpand {
1064 from: from.clone(),
1065 rel_var: Some(rel_name),
1066 etypes: rel.etypes.clone(),
1067 dir: rel.dir,
1068 to: to.clone(),
1069 min: hops.min,
1070 max: hops.max,
1071 });
1072 bound.insert(to.clone());
1073 }
1074 } else {
1075 ops.push(PlanOp::Expand {
1076 from: from.clone(),
1077 rel_var: Some(rel_name),
1078 etypes: rel.etypes.clone(),
1079 dir: rel.dir,
1080 to: to.clone(),
1081 to_label: dest.label.clone(),
1082 to_props: dest.props.clone(),
1083 });
1084 bound.insert(to.clone());
1085 }
1086 from = to;
1087 }
1088 Ok(())
1089}
1090
1091fn compile_optional_clause(
1098 oc: &OptionalClause,
1099 ops: &mut Vec<PlanOp>,
1100 bound: &mut BTreeSet<String>,
1101 rel_bound: &mut BTreeSet<String>,
1102 node_anon: &mut u32,
1103 rel_anon: &mut u32,
1104) -> Result<(), String> {
1105 let mut inner_bound = bound.clone();
1107 let mut inner_rel_bound = rel_bound.clone();
1108 let mut inner_ops: Vec<PlanOp> = Vec::new();
1109
1110 for pat in &oc.patterns {
1111 compile_pattern(
1112 pat,
1113 &mut inner_ops,
1114 &mut inner_bound,
1115 &mut inner_rel_bound,
1116 node_anon,
1117 rel_anon,
1118 )?;
1119 }
1120 if let Some(expr) = &oc.where_expr {
1121 check_expr_bound(expr, &inner_bound)?;
1122 inner_ops.push(PlanOp::Filter { expr: expr.clone() });
1123 }
1124
1125 let optional_vars: Vec<String> = inner_bound
1127 .difference(bound)
1128 .chain(inner_rel_bound.difference(rel_bound))
1129 .cloned()
1130 .collect();
1131
1132 for v in &optional_vars {
1135 bound.insert(v.clone());
1136 }
1137 for v in inner_rel_bound
1138 .difference(&*rel_bound)
1139 .cloned()
1140 .collect::<Vec<_>>()
1141 {
1142 rel_bound.insert(v);
1143 }
1144
1145 ops.push(PlanOp::LeftOuterApply {
1146 inner: inner_ops,
1147 optional_vars,
1148 });
1149 Ok(())
1150}
1151
1152fn name_node(node: &NodePat, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1153 match &node.var {
1154 Some(v) => v.clone(),
1155 None => fresh("_n", counter, bound),
1156 }
1157}
1158
1159fn name_rel(rel: &RelPat, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1160 match &rel.var {
1161 Some(v) => v.clone(),
1162 None => fresh("_r", counter, bound),
1163 }
1164}
1165
1166fn fresh(prefix: &str, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1169 for _ in 0..=u32::MAX {
1170 let name = format!("{prefix}{counter}");
1171 *counter = counter.wrapping_add(1);
1172 if !bound.contains(&name) {
1173 return name;
1174 }
1175 }
1176 format!("{prefix}x")
1177}
1178
1179fn check_expr_bound(expr: &Expr, bound: &BTreeSet<String>) -> Result<(), String> {
1180 match expr {
1181 Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => {
1182 check_expr_bound(lhs, bound)?;
1183 check_expr_bound(rhs, bound)
1184 }
1185 Expr::Not(inner) => check_expr_bound(inner, bound),
1186 Expr::Cmp { lhs, rhs, .. } => {
1187 check_operand_bound(lhs, bound, "WHERE")?;
1188 check_operand_bound(rhs, bound, "WHERE")
1189 }
1190 Expr::Truthy(op) => check_operand_bound(op, bound, "WHERE"),
1191 Expr::IsNull(op) | Expr::IsNotNull(op) => check_operand_bound(op, bound, "WHERE"),
1192 Expr::In { expr, list } => {
1193 check_operand_bound(expr, bound, "WHERE")?;
1194 for item in list {
1195 check_operand_bound(item, bound, "WHERE")?;
1196 }
1197 Ok(())
1198 }
1199 }
1200}
1201
1202fn check_operand_bound(
1203 operand: &Operand,
1204 bound: &BTreeSet<String>,
1205 clause: &str,
1206) -> Result<(), String> {
1207 match operand {
1208 Operand::Prop { var, .. } => require_bound(var, bound, clause),
1209 Operand::Lit(_) | Operand::Param(_) => Ok(()),
1210 Operand::Var(name) => require_bound(name, bound, clause),
1211 Operand::BinArith { left, right, .. } => {
1212 check_operand_bound(left, bound, clause)?;
1213 check_operand_bound(right, bound, clause)
1214 }
1215 Operand::FuncCall { args, .. } => {
1216 for arg in args {
1217 check_operand_bound(arg, bound, clause)?;
1218 }
1219 Ok(())
1220 }
1221 Operand::Case { branches, default } => {
1222 for (cond, value) in branches {
1223 check_expr_bound(cond, bound)?;
1224 check_operand_bound(value, bound, clause)?;
1225 }
1226 if let Some(d) = default {
1227 check_operand_bound(d, bound, clause)?;
1228 }
1229 Ok(())
1230 }
1231 }
1232}
1233
1234fn check_unwind_bound(expr: &UnwindExpr, bound: &BTreeSet<String>) -> Result<(), String> {
1236 match expr {
1237 UnwindExpr::Lit(_) => Ok(()),
1238 UnwindExpr::Prop { var, .. } => require_bound(var, bound, "UNWIND"),
1239 UnwindExpr::Var(name) => require_bound(name, bound, "UNWIND"),
1240 }
1241}
1242
1243fn require_bound(var: &str, bound: &BTreeSet<String>, clause: &str) -> Result<(), String> {
1244 if bound.contains(var) {
1245 Ok(())
1246 } else {
1247 Err(format!("unbound variable `{var}` in {clause}"))
1248 }
1249}
1250
1251fn reject_bare_rel(var: &str, rel_bound: &BTreeSet<String>) -> Result<(), String> {
1252 if rel_bound.contains(var) {
1253 Err(format!(
1254 "cannot return relationship variable '{var}' bare; return its properties ({var}.field) instead"
1255 ))
1256 } else {
1257 Ok(())
1258 }
1259}
1260
1261fn check_return_bound(
1262 items: &[RetItem],
1263 bound: &BTreeSet<String>,
1264 rel_bound: &BTreeSet<String>,
1265) -> Result<(), String> {
1266 for item in items {
1267 match &item.value {
1268 RetVal::Var(v) => {
1269 require_bound(v, bound, "RETURN")?;
1270 reject_bare_rel(v, rel_bound)?;
1271 }
1272 RetVal::Prop { var, .. } => {
1273 require_bound(var, bound, "RETURN")?;
1274 }
1275 RetVal::Agg { arg, .. } => match arg {
1276 AggArg::Star => {}
1277 AggArg::Var(v) => {
1278 require_bound(v, bound, "RETURN")?;
1279 }
1280 AggArg::Prop { var, .. } => {
1281 require_bound(var, bound, "RETURN")?;
1282 }
1283 },
1284 RetVal::FuncCall { args, .. } => {
1285 for arg in args {
1286 check_operand_bound(arg, bound, "RETURN")?;
1287 }
1288 }
1289 RetVal::ScalarExpr(op) => {
1290 check_operand_bound(op, bound, "RETURN")?;
1291 }
1292 }
1293 }
1294 Ok(())
1295}
1296
1297fn check_duplicate_aliases(items: &[RetItem]) -> Result<(), String> {
1298 let mut seen = BTreeSet::new();
1299 for item in items {
1300 if let Some(alias) = &item.alias {
1301 if !seen.insert(alias.clone()) {
1302 return Err(format!("duplicate RETURN alias `{alias}`"));
1303 }
1304 }
1305 }
1306 Ok(())
1307}
1308
1309fn check_duplicate_columns(items: &[RetItem]) -> Result<(), String> {
1310 let mut seen = BTreeSet::new();
1311 for item in items {
1312 let col = column_name(item);
1313 if !seen.insert(col.clone()) {
1314 return Err(format!("duplicate RETURN column `{col}`"));
1315 }
1316 }
1317 Ok(())
1318}
1319
1320fn column_name(item: &RetItem) -> String {
1323 if let Some(alias) = &item.alias {
1324 return alias.clone();
1325 }
1326 match &item.value {
1327 RetVal::Var(v) => v.clone(),
1328 RetVal::Prop { var, field } => format!("{var}.{field}"),
1329 RetVal::Agg { func, arg } => agg_column_name(func, arg),
1330 RetVal::FuncCall { name, args } => {
1331 let arg_strs: Vec<String> = args
1332 .iter()
1333 .map(|a| match a {
1334 Operand::Var(v) => v.clone(),
1335 Operand::Prop { var, field } => format!("{var}.{field}"),
1336 Operand::Lit(_) => "<lit>".to_string(),
1337 Operand::Param(p) => format!("${p}"),
1338 Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
1339 Operand::BinArith { .. } => "<arith>".to_string(),
1340 Operand::Case { .. } => "<case>".to_string(),
1341 })
1342 .collect();
1343 format!("{name}({})", arg_strs.join(", "))
1344 }
1345 RetVal::ScalarExpr(_) => "<expr>".to_string(),
1346 }
1347}
1348
1349fn agg_column_name(func: &AggFunc, arg: &AggArg) -> String {
1352 let f = func_name(func);
1353 let a = match arg {
1354 AggArg::Star => "*".to_string(),
1355 AggArg::Var(v) => v.clone(),
1356 AggArg::Prop { var, field } => format!("{var}.{field}"),
1357 };
1358 format!("{f}({a})")
1359}
1360
1361fn func_name(func: &AggFunc) -> &'static str {
1362 match func {
1363 AggFunc::Count => "COUNT",
1364 AggFunc::Sum => "SUM",
1365 AggFunc::Avg => "AVG",
1366 AggFunc::Min => "MIN",
1367 AggFunc::Max => "MAX",
1368 AggFunc::Collect => "COLLECT",
1369 }
1370}
1371
1372fn rewrite_order_item(
1373 item: &OrderItem,
1374 returns: &[RetItem],
1375 bound: &BTreeSet<String>,
1376 rel_bound: &BTreeSet<String>,
1377) -> Result<OrderItem, String> {
1378 let column = match &item.target {
1379 OrderTarget::Alias(name) => {
1380 if returns
1381 .iter()
1382 .any(|r| r.alias.as_deref() == Some(name.as_str()))
1383 {
1384 name.clone()
1385 } else {
1386 return Err(format!("ORDER BY target `{name}` is not present in RETURN"));
1387 }
1388 }
1389 OrderTarget::Var(v) => {
1390 require_bound(v, bound, "ORDER BY")?;
1391 reject_bare_rel(v, rel_bound)?;
1392 match returns
1393 .iter()
1394 .find(|r| matches!(&r.value, RetVal::Var(x) if x == v))
1395 {
1396 Some(r) => column_name(r),
1397 None => {
1398 return Err(format!("ORDER BY target `{v}` is not present in RETURN"));
1399 }
1400 }
1401 }
1402 OrderTarget::Prop { var, field } => {
1403 require_bound(var, bound, "ORDER BY")?;
1404 match returns.iter().find(
1405 |r| matches!(&r.value, RetVal::Prop { var: v, field: f } if v == var && f == field),
1406 ) {
1407 Some(r) => column_name(r),
1408 None => {
1409 return Err(format!(
1410 "ORDER BY target `{var}.{field}` is not present in RETURN"
1411 ));
1412 }
1413 }
1414 }
1415 };
1416 Ok(OrderItem {
1417 target: OrderTarget::Alias(column),
1418 descending: item.descending,
1419 })
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424 use super::{plan, PlanOp};
1425 use crate::cypher::ast::{Expr, LimitSkip, Operand, OrderItem, OrderTarget, RetItem, RetVal};
1426 use crate::cypher::{lex, parse, RelDir};
1427 use crate::filter::CmpOp;
1428 use core_storage::Value;
1429
1430 fn plan_src(src: &str) -> Result<Vec<PlanOp>, String> {
1431 plan(&parse(&lex(src)?)?)
1432 }
1433
1434 fn assert_plan_err(src: &str, needle: &str) -> String {
1435 let result = std::panic::catch_unwind(|| plan_src(src));
1436 assert!(result.is_ok(), "plan({src:?}) panicked");
1437 let err = result
1438 .unwrap()
1439 .expect_err(&format!("plan({src:?}) must be Err"));
1440 assert!(
1441 err.contains(needle),
1442 "error must mention {needle:?}, got: {err}"
1443 );
1444 err
1445 }
1446
1447 #[test]
1453 fn dogfood_query_exact_plan() {
1454 let src = "\
1455MATCH (t:Talent {id: $tid}) \
1456MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
1457MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
1458WHERE i.score >= 0.5 AND s.score >= 0.5 \
1459RETURN c, i.score AS industry, s.score AS specialty \
1460ORDER BY industry DESC, specialty DESC \
1461LIMIT 10";
1462 let got = plan_src(src).expect("dogfood query must plan");
1463 let expected = vec![
1464 PlanOp::ScanKey {
1465 var: "t".into(),
1466 key: Operand::Param("tid".into()),
1467 label: Some("Talent".into()),
1468 },
1469 PlanOp::Expand {
1470 from: "t".into(),
1471 rel_var: Some("i".into()),
1472 etypes: vec!["INDUSTRY_ALIGNMENT".into()],
1473 dir: RelDir::Left,
1474 to: "c".into(),
1475 to_label: Some("Company".into()),
1476 to_props: vec![],
1477 },
1478 PlanOp::JoinBound {
1479 var: "c".into(),
1480 label: None,
1481 props: vec![],
1482 },
1483 PlanOp::Expand {
1484 from: "c".into(),
1485 rel_var: Some("s".into()),
1486 etypes: vec!["SPECIALTY_MATCH".into()],
1487 dir: RelDir::Right,
1488 to: "t".into(),
1489 to_label: None,
1490 to_props: vec![],
1491 },
1492 PlanOp::Filter {
1493 expr: Expr::And(
1494 Box::new(Expr::Cmp {
1495 lhs: Operand::Prop {
1496 var: "i".into(),
1497 field: "score".into(),
1498 },
1499 op: CmpOp::Ge,
1500 rhs: Operand::Lit(Value::Float(0.5)),
1501 }),
1502 Box::new(Expr::Cmp {
1503 lhs: Operand::Prop {
1504 var: "s".into(),
1505 field: "score".into(),
1506 },
1507 op: CmpOp::Ge,
1508 rhs: Operand::Lit(Value::Float(0.5)),
1509 }),
1510 ),
1511 },
1512 PlanOp::Project {
1513 items: vec![
1514 RetItem {
1515 value: RetVal::Var("c".into()),
1516 alias: None,
1517 },
1518 RetItem {
1519 value: RetVal::Prop {
1520 var: "i".into(),
1521 field: "score".into(),
1522 },
1523 alias: Some("industry".into()),
1524 },
1525 RetItem {
1526 value: RetVal::Prop {
1527 var: "s".into(),
1528 field: "score".into(),
1529 },
1530 alias: Some("specialty".into()),
1531 },
1532 ],
1533 },
1534 PlanOp::OrderBy {
1535 items: vec![
1536 OrderItem {
1537 target: OrderTarget::Alias("industry".into()),
1538 descending: true,
1539 },
1540 OrderItem {
1541 target: OrderTarget::Alias("specialty".into()),
1542 descending: true,
1543 },
1544 ],
1545 },
1546 PlanOp::Limit(LimitSkip::Exact(10)),
1547 ];
1548 assert_eq!(got, expected);
1549 }
1550
1551 #[test]
1556 fn anonymous_node_and_rel_names_are_stable() {
1557 let got = plan_src("MATCH ()-[]->(a) MATCH ()-[]->(a) RETURN a").unwrap();
1558 assert_eq!(
1559 got,
1560 vec![
1561 PlanOp::ScanLabel {
1562 var: "_n0".into(),
1563 label: None,
1564 },
1565 PlanOp::Expand {
1566 from: "_n0".into(),
1567 rel_var: Some("_r0".into()),
1568 etypes: vec![],
1569 dir: RelDir::Right,
1570 to: "a".into(),
1571 to_label: None,
1572 to_props: vec![],
1573 },
1574 PlanOp::Expand {
1575 from: "a".into(),
1576 rel_var: Some("_r1".into()),
1577 etypes: vec![],
1578 dir: RelDir::Left,
1579 to: "_n1".into(),
1580 to_label: None,
1581 to_props: vec![],
1582 },
1583 PlanOp::Project {
1584 items: vec![RetItem {
1585 value: RetVal::Var("a".into()),
1586 alias: None,
1587 }],
1588 },
1589 ]
1590 );
1591 }
1592
1593 #[test]
1594 fn props_on_scan_node_emit_scan_then_lookup() {
1595 let got = plan_src("MATCH (t:Talent {id: $tid}) RETURN t").unwrap();
1596 assert_eq!(
1597 got,
1598 vec![
1599 PlanOp::ScanKey {
1600 var: "t".into(),
1601 key: Operand::Param("tid".into()),
1602 label: Some("Talent".into()),
1603 },
1604 PlanOp::Project {
1605 items: vec![RetItem {
1606 value: RetVal::Var("t".into()),
1607 alias: None,
1608 }],
1609 },
1610 ]
1611 );
1612 }
1613
1614 #[test]
1615 fn mixed_id_map_stays_scan_label_then_lookup() {
1616 let got = plan_src("MATCH (t:Talent {id: $k, name: 'x'}) RETURN t").unwrap();
1617 assert_eq!(
1618 got,
1619 vec![
1620 PlanOp::ScanLabel {
1621 var: "t".into(),
1622 label: Some("Talent".into()),
1623 },
1624 PlanOp::LookupProps {
1625 var: "t".into(),
1626 props: vec![
1627 ("id".into(), Operand::Param("k".into())),
1628 ("name".into(), Operand::Lit(Value::Str("x".into()))),
1629 ],
1630 },
1631 PlanOp::Project {
1632 items: vec![RetItem {
1633 value: RetVal::Var("t".into()),
1634 alias: None,
1635 }],
1636 },
1637 ]
1638 );
1639 }
1640
1641 #[test]
1642 fn plan_id_map_is_scan_key() {
1643 let toks = crate::cypher::lex("MATCH (n:Person {id: $k}) RETURN n").unwrap();
1644 let q = crate::cypher::parse(&toks).unwrap();
1645 let ops = plan(&q).unwrap();
1646 assert!(matches!(ops[0], PlanOp::ScanKey { .. }), "{ops:?}");
1647 }
1648
1649 #[test]
1650 fn plan_expands_from_bound_key() {
1651 let cy =
1652 "MATCH (t:Talent {id: $tid}) MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) RETURN c";
1653 let ops = plan(&crate::cypher::parse(&crate::cypher::lex(cy).unwrap()).unwrap()).unwrap();
1654 assert!(matches!(&ops[0], PlanOp::ScanKey { var, .. } if var == "t"));
1656 match &ops[1] {
1657 PlanOp::Expand { from, dir, to, .. } => {
1658 assert_eq!(from, "t");
1659 assert_eq!(to, "c");
1660 assert_eq!(*dir, RelDir::Left);
1661 }
1662 other => panic!("{other:?}"),
1663 }
1664 }
1665
1666 #[test]
1670 fn plan_does_not_reverse_variable_length_from_bound() {
1671 let cy = "MATCH (t {id: $tid}) MATCH (c:Company)-[*1..2]->(t) RETURN c";
1672 let ops = plan_src(cy).unwrap();
1673 assert!(
1674 matches!(&ops[0], PlanOp::ScanKey { var, .. } if var == "t"),
1675 "{ops:?}"
1676 );
1677 assert!(
1678 matches!(&ops[1], PlanOp::ScanLabel { var, label } if var == "c" && label.as_deref() == Some("Company")),
1679 "{ops:?}"
1680 );
1681 match &ops[2] {
1682 PlanOp::VarExpand {
1683 from,
1684 dir,
1685 to,
1686 min,
1687 max,
1688 ..
1689 } => {
1690 assert_eq!(from, "c");
1691 assert_eq!(to, "t");
1692 assert_eq!(*dir, RelDir::Right);
1693 assert_eq!(*min, 1);
1694 assert_eq!(*max, 2);
1695 }
1696 other => panic!("{other:?}"),
1697 }
1698 }
1699
1700 #[test]
1701 fn unbound_var_in_where_is_err() {
1702 let err = assert_plan_err("MATCH (a) WHERE b.x = 1 RETURN a", "b");
1703 assert!(
1704 err.to_ascii_lowercase().contains("unbound")
1705 && err.to_ascii_lowercase().contains("where"),
1706 "expected unbound-in-WHERE context, got: {err}"
1707 );
1708 }
1709
1710 #[test]
1711 fn unbound_var_in_return_is_err() {
1712 let err = assert_plan_err("MATCH (a) RETURN b", "b");
1713 assert!(
1714 err.to_ascii_lowercase().contains("unbound")
1715 && err.to_ascii_lowercase().contains("return"),
1716 "expected unbound-in-RETURN context, got: {err}"
1717 );
1718 }
1719
1720 #[test]
1721 fn unbound_var_in_order_by_is_err() {
1722 let err = assert_plan_err("MATCH (a) RETURN a ORDER BY b", "b");
1723 assert!(
1724 err.to_ascii_lowercase().contains("unbound")
1725 && (err.to_ascii_lowercase().contains("order")),
1726 "expected unbound-in-ORDER context, got: {err}"
1727 );
1728 }
1729
1730 #[test]
1731 fn duplicate_alias_is_err() {
1732 let err = assert_plan_err("MATCH (a) RETURN a AS x, a.id AS x", "x");
1733 assert!(
1734 err.to_ascii_lowercase().contains("duplicate")
1735 && err.to_ascii_lowercase().contains("alias"),
1736 "expected duplicate-alias context, got: {err}"
1737 );
1738 }
1739
1740 #[test]
1741 fn duplicate_column_name_is_err() {
1742 let err = assert_plan_err("MATCH (a) RETURN a, a", "a");
1743 assert!(
1744 err.to_ascii_lowercase().contains("duplicate")
1745 && err.to_ascii_lowercase().contains("column"),
1746 "expected duplicate-column context, got: {err}"
1747 );
1748 }
1749
1750 #[test]
1751 fn order_by_target_absent_from_return_is_err() {
1752 let err = assert_plan_err("MATCH (a) RETURN a ORDER BY a.x", "a.x");
1754 assert!(
1755 err.to_ascii_lowercase().contains("return"),
1756 "expected ORDER BY target-not-in-RETURN context, got: {err}"
1757 );
1758 }
1759
1760 #[test]
1763 fn order_by_targets_rewrite_to_projected_column_names() {
1764 let got = plan_src(
1765 "MATCH (a)-[r]->(b) \
1766 RETURN a, a.name AS nm, b.age \
1767 ORDER BY nm DESC, a ASC, b.age",
1768 )
1769 .unwrap();
1770 let order = got
1771 .iter()
1772 .find_map(|op| match op {
1773 PlanOp::OrderBy { items } => Some(items),
1774 _ => None,
1775 })
1776 .expect("plan must contain OrderBy");
1777 assert_eq!(
1778 order,
1779 &vec![
1780 OrderItem {
1781 target: OrderTarget::Alias("nm".into()),
1782 descending: true,
1783 },
1784 OrderItem {
1785 target: OrderTarget::Alias("a".into()),
1786 descending: false,
1787 },
1788 OrderItem {
1789 target: OrderTarget::Alias("b.age".into()),
1790 descending: false,
1791 },
1792 ]
1793 );
1794
1795 let aliased_var = plan_src("MATCH (a) RETURN a AS person ORDER BY a").unwrap();
1796 let order = aliased_var
1797 .iter()
1798 .find_map(|op| match op {
1799 PlanOp::OrderBy { items } => Some(items),
1800 _ => None,
1801 })
1802 .expect("plan must contain OrderBy");
1803 assert_eq!(
1804 order,
1805 &vec![OrderItem {
1806 target: OrderTarget::Alias("person".into()),
1807 descending: false,
1808 }]
1809 );
1810 }
1811
1812 #[test]
1813 fn bound_pattern_start_is_join_bound_then_expand() {
1814 let got = plan_src("MATCH (a:L) MATCH (a)-[r:T]->(b) RETURN a, b").unwrap();
1815 assert_eq!(
1816 got,
1817 vec![
1818 PlanOp::ScanLabel {
1819 var: "a".into(),
1820 label: Some("L".into()),
1821 },
1822 PlanOp::JoinBound {
1823 var: "a".into(),
1824 label: None,
1825 props: vec![],
1826 },
1827 PlanOp::Expand {
1828 from: "a".into(),
1829 rel_var: Some("r".into()),
1830 etypes: vec!["T".into()],
1831 dir: RelDir::Right,
1832 to: "b".into(),
1833 to_label: None,
1834 to_props: vec![],
1835 },
1836 PlanOp::Project {
1837 items: vec![
1838 RetItem {
1839 value: RetVal::Var("a".into()),
1840 alias: None,
1841 },
1842 RetItem {
1843 value: RetVal::Var("b".into()),
1844 alias: None,
1845 },
1846 ],
1847 },
1848 ]
1849 );
1850 }
1851
1852 #[test]
1853 fn bound_dest_extra_checks_ride_on_expand() {
1854 let got = plan_src("MATCH (t:Talent) MATCH (c)-[r]->(t:Talent {id: 1}) RETURN t").unwrap();
1855 assert_eq!(
1856 got,
1857 vec![
1858 PlanOp::ScanLabel {
1859 var: "t".into(),
1860 label: Some("Talent".into()),
1861 },
1862 PlanOp::JoinBound {
1863 var: "t".into(),
1864 label: Some("Talent".into()),
1865 props: vec![("id".into(), Operand::Lit(Value::Int(1)))],
1866 },
1867 PlanOp::Expand {
1868 from: "t".into(),
1869 rel_var: Some("r".into()),
1870 etypes: vec![],
1871 dir: RelDir::Left,
1872 to: "c".into(),
1873 to_label: None,
1874 to_props: vec![],
1875 },
1876 PlanOp::Project {
1877 items: vec![RetItem {
1878 value: RetVal::Var("t".into()),
1879 alias: None,
1880 }],
1881 },
1882 ]
1883 );
1884 }
1885
1886 #[test]
1887 fn return_distinct_emits_distinct_after_project() {
1888 let ops = plan_src("MATCH (n) RETURN DISTINCT n").expect("DISTINCT must plan");
1889 let proj = ops
1890 .iter()
1891 .position(|op| matches!(op, PlanOp::Project { .. }))
1892 .expect("Project");
1893 assert!(
1894 matches!(ops.get(proj + 1), Some(PlanOp::Distinct)),
1895 "DISTINCT must follow Project, got: {ops:?}"
1896 );
1897 let bounded = plan_src("MATCH (n) RETURN DISTINCT n LIMIT 1").unwrap();
1898 assert!(
1899 super::row_bound(&bounded).is_none(),
1900 "DISTINCT + LIMIT must not push LIMIT into producers"
1901 );
1902 }
1903
1904 #[test]
1905 fn skip_then_limit_follow_project() {
1906 let got = plan_src("MATCH (a) RETURN a SKIP 2 LIMIT 3").unwrap();
1907 assert_eq!(
1908 got,
1909 vec![
1910 PlanOp::ScanLabel {
1911 var: "a".into(),
1912 label: None,
1913 },
1914 PlanOp::Project {
1915 items: vec![RetItem {
1916 value: RetVal::Var("a".into()),
1917 alias: None,
1918 }],
1919 },
1920 PlanOp::Skip(LimitSkip::Exact(2)),
1921 PlanOp::Limit(LimitSkip::Exact(3)),
1922 ]
1923 );
1924 }
1925
1926 #[test]
1927 fn aliased_prop_order_by_rewrites_to_alias_column() {
1928 let got = plan_src("MATCH (a) RETURN a.name AS nm ORDER BY a.name").unwrap();
1929 let order = got
1930 .iter()
1931 .find_map(|op| match op {
1932 PlanOp::OrderBy { items } => Some(items),
1933 _ => None,
1934 })
1935 .unwrap();
1936 assert_eq!(
1937 order,
1938 &vec![OrderItem {
1939 target: OrderTarget::Alias("nm".into()),
1940 descending: false,
1941 }]
1942 );
1943 }
1944
1945 #[test]
1946 fn plan_never_panics_on_hand_built_query() {
1947 use crate::cypher::ast::{NodePat, Pattern, Query};
1948 let q = Query {
1949 matches: vec![],
1950 optional_clauses: vec![],
1951 where_expr: None,
1952 unwinds: vec![],
1953 post_unwind_where: None,
1954 stages: vec![],
1955 returns: vec![],
1956 order_by: vec![],
1957 distinct: false,
1958 skip: None,
1959 limit: None,
1960 };
1961 let result = std::panic::catch_unwind(|| plan(&q));
1962 assert!(result.is_ok(), "plan panicked on empty Query");
1963 let _ = result.unwrap();
1964
1965 let q = Query {
1966 matches: vec![Pattern {
1967 start: NodePat {
1968 var: None,
1969 label: None,
1970 props: vec![],
1971 },
1972 chain: vec![],
1973 shortest: false,
1974 }],
1975 optional_clauses: vec![],
1976 where_expr: Some(Expr::Not(Box::new(Expr::Cmp {
1977 lhs: Operand::Param("p".into()),
1978 op: CmpOp::Eq,
1979 rhs: Operand::Lit(Value::Int(1)),
1980 }))),
1981 unwinds: vec![],
1982 post_unwind_where: None,
1983 stages: vec![],
1984 returns: vec![],
1985 distinct: false,
1986 order_by: vec![OrderItem {
1987 target: OrderTarget::Alias("missing".into()),
1988 descending: true,
1989 }],
1990 skip: Some(LimitSkip::Exact(0)),
1991 limit: Some(LimitSkip::Exact(0)),
1992 };
1993 let result = std::panic::catch_unwind(|| plan(&q));
1994 assert!(result.is_ok(), "plan panicked on hand-built Query");
1995 let _ = result.unwrap();
1996 }
1997
1998 #[test]
1999 fn bare_relationship_var_in_return_is_err() {
2000 let err = assert_plan_err("MATCH (a)-[r:T]->(b) RETURN r", "r");
2001 assert!(
2002 err.to_ascii_lowercase().contains("relationship"),
2003 "expected bare-rel RETURN guidance, got: {err}"
2004 );
2005 }
2006
2007 #[test]
2008 fn relationship_prop_in_return_is_ok() {
2009 plan_src("MATCH (a)-[r:T]->(b) RETURN r.w").expect("rel prop RETURN must plan");
2010 }
2011
2012 #[test]
2013 fn bare_relationship_var_in_order_by_is_err() {
2014 let err = assert_plan_err("MATCH (a)-[r:T]->(b) RETURN r.w ORDER BY r", "r");
2017 assert!(
2018 err.to_ascii_lowercase().contains("relationship"),
2019 "expected bare-rel ORDER BY guidance, got: {err}"
2020 );
2021 }
2022
2023 #[test]
2024 fn relationship_prop_in_order_by_is_ok() {
2025 plan_src("MATCH (a)-[r:T]->(b) RETURN r.w ORDER BY r.w")
2026 .expect("rel prop ORDER BY must plan");
2027 }
2028
2029 #[test]
2032 fn var_expand_op_emitted_for_star_rel() {
2033 use super::row_bound;
2034 let ops = plan_src("MATCH (a)-[r:T*2..4]->(b) RETURN b").unwrap();
2035 let has_var = ops
2036 .iter()
2037 .any(|op| matches!(op, PlanOp::VarExpand { min: 2, max: 4, .. }));
2038 assert!(has_var, "expected VarExpand(2..4) in plan, got: {ops:?}");
2039 assert_eq!(
2041 row_bound(&ops),
2042 None,
2043 "VarExpand plan must not use pull path"
2044 );
2045 }
2046
2047 #[test]
2048 fn var_expand_with_limit_still_takes_staged_path() {
2049 use super::row_bound;
2050 let ops = plan_src("MATCH (a)-[r:T*1..3]->(b) RETURN b LIMIT 5").unwrap();
2051 assert_eq!(
2053 row_bound(&ops),
2054 None,
2055 "VarExpand + LIMIT must still use staged path"
2056 );
2057 let has_var = ops.iter().any(|op| matches!(op, PlanOp::VarExpand { .. }));
2058 assert!(has_var, "plan must contain VarExpand");
2059 let has_limit = ops
2060 .iter()
2061 .any(|op| matches!(op, PlanOp::Limit(LimitSkip::Exact(5))));
2062 assert!(has_limit, "plan must still emit Limit op");
2063 }
2064
2065 #[test]
2066 fn shortest_path_op_emitted_for_shortest_path_clause() {
2067 let ops =
2068 plan_src("MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*..3]->(b)) RETURN a")
2069 .unwrap();
2070 let has_sp = ops
2071 .iter()
2072 .any(|op| matches!(op, PlanOp::ShortestPath { max_hops: 3, .. }));
2073 assert!(
2074 has_sp,
2075 "expected ShortestPath op with max_hops=3, got: {ops:?}"
2076 );
2077 }
2078
2079 #[test]
2080 fn shortest_path_unbound_endpoint_is_err() {
2081 let err = assert_plan_err(
2082 "MATCH shortestPath((a)-[r:T*..3]->(b)) RETURN a",
2083 "shortestPath",
2084 );
2085 assert!(
2086 err.contains("not bound") || err.contains("bound"),
2087 "error must mention binding, got: {err}"
2088 );
2089 }
2090
2091 #[test]
2092 fn var_expand_rel_var_is_in_rel_bound() {
2093 plan_src("MATCH (a)-[r:T*1..3]->(b) RETURN r.length").expect("r.length must plan");
2095 assert_plan_err("MATCH (a)-[r:T*1..3]->(b) RETURN r", "r");
2097 }
2098
2099 #[test]
2100 fn shortest_path_min_gt_1_is_plan_err() {
2101 let err = assert_plan_err(
2103 "MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*2..5]->(b)) RETURN r.length",
2104 "shortestPath",
2105 );
2106 assert!(
2107 err.contains("minimum"),
2108 "error must mention minimum hop count, got: {err}"
2109 );
2110 }
2111
2112 fn subscribable(src: &str) -> bool {
2115 let ops = plan_src(src).expect("must plan");
2116 super::is_subscribable(&ops)
2117 }
2118
2119 #[test]
2120 fn is_subscribable_passes_simple_label_scan() {
2121 assert!(subscribable("MATCH (n:Person) RETURN n"));
2122 assert!(subscribable("MATCH (n:Person) WHERE n.age > 18 RETURN n"));
2123 assert!(subscribable("MATCH (n:Person) RETURN n LIMIT 100"));
2124 }
2125
2126 #[test]
2127 fn is_subscribable_passes_single_hop_expand() {
2128 assert!(subscribable(
2129 "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a"
2130 ));
2131 assert!(subscribable(
2132 "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a LIMIT 50"
2133 ));
2134 }
2135
2136 #[test]
2137 fn is_subscribable_rejects_multi_hop_expand() {
2138 assert!(
2140 !subscribable("MATCH (a:Person)-[r1:KNOWS]->(b:Person)-[r2:LIKES]->(c:Thing) RETURN a"),
2141 "two-hop chain must be rejected"
2142 );
2143 }
2144
2145 #[test]
2146 fn is_subscribable_rejects_skip() {
2147 assert!(
2149 !subscribable("MATCH (n:Person) RETURN n SKIP 10 LIMIT 50"),
2150 "SKIP must be rejected"
2151 );
2152 assert!(
2153 !subscribable("MATCH (n:Person) RETURN n SKIP 10"),
2154 "bare SKIP must be rejected"
2155 );
2156 }
2157
2158 #[test]
2159 fn is_subscribable_rejects_order_by() {
2160 assert!(!subscribable("MATCH (n:Person) RETURN n ORDER BY n"));
2161 }
2162
2163 #[test]
2164 fn is_subscribable_rejects_aggregates() {
2165 assert!(!subscribable("MATCH (n:Person) RETURN COUNT(*)"));
2166 }
2167
2168 #[test]
2169 fn is_subscribable_rejects_var_expand() {
2170 assert!(!subscribable(
2171 "MATCH (a:Person)-[r:KNOWS*1..3]->(b) RETURN b"
2172 ));
2173 }
2174
2175 #[test]
2178 fn where_equality_folds_to_index_scan() {
2179 let ops = plan_src("MATCH (n:Person) WHERE n.city = 'austin' RETURN n.key").unwrap();
2180 assert!(
2181 matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2182 "WHERE single equality must fold to IndexScan, got {:?}",
2183 ops[0]
2184 );
2185 assert!(
2186 !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2187 "consumed predicate must not remain as Filter"
2188 );
2189 }
2190
2191 #[test]
2192 fn where_equality_param_folds_to_index_scan() {
2193 let ops = plan_src("MATCH (n:Person) WHERE n.city = $c RETURN n.key").unwrap();
2194 assert!(
2195 matches!(&ops[0], PlanOp::IndexScan { .. }),
2196 "param WHERE equality must fold to IndexScan, got {:?}",
2197 ops[0]
2198 );
2199 }
2200
2201 #[test]
2202 fn where_and_keeps_residual_filter() {
2203 let ops = plan_src("MATCH (n:Person) WHERE n.city = 'austin' AND n.age > 30 RETURN n.key")
2204 .unwrap();
2205 assert!(
2206 matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2207 "equality must fold to IndexScan, got {:?}",
2208 ops[0]
2209 );
2210 assert!(
2211 ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2212 "n.age > 30 must remain as residual Filter"
2213 );
2214 }
2215
2216 #[test]
2217 fn where_on_expanded_var_does_not_fold() {
2218 let ops =
2219 plan_src("MATCH (a:Person)-[:KNOWS]->(b:Person) WHERE b.city = 'austin' RETURN a.key")
2220 .unwrap();
2221 assert!(
2222 matches!(&ops[0], PlanOp::ScanLabel { .. } | PlanOp::IndexScan { .. }),
2223 "first op must be a scan, got {:?}",
2224 ops[0]
2225 );
2226 assert!(
2227 ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2228 "b.city filter must remain"
2229 );
2230 }
2231
2232 #[test]
2233 fn where_inline_prop_and_where_equality_both_usable() {
2234 let ops = plan_src("MATCH (n:Person {team: 'core'}) WHERE n.city = 'austin' RETURN n.key")
2236 .unwrap();
2237 assert!(
2238 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2239 "inline+WHERE equalities must merge to IndexIntersect(2), got {:?}",
2240 ops[0]
2241 );
2242 assert!(
2243 !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2244 "both equalities fully folded; no residual Filter expected"
2245 );
2246 }
2247
2248 #[test]
2251 fn single_equality_inline_stays_index_scan() {
2252 let ops = plan_src("MATCH (n:Person {city: 'austin'}) RETURN n").unwrap();
2254 assert!(
2255 matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2256 "single-equality inline prop must emit IndexScan, got {:?}",
2257 ops[0]
2258 );
2259 }
2260
2261 #[test]
2262 fn compound_inline_props_emit_index_intersect() {
2263 let ops = plan_src("MATCH (n:Doc {namespace: 'a', status: 'live'}) RETURN n.key").unwrap();
2264 assert!(
2265 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2266 "two inline props must emit IndexIntersect(2), got {:?}",
2267 ops[0]
2268 );
2269 }
2270
2271 #[test]
2272 fn where_two_equalities_emit_index_intersect() {
2273 let ops = plan_src("MATCH (n:Doc) WHERE n.namespace = 'a' AND n.status = $s RETURN n.key")
2274 .unwrap();
2275 assert!(
2276 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2277 "two WHERE equalities must emit IndexIntersect(2), got {:?}",
2278 ops[0]
2279 );
2280 assert!(
2281 !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2282 "both equalities fully consumed; no residual Filter expected"
2283 );
2284 }
2285
2286 #[test]
2287 fn mixed_inline_and_where_equalities_merge() {
2288 let ops = plan_src("MATCH (n:Doc {namespace: 'a'}) WHERE n.status = 'live' RETURN n.key")
2289 .unwrap();
2290 assert!(
2291 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2292 "inline+WHERE equalities must merge to IndexIntersect(2), got {:?}",
2293 ops[0]
2294 );
2295 }
2296
2297 #[test]
2298 fn where_three_equalities_emit_index_intersect() {
2299 let ops = plan_src(
2300 "MATCH (n:Doc) WHERE n.namespace = 'a' AND n.status = 'live' AND n.kind = $k RETURN n",
2301 )
2302 .unwrap();
2303 assert!(
2304 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 3),
2305 "three WHERE equalities must emit IndexIntersect(3), got {:?}",
2306 ops[0]
2307 );
2308 assert!(
2309 !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2310 "all three equalities fully consumed; no residual Filter expected"
2311 );
2312 }
2313}