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);
867 if let Some((idx, form, value)) = find_identity_eq(&terms, &scan_var) {
868 terms.remove(idx);
869 if let Some((f, v)) = existing_eq {
870 terms.push(Expr::Cmp {
871 lhs: Operand::Prop {
872 var: scan_var.clone(),
873 field: f,
874 },
875 op: CmpOp::Eq,
876 rhs: v,
877 });
878 }
879 ops[scan_pos] = match form {
880 IdentityEqForm::Prop(field) => PlanOp::IndexScan {
883 var: scan_var,
884 label: scan_label,
885 field,
886 value,
887 },
888 IdentityEqForm::Func => PlanOp::ScanKey {
891 var: scan_var,
892 key: value,
893 label: scan_label,
894 },
895 };
896 match join_and(terms) {
897 Some(residual) => ops[filter_pos] = PlanOp::Filter { expr: residual },
898 None => {
899 ops.remove(filter_pos);
900 }
901 }
902 return ops;
903 }
904
905 let mut extracted: Vec<(String, Operand)> = Vec::new();
907 let mut i = 0;
908 while i < terms.len() {
909 if matches!(
910 &terms[i],
911 Expr::Cmp {
912 lhs: Operand::Prop { var, .. },
913 op: CmpOp::Eq,
914 rhs: Operand::Lit(_) | Operand::Param(_),
915 } if var == &scan_var
916 ) {
917 let term = terms.remove(i);
918 match term {
919 Expr::Cmp {
920 lhs: Operand::Prop { field, .. },
921 rhs,
922 ..
923 } => extracted.push((field, rhs)),
924 _ => unreachable!(),
925 }
926 } else {
927 i += 1;
928 }
929 }
930
931 if extracted.is_empty() {
932 return ops;
933 }
934
935 let mut all_equalities: Vec<(String, Operand)> = Vec::new();
937 if let Some(eq) = existing_eq {
938 all_equalities.push(eq);
939 }
940 all_equalities.extend(extracted);
941
942 ops[scan_pos] = if all_equalities.len() == 1 {
944 let (field, value) = all_equalities.remove(0);
945 PlanOp::IndexScan {
946 var: scan_var,
947 label: scan_label,
948 field,
949 value,
950 }
951 } else {
952 PlanOp::IndexIntersect {
953 var: scan_var,
954 label: scan_label,
955 equalities: all_equalities,
956 }
957 };
958
959 match join_and(terms) {
961 Some(residual) => ops[filter_pos] = PlanOp::Filter { expr: residual },
962 None => {
963 ops.remove(filter_pos);
964 }
965 }
966
967 ops
968}
969
970fn is_identity_eq_field(field: &str) -> bool {
973 field == "key" || field == "id"
974}
975
976fn identity_func_field(name: &str) -> Option<&'static str> {
977 match name.to_ascii_lowercase().as_str() {
978 "key" => Some("key"),
979 "id" => Some("id"),
980 _ => None,
981 }
982}
983
984enum IdentityEqForm {
993 Prop(String),
995 Func,
997}
998
999fn identity_eq_term(term: &Expr, scan_var: &str) -> Option<(IdentityEqForm, Operand)> {
1000 let Expr::Cmp {
1001 lhs,
1002 op: CmpOp::Eq,
1003 rhs,
1004 } = term
1005 else {
1006 return None;
1007 };
1008 if !matches!(rhs, Operand::Lit(_) | Operand::Param(_)) {
1009 return None;
1010 }
1011 match lhs {
1012 Operand::Prop { var, field } if var == scan_var && is_identity_eq_field(field) => {
1013 Some((IdentityEqForm::Prop(field.clone()), rhs.clone()))
1014 }
1015 Operand::FuncCall { name, args } if args.len() == 1 => {
1016 let Operand::Var(v) = &args[0] else {
1017 return None;
1018 };
1019 if v != scan_var {
1020 return None;
1021 }
1022 identity_func_field(name)?;
1023 Some((IdentityEqForm::Func, rhs.clone()))
1024 }
1025 _ => None,
1026 }
1027}
1028
1029fn find_identity_eq(terms: &[Expr], scan_var: &str) -> Option<(usize, IdentityEqForm, Operand)> {
1030 for (i, term) in terms.iter().enumerate() {
1031 if let Some((form, value)) = identity_eq_term(term, scan_var) {
1032 return Some((i, form, value));
1033 }
1034 }
1035 None
1036}
1037
1038fn invert_dir(d: RelDir) -> RelDir {
1039 match d {
1040 RelDir::Right => RelDir::Left,
1041 RelDir::Left => RelDir::Right,
1042 RelDir::Undirected => RelDir::Undirected,
1043 }
1044}
1045
1046fn compile_pattern(
1047 pat: &Pattern,
1048 ops: &mut Vec<PlanOp>,
1049 bound: &mut BTreeSet<String>,
1050 rel_bound: &mut BTreeSet<String>,
1051 node_anon: &mut u32,
1052 rel_anon: &mut u32,
1053) -> Result<(), String> {
1054 let start = name_node(&pat.start, node_anon, bound);
1055 if pat.shortest {
1056 if !bound.contains(&start) {
1058 return Err(format!(
1059 "shortestPath: source node `{start}` is not bound; \
1060 bind both endpoints before shortestPath"
1061 ));
1062 }
1063 ops.push(PlanOp::JoinBound {
1064 var: start.clone(),
1065 label: pat.start.label.clone(),
1066 props: pat.start.props.clone(),
1067 });
1068 } else if bound.contains(&start) {
1069 ops.push(PlanOp::JoinBound {
1070 var: start.clone(),
1071 label: pat.start.label.clone(),
1072 props: pat.start.props.clone(),
1073 });
1074 } else if pat.chain.len() == 1
1075 && pat.chain[0].0.hops.is_none()
1076 && pat.chain[0]
1077 .1
1078 .var
1079 .as_ref()
1080 .is_some_and(|v| bound.contains(v))
1081 {
1082 let (rel, dest) = &pat.chain[0];
1087 let dest_name = name_node(dest, node_anon, bound);
1088 let rel_name = name_rel(rel, rel_anon, bound);
1089 bound.insert(rel_name.clone());
1090 rel_bound.insert(rel_name.clone());
1091 if dest.label.is_some() || !dest.props.is_empty() {
1092 ops.push(PlanOp::JoinBound {
1093 var: dest_name.clone(),
1094 label: dest.label.clone(),
1095 props: dest.props.clone(),
1096 });
1097 }
1098 ops.push(PlanOp::Expand {
1099 from: dest_name,
1100 rel_var: Some(rel_name),
1101 etypes: rel.etypes.clone(),
1102 dir: invert_dir(rel.dir),
1103 to: start.clone(),
1104 to_label: pat.start.label.clone(),
1105 to_props: pat.start.props.clone(),
1106 });
1107 bound.insert(start);
1108 return Ok(());
1109 } else if let Some(key) = id_lookup(&pat.start.props) {
1110 ops.push(PlanOp::ScanKey {
1111 var: start.clone(),
1112 key: key.clone(),
1113 label: pat.start.label.clone(),
1114 });
1115 bound.insert(start.clone());
1116 } else if let Some((field, value)) = index_lookup(&pat.start.props) {
1117 ops.push(PlanOp::IndexScan {
1118 var: start.clone(),
1119 label: pat.start.label.clone(),
1120 field: field.to_string(),
1121 value: value.clone(),
1122 });
1123 bound.insert(start.clone());
1124 } else if let Some(equalities) = multi_index_lookup(&pat.start.props) {
1125 ops.push(PlanOp::IndexIntersect {
1126 var: start.clone(),
1127 label: pat.start.label.clone(),
1128 equalities,
1129 });
1130 bound.insert(start.clone());
1131 } else {
1132 ops.push(PlanOp::ScanLabel {
1133 var: start.clone(),
1134 label: pat.start.label.clone(),
1135 });
1136 if !pat.start.props.is_empty() {
1137 ops.push(PlanOp::LookupProps {
1138 var: start.clone(),
1139 props: pat.start.props.clone(),
1140 });
1141 }
1142 bound.insert(start.clone());
1143 }
1144
1145 let mut from = start;
1146 for (rel, dest) in &pat.chain {
1147 let rel_name = name_rel(rel, rel_anon, bound);
1148 bound.insert(rel_name.clone());
1149 rel_bound.insert(rel_name.clone());
1150 let to = name_node(dest, node_anon, bound);
1151
1152 if let Some(hops) = rel.hops {
1153 if pat.shortest {
1154 if !bound.contains(&to) {
1156 return Err(format!(
1157 "shortestPath: destination node `{to}` is not bound; \
1158 bind both endpoints before shortestPath"
1159 ));
1160 }
1161 if hops.min > 1 {
1165 return Err(format!(
1166 "shortestPath does not support a minimum hop count \
1167 (got min={}); use a plain variable-length pattern \
1168 if you need a minimum",
1169 hops.min
1170 ));
1171 }
1172 ops.push(PlanOp::ShortestPath {
1173 from: from.clone(),
1174 rel_var: Some(rel_name),
1175 etypes: rel.etypes.clone(),
1176 dir: rel.dir,
1177 to: to.clone(),
1178 max_hops: hops.max,
1179 });
1180 } else {
1181 ops.push(PlanOp::VarExpand {
1182 from: from.clone(),
1183 rel_var: Some(rel_name),
1184 etypes: rel.etypes.clone(),
1185 dir: rel.dir,
1186 to: to.clone(),
1187 min: hops.min,
1188 max: hops.max,
1189 });
1190 bound.insert(to.clone());
1191 }
1192 } else {
1193 ops.push(PlanOp::Expand {
1194 from: from.clone(),
1195 rel_var: Some(rel_name),
1196 etypes: rel.etypes.clone(),
1197 dir: rel.dir,
1198 to: to.clone(),
1199 to_label: dest.label.clone(),
1200 to_props: dest.props.clone(),
1201 });
1202 bound.insert(to.clone());
1203 }
1204 from = to;
1205 }
1206 Ok(())
1207}
1208
1209fn compile_optional_clause(
1216 oc: &OptionalClause,
1217 ops: &mut Vec<PlanOp>,
1218 bound: &mut BTreeSet<String>,
1219 rel_bound: &mut BTreeSet<String>,
1220 node_anon: &mut u32,
1221 rel_anon: &mut u32,
1222) -> Result<(), String> {
1223 let mut inner_bound = bound.clone();
1225 let mut inner_rel_bound = rel_bound.clone();
1226 let mut inner_ops: Vec<PlanOp> = Vec::new();
1227
1228 for pat in &oc.patterns {
1229 compile_pattern(
1230 pat,
1231 &mut inner_ops,
1232 &mut inner_bound,
1233 &mut inner_rel_bound,
1234 node_anon,
1235 rel_anon,
1236 )?;
1237 }
1238 if let Some(expr) = &oc.where_expr {
1239 check_expr_bound(expr, &inner_bound)?;
1240 inner_ops.push(PlanOp::Filter { expr: expr.clone() });
1241 }
1242
1243 let optional_vars: Vec<String> = inner_bound
1245 .difference(bound)
1246 .chain(inner_rel_bound.difference(rel_bound))
1247 .cloned()
1248 .collect();
1249
1250 for v in &optional_vars {
1253 bound.insert(v.clone());
1254 }
1255 for v in inner_rel_bound
1256 .difference(&*rel_bound)
1257 .cloned()
1258 .collect::<Vec<_>>()
1259 {
1260 rel_bound.insert(v);
1261 }
1262
1263 ops.push(PlanOp::LeftOuterApply {
1264 inner: inner_ops,
1265 optional_vars,
1266 });
1267 Ok(())
1268}
1269
1270fn name_node(node: &NodePat, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1271 match &node.var {
1272 Some(v) => v.clone(),
1273 None => fresh("_n", counter, bound),
1274 }
1275}
1276
1277fn name_rel(rel: &RelPat, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1278 match &rel.var {
1279 Some(v) => v.clone(),
1280 None => fresh("_r", counter, bound),
1281 }
1282}
1283
1284fn fresh(prefix: &str, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1287 for _ in 0..=u32::MAX {
1288 let name = format!("{prefix}{counter}");
1289 *counter = counter.wrapping_add(1);
1290 if !bound.contains(&name) {
1291 return name;
1292 }
1293 }
1294 format!("{prefix}x")
1295}
1296
1297fn check_expr_bound(expr: &Expr, bound: &BTreeSet<String>) -> Result<(), String> {
1298 match expr {
1299 Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => {
1300 check_expr_bound(lhs, bound)?;
1301 check_expr_bound(rhs, bound)
1302 }
1303 Expr::Not(inner) => check_expr_bound(inner, bound),
1304 Expr::Cmp { lhs, rhs, .. } => {
1305 check_operand_bound(lhs, bound, "WHERE")?;
1306 check_operand_bound(rhs, bound, "WHERE")
1307 }
1308 Expr::Truthy(op) => check_operand_bound(op, bound, "WHERE"),
1309 Expr::IsNull(op) | Expr::IsNotNull(op) => check_operand_bound(op, bound, "WHERE"),
1310 Expr::In { expr, list } => {
1311 check_operand_bound(expr, bound, "WHERE")?;
1312 for item in list {
1313 check_operand_bound(item, bound, "WHERE")?;
1314 }
1315 Ok(())
1316 }
1317 }
1318}
1319
1320fn check_operand_bound(
1321 operand: &Operand,
1322 bound: &BTreeSet<String>,
1323 clause: &str,
1324) -> Result<(), String> {
1325 match operand {
1326 Operand::Prop { var, .. } => require_bound(var, bound, clause),
1327 Operand::Lit(_) | Operand::Param(_) => Ok(()),
1328 Operand::Var(name) => require_bound(name, bound, clause),
1329 Operand::BinArith { left, right, .. } => {
1330 check_operand_bound(left, bound, clause)?;
1331 check_operand_bound(right, bound, clause)
1332 }
1333 Operand::Index { base, index } => {
1334 check_operand_bound(base, bound, clause)?;
1335 check_operand_bound(index, bound, clause)
1336 }
1337 Operand::FuncCall { args, .. } => {
1338 for arg in args {
1339 check_operand_bound(arg, bound, clause)?;
1340 }
1341 Ok(())
1342 }
1343 Operand::Case { branches, default } => {
1344 for (cond, value) in branches {
1345 check_expr_bound(cond, bound)?;
1346 check_operand_bound(value, bound, clause)?;
1347 }
1348 if let Some(d) = default {
1349 check_operand_bound(d, bound, clause)?;
1350 }
1351 Ok(())
1352 }
1353 }
1354}
1355
1356fn check_unwind_bound(expr: &UnwindExpr, bound: &BTreeSet<String>) -> Result<(), String> {
1358 match expr {
1359 UnwindExpr::Lit(_) => Ok(()),
1360 UnwindExpr::Prop { var, .. } => require_bound(var, bound, "UNWIND"),
1361 UnwindExpr::Var(name) => require_bound(name, bound, "UNWIND"),
1362 }
1363}
1364
1365fn require_bound(var: &str, bound: &BTreeSet<String>, clause: &str) -> Result<(), String> {
1366 if bound.contains(var) {
1367 Ok(())
1368 } else {
1369 Err(format!("unbound variable `{var}` in {clause}"))
1370 }
1371}
1372
1373fn reject_bare_rel(var: &str, rel_bound: &BTreeSet<String>) -> Result<(), String> {
1374 if rel_bound.contains(var) {
1375 Err(format!(
1376 "cannot return relationship variable '{var}' bare; return its properties ({var}.field) instead"
1377 ))
1378 } else {
1379 Ok(())
1380 }
1381}
1382
1383fn check_return_bound(
1384 items: &[RetItem],
1385 bound: &BTreeSet<String>,
1386 rel_bound: &BTreeSet<String>,
1387) -> Result<(), String> {
1388 for item in items {
1389 match &item.value {
1390 RetVal::Var(v) => {
1391 require_bound(v, bound, "RETURN")?;
1392 reject_bare_rel(v, rel_bound)?;
1393 }
1394 RetVal::Prop { var, .. } => {
1395 require_bound(var, bound, "RETURN")?;
1396 }
1397 RetVal::Agg { arg, .. } => check_agg_arg_bound(arg, bound)?,
1398 RetVal::FuncCall { args, .. } => {
1399 for arg in args {
1400 check_operand_bound(arg, bound, "RETURN")?;
1401 }
1402 }
1403 RetVal::ScalarExpr(op) => {
1404 check_operand_bound(op, bound, "RETURN")?;
1405 }
1406 }
1407 }
1408 Ok(())
1409}
1410
1411fn check_duplicate_aliases(items: &[RetItem]) -> Result<(), String> {
1412 let mut seen = BTreeSet::new();
1413 for item in items {
1414 if let Some(alias) = &item.alias {
1415 if !seen.insert(alias.clone()) {
1416 return Err(format!("duplicate RETURN alias `{alias}`"));
1417 }
1418 }
1419 }
1420 Ok(())
1421}
1422
1423fn check_duplicate_columns(items: &[RetItem]) -> Result<(), String> {
1424 let mut seen = BTreeSet::new();
1425 for item in items {
1426 let col = column_name(item);
1427 if !seen.insert(col.clone()) {
1428 return Err(format!("duplicate RETURN column `{col}`"));
1429 }
1430 }
1431 Ok(())
1432}
1433
1434fn column_name(item: &RetItem) -> String {
1437 if let Some(alias) = &item.alias {
1438 return alias.clone();
1439 }
1440 ret_val_label(&item.value).unwrap_or_else(|| match &item.value {
1441 RetVal::Agg { func, arg } => agg_column_name(func, arg),
1442 _ => unreachable!("ret_val_label names every non-aggregate item"),
1443 })
1444}
1445
1446fn check_agg_arg_bound(arg: &AggArg, bound: &BTreeSet<String>) -> Result<(), String> {
1449 match arg {
1450 AggArg::Star => Ok(()),
1451 AggArg::Var(v) => require_bound(v, bound, "RETURN"),
1452 AggArg::Prop { var, .. } => require_bound(var, bound, "RETURN"),
1453 AggArg::Distinct(inner) => check_agg_arg_bound(inner, bound),
1454 }
1455}
1456
1457fn agg_column_name(func: &AggFunc, arg: &AggArg) -> String {
1460 let f = func_name(func);
1461 format!("{f}({})", agg_arg_name(arg))
1462}
1463
1464fn agg_arg_name(arg: &AggArg) -> String {
1465 match arg {
1466 AggArg::Star => "*".to_string(),
1467 AggArg::Var(v) => v.clone(),
1468 AggArg::Prop { var, field } => format!("{var}.{field}"),
1469 AggArg::Distinct(inner) => format!("DISTINCT {}", agg_arg_name(inner)),
1470 }
1471}
1472
1473fn func_name(func: &AggFunc) -> &'static str {
1474 match func {
1475 AggFunc::Count => "COUNT",
1476 AggFunc::Sum => "SUM",
1477 AggFunc::Avg => "AVG",
1478 AggFunc::Min => "MIN",
1479 AggFunc::Max => "MAX",
1480 AggFunc::Collect => "COLLECT",
1481 }
1482}
1483
1484fn rewrite_order_item(
1485 item: &OrderItem,
1486 returns: &[RetItem],
1487 bound: &BTreeSet<String>,
1488 rel_bound: &BTreeSet<String>,
1489) -> Result<OrderItem, String> {
1490 let column = match &item.target {
1491 OrderTarget::Alias(name) => {
1492 if returns
1493 .iter()
1494 .any(|r| r.alias.as_deref() == Some(name.as_str()))
1495 {
1496 name.clone()
1497 } else {
1498 return Err(format!("ORDER BY target `{name}` is not present in RETURN"));
1499 }
1500 }
1501 OrderTarget::Var(v) => {
1502 require_bound(v, bound, "ORDER BY")?;
1503 reject_bare_rel(v, rel_bound)?;
1504 match returns
1505 .iter()
1506 .find(|r| matches!(&r.value, RetVal::Var(x) if x == v))
1507 {
1508 Some(r) => column_name(r),
1509 None => {
1510 return Err(format!("ORDER BY target `{v}` is not present in RETURN"));
1511 }
1512 }
1513 }
1514 OrderTarget::Prop { var, field } => {
1515 require_bound(var, bound, "ORDER BY")?;
1516 match returns.iter().find(
1517 |r| matches!(&r.value, RetVal::Prop { var: v, field: f } if v == var && f == field),
1518 ) {
1519 Some(r) => column_name(r),
1520 None => {
1521 return Err(format!(
1522 "ORDER BY target `{var}.{field}` is not present in RETURN"
1523 ));
1524 }
1525 }
1526 }
1527 };
1528 Ok(OrderItem {
1529 target: OrderTarget::Alias(column),
1530 descending: item.descending,
1531 })
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536 use super::{plan, PlanOp};
1537 use crate::cypher::ast::{Expr, LimitSkip, Operand, OrderItem, OrderTarget, RetItem, RetVal};
1538 use crate::cypher::{lex, parse, RelDir};
1539 use crate::filter::CmpOp;
1540 use core_storage::Value;
1541
1542 fn plan_src(src: &str) -> Result<Vec<PlanOp>, String> {
1543 plan(&parse(&lex(src)?)?)
1544 }
1545
1546 fn assert_plan_err(src: &str, needle: &str) -> String {
1547 let result = std::panic::catch_unwind(|| plan_src(src));
1548 assert!(result.is_ok(), "plan({src:?}) panicked");
1549 let err = result
1550 .unwrap()
1551 .expect_err(&format!("plan({src:?}) must be Err"));
1552 assert!(
1553 err.contains(needle),
1554 "error must mention {needle:?}, got: {err}"
1555 );
1556 err
1557 }
1558
1559 #[test]
1565 fn dogfood_query_exact_plan() {
1566 let src = "\
1567MATCH (t:Talent {id: $tid}) \
1568MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
1569MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
1570WHERE i.score >= 0.5 AND s.score >= 0.5 \
1571RETURN c, i.score AS industry, s.score AS specialty \
1572ORDER BY industry DESC, specialty DESC \
1573LIMIT 10";
1574 let got = plan_src(src).expect("dogfood query must plan");
1575 let expected = vec![
1576 PlanOp::ScanKey {
1577 var: "t".into(),
1578 key: Operand::Param("tid".into()),
1579 label: Some("Talent".into()),
1580 },
1581 PlanOp::Expand {
1582 from: "t".into(),
1583 rel_var: Some("i".into()),
1584 etypes: vec!["INDUSTRY_ALIGNMENT".into()],
1585 dir: RelDir::Left,
1586 to: "c".into(),
1587 to_label: Some("Company".into()),
1588 to_props: vec![],
1589 },
1590 PlanOp::JoinBound {
1591 var: "c".into(),
1592 label: None,
1593 props: vec![],
1594 },
1595 PlanOp::Expand {
1596 from: "c".into(),
1597 rel_var: Some("s".into()),
1598 etypes: vec!["SPECIALTY_MATCH".into()],
1599 dir: RelDir::Right,
1600 to: "t".into(),
1601 to_label: None,
1602 to_props: vec![],
1603 },
1604 PlanOp::Filter {
1605 expr: Expr::And(
1606 Box::new(Expr::Cmp {
1607 lhs: Operand::Prop {
1608 var: "i".into(),
1609 field: "score".into(),
1610 },
1611 op: CmpOp::Ge,
1612 rhs: Operand::Lit(Value::Float(0.5)),
1613 }),
1614 Box::new(Expr::Cmp {
1615 lhs: Operand::Prop {
1616 var: "s".into(),
1617 field: "score".into(),
1618 },
1619 op: CmpOp::Ge,
1620 rhs: Operand::Lit(Value::Float(0.5)),
1621 }),
1622 ),
1623 },
1624 PlanOp::Project {
1625 items: vec![
1626 RetItem {
1627 value: RetVal::Var("c".into()),
1628 alias: None,
1629 },
1630 RetItem {
1631 value: RetVal::Prop {
1632 var: "i".into(),
1633 field: "score".into(),
1634 },
1635 alias: Some("industry".into()),
1636 },
1637 RetItem {
1638 value: RetVal::Prop {
1639 var: "s".into(),
1640 field: "score".into(),
1641 },
1642 alias: Some("specialty".into()),
1643 },
1644 ],
1645 },
1646 PlanOp::OrderBy {
1647 items: vec![
1648 OrderItem {
1649 target: OrderTarget::Alias("industry".into()),
1650 descending: true,
1651 },
1652 OrderItem {
1653 target: OrderTarget::Alias("specialty".into()),
1654 descending: true,
1655 },
1656 ],
1657 },
1658 PlanOp::Limit(LimitSkip::Exact(10)),
1659 ];
1660 assert_eq!(got, expected);
1661 }
1662
1663 #[test]
1668 fn anonymous_node_and_rel_names_are_stable() {
1669 let got = plan_src("MATCH ()-[]->(a) MATCH ()-[]->(a) RETURN a").unwrap();
1670 assert_eq!(
1671 got,
1672 vec![
1673 PlanOp::ScanLabel {
1674 var: "_n0".into(),
1675 label: None,
1676 },
1677 PlanOp::Expand {
1678 from: "_n0".into(),
1679 rel_var: Some("_r0".into()),
1680 etypes: vec![],
1681 dir: RelDir::Right,
1682 to: "a".into(),
1683 to_label: None,
1684 to_props: vec![],
1685 },
1686 PlanOp::Expand {
1687 from: "a".into(),
1688 rel_var: Some("_r1".into()),
1689 etypes: vec![],
1690 dir: RelDir::Left,
1691 to: "_n1".into(),
1692 to_label: None,
1693 to_props: vec![],
1694 },
1695 PlanOp::Project {
1696 items: vec![RetItem {
1697 value: RetVal::Var("a".into()),
1698 alias: None,
1699 }],
1700 },
1701 ]
1702 );
1703 }
1704
1705 #[test]
1706 fn props_on_scan_node_emit_scan_then_lookup() {
1707 let got = plan_src("MATCH (t:Talent {id: $tid}) RETURN t").unwrap();
1708 assert_eq!(
1709 got,
1710 vec![
1711 PlanOp::ScanKey {
1712 var: "t".into(),
1713 key: Operand::Param("tid".into()),
1714 label: Some("Talent".into()),
1715 },
1716 PlanOp::Project {
1717 items: vec![RetItem {
1718 value: RetVal::Var("t".into()),
1719 alias: None,
1720 }],
1721 },
1722 ]
1723 );
1724 }
1725
1726 #[test]
1727 fn mixed_id_map_stays_scan_label_then_lookup() {
1728 let got = plan_src("MATCH (t:Talent {id: $k, name: 'x'}) RETURN t").unwrap();
1729 assert_eq!(
1730 got,
1731 vec![
1732 PlanOp::ScanLabel {
1733 var: "t".into(),
1734 label: Some("Talent".into()),
1735 },
1736 PlanOp::LookupProps {
1737 var: "t".into(),
1738 props: vec![
1739 ("id".into(), Operand::Param("k".into())),
1740 ("name".into(), Operand::Lit(Value::Str("x".into()))),
1741 ],
1742 },
1743 PlanOp::Project {
1744 items: vec![RetItem {
1745 value: RetVal::Var("t".into()),
1746 alias: None,
1747 }],
1748 },
1749 ]
1750 );
1751 }
1752
1753 #[test]
1754 fn plan_id_map_is_scan_key() {
1755 let toks = crate::cypher::lex("MATCH (n:Person {id: $k}) RETURN n").unwrap();
1756 let q = crate::cypher::parse(&toks).unwrap();
1757 let ops = plan(&q).unwrap();
1758 assert!(matches!(ops[0], PlanOp::ScanKey { .. }), "{ops:?}");
1759 }
1760
1761 #[test]
1762 fn plan_expands_from_bound_key() {
1763 let cy =
1764 "MATCH (t:Talent {id: $tid}) MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) RETURN c";
1765 let ops = plan(&crate::cypher::parse(&crate::cypher::lex(cy).unwrap()).unwrap()).unwrap();
1766 assert!(matches!(&ops[0], PlanOp::ScanKey { var, .. } if var == "t"));
1768 match &ops[1] {
1769 PlanOp::Expand { from, dir, to, .. } => {
1770 assert_eq!(from, "t");
1771 assert_eq!(to, "c");
1772 assert_eq!(*dir, RelDir::Left);
1773 }
1774 other => panic!("{other:?}"),
1775 }
1776 }
1777
1778 #[test]
1782 fn plan_does_not_reverse_variable_length_from_bound() {
1783 let cy = "MATCH (t {id: $tid}) MATCH (c:Company)-[*1..2]->(t) RETURN c";
1784 let ops = plan_src(cy).unwrap();
1785 assert!(
1786 matches!(&ops[0], PlanOp::ScanKey { var, .. } if var == "t"),
1787 "{ops:?}"
1788 );
1789 assert!(
1790 matches!(&ops[1], PlanOp::ScanLabel { var, label } if var == "c" && label.as_deref() == Some("Company")),
1791 "{ops:?}"
1792 );
1793 match &ops[2] {
1794 PlanOp::VarExpand {
1795 from,
1796 dir,
1797 to,
1798 min,
1799 max,
1800 ..
1801 } => {
1802 assert_eq!(from, "c");
1803 assert_eq!(to, "t");
1804 assert_eq!(*dir, RelDir::Right);
1805 assert_eq!(*min, 1);
1806 assert_eq!(*max, 2);
1807 }
1808 other => panic!("{other:?}"),
1809 }
1810 }
1811
1812 #[test]
1813 fn unbound_var_in_where_is_err() {
1814 let err = assert_plan_err("MATCH (a) WHERE b.x = 1 RETURN a", "b");
1815 assert!(
1816 err.to_ascii_lowercase().contains("unbound")
1817 && err.to_ascii_lowercase().contains("where"),
1818 "expected unbound-in-WHERE context, got: {err}"
1819 );
1820 }
1821
1822 #[test]
1823 fn unbound_var_in_return_is_err() {
1824 let err = assert_plan_err("MATCH (a) RETURN b", "b");
1825 assert!(
1826 err.to_ascii_lowercase().contains("unbound")
1827 && err.to_ascii_lowercase().contains("return"),
1828 "expected unbound-in-RETURN context, got: {err}"
1829 );
1830 }
1831
1832 #[test]
1833 fn unbound_var_in_order_by_is_err() {
1834 let err = assert_plan_err("MATCH (a) RETURN a ORDER BY b", "b");
1835 assert!(
1836 err.to_ascii_lowercase().contains("unbound")
1837 && (err.to_ascii_lowercase().contains("order")),
1838 "expected unbound-in-ORDER context, got: {err}"
1839 );
1840 }
1841
1842 #[test]
1843 fn duplicate_alias_is_err() {
1844 let err = assert_plan_err("MATCH (a) RETURN a AS x, a.id AS x", "x");
1845 assert!(
1846 err.to_ascii_lowercase().contains("duplicate")
1847 && err.to_ascii_lowercase().contains("alias"),
1848 "expected duplicate-alias context, got: {err}"
1849 );
1850 }
1851
1852 #[test]
1853 fn duplicate_column_name_is_err() {
1854 let err = assert_plan_err("MATCH (a) RETURN a, a", "a");
1855 assert!(
1856 err.to_ascii_lowercase().contains("duplicate")
1857 && err.to_ascii_lowercase().contains("column"),
1858 "expected duplicate-column context, got: {err}"
1859 );
1860 }
1861
1862 #[test]
1863 fn order_by_target_absent_from_return_is_err() {
1864 let err = assert_plan_err("MATCH (a) RETURN a ORDER BY a.x", "a.x");
1866 assert!(
1867 err.to_ascii_lowercase().contains("return"),
1868 "expected ORDER BY target-not-in-RETURN context, got: {err}"
1869 );
1870 }
1871
1872 #[test]
1875 fn order_by_targets_rewrite_to_projected_column_names() {
1876 let got = plan_src(
1877 "MATCH (a)-[r]->(b) \
1878 RETURN a, a.name AS nm, b.age \
1879 ORDER BY nm DESC, a ASC, b.age",
1880 )
1881 .unwrap();
1882 let order = got
1883 .iter()
1884 .find_map(|op| match op {
1885 PlanOp::OrderBy { items } => Some(items),
1886 _ => None,
1887 })
1888 .expect("plan must contain OrderBy");
1889 assert_eq!(
1890 order,
1891 &vec![
1892 OrderItem {
1893 target: OrderTarget::Alias("nm".into()),
1894 descending: true,
1895 },
1896 OrderItem {
1897 target: OrderTarget::Alias("a".into()),
1898 descending: false,
1899 },
1900 OrderItem {
1901 target: OrderTarget::Alias("b.age".into()),
1902 descending: false,
1903 },
1904 ]
1905 );
1906
1907 let aliased_var = plan_src("MATCH (a) RETURN a AS person ORDER BY a").unwrap();
1908 let order = aliased_var
1909 .iter()
1910 .find_map(|op| match op {
1911 PlanOp::OrderBy { items } => Some(items),
1912 _ => None,
1913 })
1914 .expect("plan must contain OrderBy");
1915 assert_eq!(
1916 order,
1917 &vec![OrderItem {
1918 target: OrderTarget::Alias("person".into()),
1919 descending: false,
1920 }]
1921 );
1922 }
1923
1924 #[test]
1925 fn bound_pattern_start_is_join_bound_then_expand() {
1926 let got = plan_src("MATCH (a:L) MATCH (a)-[r:T]->(b) RETURN a, b").unwrap();
1927 assert_eq!(
1928 got,
1929 vec![
1930 PlanOp::ScanLabel {
1931 var: "a".into(),
1932 label: Some("L".into()),
1933 },
1934 PlanOp::JoinBound {
1935 var: "a".into(),
1936 label: None,
1937 props: vec![],
1938 },
1939 PlanOp::Expand {
1940 from: "a".into(),
1941 rel_var: Some("r".into()),
1942 etypes: vec!["T".into()],
1943 dir: RelDir::Right,
1944 to: "b".into(),
1945 to_label: None,
1946 to_props: vec![],
1947 },
1948 PlanOp::Project {
1949 items: vec![
1950 RetItem {
1951 value: RetVal::Var("a".into()),
1952 alias: None,
1953 },
1954 RetItem {
1955 value: RetVal::Var("b".into()),
1956 alias: None,
1957 },
1958 ],
1959 },
1960 ]
1961 );
1962 }
1963
1964 #[test]
1965 fn bound_dest_extra_checks_ride_on_expand() {
1966 let got = plan_src("MATCH (t:Talent) MATCH (c)-[r]->(t:Talent {id: 1}) RETURN t").unwrap();
1967 assert_eq!(
1968 got,
1969 vec![
1970 PlanOp::ScanLabel {
1971 var: "t".into(),
1972 label: Some("Talent".into()),
1973 },
1974 PlanOp::JoinBound {
1975 var: "t".into(),
1976 label: Some("Talent".into()),
1977 props: vec![("id".into(), Operand::Lit(Value::Int(1)))],
1978 },
1979 PlanOp::Expand {
1980 from: "t".into(),
1981 rel_var: Some("r".into()),
1982 etypes: vec![],
1983 dir: RelDir::Left,
1984 to: "c".into(),
1985 to_label: None,
1986 to_props: vec![],
1987 },
1988 PlanOp::Project {
1989 items: vec![RetItem {
1990 value: RetVal::Var("t".into()),
1991 alias: None,
1992 }],
1993 },
1994 ]
1995 );
1996 }
1997
1998 #[test]
1999 fn return_distinct_emits_distinct_after_project() {
2000 let ops = plan_src("MATCH (n) RETURN DISTINCT n").expect("DISTINCT must plan");
2001 let proj = ops
2002 .iter()
2003 .position(|op| matches!(op, PlanOp::Project { .. }))
2004 .expect("Project");
2005 assert!(
2006 matches!(ops.get(proj + 1), Some(PlanOp::Distinct)),
2007 "DISTINCT must follow Project, got: {ops:?}"
2008 );
2009 let bounded = plan_src("MATCH (n) RETURN DISTINCT n LIMIT 1").unwrap();
2010 assert!(
2011 super::row_bound(&bounded).is_none(),
2012 "DISTINCT + LIMIT must not push LIMIT into producers"
2013 );
2014 }
2015
2016 #[test]
2017 fn skip_then_limit_follow_project() {
2018 let got = plan_src("MATCH (a) RETURN a SKIP 2 LIMIT 3").unwrap();
2019 assert_eq!(
2020 got,
2021 vec![
2022 PlanOp::ScanLabel {
2023 var: "a".into(),
2024 label: None,
2025 },
2026 PlanOp::Project {
2027 items: vec![RetItem {
2028 value: RetVal::Var("a".into()),
2029 alias: None,
2030 }],
2031 },
2032 PlanOp::Skip(LimitSkip::Exact(2)),
2033 PlanOp::Limit(LimitSkip::Exact(3)),
2034 ]
2035 );
2036 }
2037
2038 #[test]
2039 fn aliased_prop_order_by_rewrites_to_alias_column() {
2040 let got = plan_src("MATCH (a) RETURN a.name AS nm ORDER BY a.name").unwrap();
2041 let order = got
2042 .iter()
2043 .find_map(|op| match op {
2044 PlanOp::OrderBy { items } => Some(items),
2045 _ => None,
2046 })
2047 .unwrap();
2048 assert_eq!(
2049 order,
2050 &vec![OrderItem {
2051 target: OrderTarget::Alias("nm".into()),
2052 descending: false,
2053 }]
2054 );
2055 }
2056
2057 #[test]
2058 fn plan_never_panics_on_hand_built_query() {
2059 use crate::cypher::ast::{NodePat, Pattern, Query};
2060 let q = Query {
2061 matches: vec![],
2062 optional_clauses: vec![],
2063 where_expr: None,
2064 unwinds: vec![],
2065 post_unwind_where: None,
2066 stages: vec![],
2067 returns: vec![],
2068 order_by: vec![],
2069 distinct: false,
2070 skip: None,
2071 limit: None,
2072 };
2073 let result = std::panic::catch_unwind(|| plan(&q));
2074 assert!(result.is_ok(), "plan panicked on empty Query");
2075 let _ = result.unwrap();
2076
2077 let q = Query {
2078 matches: vec![Pattern {
2079 start: NodePat {
2080 var: None,
2081 label: None,
2082 props: vec![],
2083 },
2084 chain: vec![],
2085 shortest: false,
2086 }],
2087 optional_clauses: vec![],
2088 where_expr: Some(Expr::Not(Box::new(Expr::Cmp {
2089 lhs: Operand::Param("p".into()),
2090 op: CmpOp::Eq,
2091 rhs: Operand::Lit(Value::Int(1)),
2092 }))),
2093 unwinds: vec![],
2094 post_unwind_where: None,
2095 stages: vec![],
2096 returns: vec![],
2097 distinct: false,
2098 order_by: vec![OrderItem {
2099 target: OrderTarget::Alias("missing".into()),
2100 descending: true,
2101 }],
2102 skip: Some(LimitSkip::Exact(0)),
2103 limit: Some(LimitSkip::Exact(0)),
2104 };
2105 let result = std::panic::catch_unwind(|| plan(&q));
2106 assert!(result.is_ok(), "plan panicked on hand-built Query");
2107 let _ = result.unwrap();
2108 }
2109
2110 #[test]
2111 fn bare_relationship_var_in_return_is_err() {
2112 let err = assert_plan_err("MATCH (a)-[r:T]->(b) RETURN r", "r");
2113 assert!(
2114 err.to_ascii_lowercase().contains("relationship"),
2115 "expected bare-rel RETURN guidance, got: {err}"
2116 );
2117 }
2118
2119 #[test]
2120 fn relationship_prop_in_return_is_ok() {
2121 plan_src("MATCH (a)-[r:T]->(b) RETURN r.w").expect("rel prop RETURN must plan");
2122 }
2123
2124 #[test]
2125 fn bare_relationship_var_in_order_by_is_err() {
2126 let err = assert_plan_err("MATCH (a)-[r:T]->(b) RETURN r.w ORDER BY r", "r");
2129 assert!(
2130 err.to_ascii_lowercase().contains("relationship"),
2131 "expected bare-rel ORDER BY guidance, got: {err}"
2132 );
2133 }
2134
2135 #[test]
2136 fn relationship_prop_in_order_by_is_ok() {
2137 plan_src("MATCH (a)-[r:T]->(b) RETURN r.w ORDER BY r.w")
2138 .expect("rel prop ORDER BY must plan");
2139 }
2140
2141 #[test]
2144 fn var_expand_op_emitted_for_star_rel() {
2145 use super::row_bound;
2146 let ops = plan_src("MATCH (a)-[r:T*2..4]->(b) RETURN b").unwrap();
2147 let has_var = ops
2148 .iter()
2149 .any(|op| matches!(op, PlanOp::VarExpand { min: 2, max: 4, .. }));
2150 assert!(has_var, "expected VarExpand(2..4) in plan, got: {ops:?}");
2151 assert_eq!(
2153 row_bound(&ops),
2154 None,
2155 "VarExpand plan must not use pull path"
2156 );
2157 }
2158
2159 #[test]
2160 fn var_expand_with_limit_still_takes_staged_path() {
2161 use super::row_bound;
2162 let ops = plan_src("MATCH (a)-[r:T*1..3]->(b) RETURN b LIMIT 5").unwrap();
2163 assert_eq!(
2165 row_bound(&ops),
2166 None,
2167 "VarExpand + LIMIT must still use staged path"
2168 );
2169 let has_var = ops.iter().any(|op| matches!(op, PlanOp::VarExpand { .. }));
2170 assert!(has_var, "plan must contain VarExpand");
2171 let has_limit = ops
2172 .iter()
2173 .any(|op| matches!(op, PlanOp::Limit(LimitSkip::Exact(5))));
2174 assert!(has_limit, "plan must still emit Limit op");
2175 }
2176
2177 #[test]
2178 fn shortest_path_op_emitted_for_shortest_path_clause() {
2179 let ops =
2180 plan_src("MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*..3]->(b)) RETURN a")
2181 .unwrap();
2182 let has_sp = ops
2183 .iter()
2184 .any(|op| matches!(op, PlanOp::ShortestPath { max_hops: 3, .. }));
2185 assert!(
2186 has_sp,
2187 "expected ShortestPath op with max_hops=3, got: {ops:?}"
2188 );
2189 }
2190
2191 #[test]
2192 fn shortest_path_unbound_endpoint_is_err() {
2193 let err = assert_plan_err(
2194 "MATCH shortestPath((a)-[r:T*..3]->(b)) RETURN a",
2195 "shortestPath",
2196 );
2197 assert!(
2198 err.contains("not bound") || err.contains("bound"),
2199 "error must mention binding, got: {err}"
2200 );
2201 }
2202
2203 #[test]
2204 fn var_expand_rel_var_is_in_rel_bound() {
2205 plan_src("MATCH (a)-[r:T*1..3]->(b) RETURN r.length").expect("r.length must plan");
2207 assert_plan_err("MATCH (a)-[r:T*1..3]->(b) RETURN r", "r");
2209 }
2210
2211 #[test]
2212 fn shortest_path_min_gt_1_is_plan_err() {
2213 let err = assert_plan_err(
2215 "MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*2..5]->(b)) RETURN r.length",
2216 "shortestPath",
2217 );
2218 assert!(
2219 err.contains("minimum"),
2220 "error must mention minimum hop count, got: {err}"
2221 );
2222 }
2223
2224 fn subscribable(src: &str) -> bool {
2227 let ops = plan_src(src).expect("must plan");
2228 super::is_subscribable(&ops)
2229 }
2230
2231 #[test]
2232 fn is_subscribable_passes_simple_label_scan() {
2233 assert!(subscribable("MATCH (n:Person) RETURN n"));
2234 assert!(subscribable("MATCH (n:Person) WHERE n.age > 18 RETURN n"));
2235 assert!(subscribable("MATCH (n:Person) RETURN n LIMIT 100"));
2236 }
2237
2238 #[test]
2239 fn is_subscribable_passes_single_hop_expand() {
2240 assert!(subscribable(
2241 "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a"
2242 ));
2243 assert!(subscribable(
2244 "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a LIMIT 50"
2245 ));
2246 }
2247
2248 #[test]
2249 fn is_subscribable_rejects_multi_hop_expand() {
2250 assert!(
2252 !subscribable("MATCH (a:Person)-[r1:KNOWS]->(b:Person)-[r2:LIKES]->(c:Thing) RETURN a"),
2253 "two-hop chain must be rejected"
2254 );
2255 }
2256
2257 #[test]
2258 fn is_subscribable_rejects_skip() {
2259 assert!(
2261 !subscribable("MATCH (n:Person) RETURN n SKIP 10 LIMIT 50"),
2262 "SKIP must be rejected"
2263 );
2264 assert!(
2265 !subscribable("MATCH (n:Person) RETURN n SKIP 10"),
2266 "bare SKIP must be rejected"
2267 );
2268 }
2269
2270 #[test]
2271 fn is_subscribable_rejects_order_by() {
2272 assert!(!subscribable("MATCH (n:Person) RETURN n ORDER BY n"));
2273 }
2274
2275 #[test]
2276 fn is_subscribable_rejects_aggregates() {
2277 assert!(!subscribable("MATCH (n:Person) RETURN COUNT(*)"));
2278 }
2279
2280 #[test]
2281 fn is_subscribable_rejects_var_expand() {
2282 assert!(!subscribable(
2283 "MATCH (a:Person)-[r:KNOWS*1..3]->(b) RETURN b"
2284 ));
2285 }
2286
2287 #[test]
2290 fn where_equality_folds_to_index_scan() {
2291 let ops = plan_src("MATCH (n:Person) WHERE n.city = 'austin' RETURN n.key").unwrap();
2292 assert!(
2293 matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2294 "WHERE single equality must fold to IndexScan, got {:?}",
2295 ops[0]
2296 );
2297 assert!(
2298 !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2299 "consumed predicate must not remain as Filter"
2300 );
2301 }
2302
2303 #[test]
2304 fn where_equality_param_folds_to_index_scan() {
2305 let ops = plan_src("MATCH (n:Person) WHERE n.city = $c RETURN n.key").unwrap();
2306 assert!(
2307 matches!(&ops[0], PlanOp::IndexScan { .. }),
2308 "param WHERE equality must fold to IndexScan, got {:?}",
2309 ops[0]
2310 );
2311 }
2312
2313 #[test]
2314 fn where_and_keeps_residual_filter() {
2315 let ops = plan_src("MATCH (n:Person) WHERE n.city = 'austin' AND n.age > 30 RETURN n.key")
2316 .unwrap();
2317 assert!(
2318 matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2319 "equality must fold to IndexScan, got {:?}",
2320 ops[0]
2321 );
2322 assert!(
2323 ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2324 "n.age > 30 must remain as residual Filter"
2325 );
2326 }
2327
2328 #[test]
2329 fn where_on_expanded_var_does_not_fold() {
2330 let ops =
2331 plan_src("MATCH (a:Person)-[:KNOWS]->(b:Person) WHERE b.city = 'austin' RETURN a.key")
2332 .unwrap();
2333 assert!(
2334 matches!(&ops[0], PlanOp::ScanLabel { .. } | PlanOp::IndexScan { .. }),
2335 "first op must be a scan, got {:?}",
2336 ops[0]
2337 );
2338 assert!(
2339 ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2340 "b.city filter must remain"
2341 );
2342 }
2343
2344 #[test]
2345 fn where_inline_prop_and_where_equality_both_usable() {
2346 let ops = plan_src("MATCH (n:Person {team: 'core'}) WHERE n.city = 'austin' RETURN n.key")
2348 .unwrap();
2349 assert!(
2350 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2351 "inline+WHERE equalities must merge to IndexIntersect(2), got {:?}",
2352 ops[0]
2353 );
2354 assert!(
2355 !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2356 "both equalities fully folded; no residual Filter expected"
2357 );
2358 }
2359
2360 #[test]
2363 fn single_equality_inline_stays_index_scan() {
2364 let ops = plan_src("MATCH (n:Person {city: 'austin'}) RETURN n").unwrap();
2366 assert!(
2367 matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2368 "single-equality inline prop must emit IndexScan, got {:?}",
2369 ops[0]
2370 );
2371 }
2372
2373 #[test]
2374 fn compound_inline_props_emit_index_intersect() {
2375 let ops = plan_src("MATCH (n:Doc {namespace: 'a', status: 'live'}) RETURN n.key").unwrap();
2376 assert!(
2377 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2378 "two inline props must emit IndexIntersect(2), got {:?}",
2379 ops[0]
2380 );
2381 }
2382
2383 #[test]
2384 fn where_two_equalities_emit_index_intersect() {
2385 let ops = plan_src("MATCH (n:Doc) WHERE n.namespace = 'a' AND n.status = $s RETURN n.key")
2386 .unwrap();
2387 assert!(
2388 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2389 "two WHERE equalities must emit IndexIntersect(2), got {:?}",
2390 ops[0]
2391 );
2392 assert!(
2393 !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2394 "both equalities fully consumed; no residual Filter expected"
2395 );
2396 }
2397
2398 #[test]
2399 fn mixed_inline_and_where_equalities_merge() {
2400 let ops = plan_src("MATCH (n:Doc {namespace: 'a'}) WHERE n.status = 'live' RETURN n.key")
2401 .unwrap();
2402 assert!(
2403 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2404 "inline+WHERE equalities must merge to IndexIntersect(2), got {:?}",
2405 ops[0]
2406 );
2407 }
2408
2409 #[test]
2410 fn where_three_equalities_emit_index_intersect() {
2411 let ops = plan_src(
2412 "MATCH (n:Doc) WHERE n.namespace = 'a' AND n.status = 'live' AND n.kind = $k RETURN n",
2413 )
2414 .unwrap();
2415 assert!(
2416 matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 3),
2417 "three WHERE equalities must emit IndexIntersect(3), got {:?}",
2418 ops[0]
2419 );
2420 assert!(
2421 !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2422 "all three equalities fully consumed; no residual Filter expected"
2423 );
2424 }
2425}