1use super::ast::{
8 AggArg, AggFunc, Expr, LimitSkip, NodePat, Operand, OptionalClause, OrderItem, OrderTarget,
9 Pattern, Query, RelDir, RelPat, RetItem, RetVal, UnwindExpr, WithStage,
10};
11use std::collections::BTreeSet;
12
13#[derive(Debug, Clone, PartialEq)]
17pub enum PlanOp {
18 ScanLabel {
20 var: String,
21 label: Option<String>,
22 },
23 ScanKey {
26 var: String,
27 key: Operand,
28 label: Option<String>,
29 },
30 IndexScan {
36 var: String,
37 label: Option<String>,
38 field: String,
39 value: Operand,
40 },
41 LookupProps {
43 var: String,
44 props: Vec<(String, Operand)>,
45 },
46 Expand {
53 from: String,
54 rel_var: Option<String>,
55 etypes: Vec<String>,
56 dir: RelDir,
57 to: String,
58 to_label: Option<String>,
59 to_props: Vec<(String, Operand)>,
60 },
61 JoinBound {
63 var: String,
64 label: Option<String>,
65 props: Vec<(String, Operand)>,
66 },
67 Filter {
68 expr: Expr,
69 },
70 Project {
71 items: Vec<RetItem>,
72 },
73 Distinct,
77 OrderBy {
81 items: Vec<OrderItem>,
82 },
83 Skip(LimitSkip),
84 Limit(LimitSkip),
85 Aggregate {
93 func: AggFunc,
94 arg: AggArg,
95 column: String,
98 },
99 GroupAggregate {
113 keys: Vec<(String, RetItem)>,
115 aggs: Vec<(AggFunc, AggArg, String)>,
117 },
118 VarExpand {
129 from: String,
130 rel_var: Option<String>,
131 etypes: Vec<String>,
132 dir: RelDir,
133 to: String,
134 min: u8,
135 max: u8,
136 },
137 ShortestPath {
149 from: String,
150 rel_var: Option<String>,
151 etypes: Vec<String>,
152 dir: RelDir,
153 to: String,
154 max_hops: u8,
155 },
156 With {
163 items: Vec<RetItem>,
164 where_expr: Option<Expr>,
165 order_by: Vec<OrderItem>,
166 skip: Option<LimitSkip>,
167 limit: Option<LimitSkip>,
168 },
169 Unwind {
180 expr: UnwindExpr,
181 alias: String,
182 },
183 LeftOuterApply {
199 inner: Vec<PlanOp>,
200 optional_vars: Vec<String>,
201 },
202}
203
204pub fn row_bound(ops: &[PlanOp]) -> Option<usize> {
232 if ops
234 .iter()
235 .any(|op| matches!(op, PlanOp::OrderBy { .. } | PlanOp::Distinct))
236 {
237 return None;
238 }
239 if ops.iter().any(|op| matches!(op, PlanOp::Aggregate { .. })) {
241 return None;
242 }
243 if ops
246 .iter()
247 .any(|op| matches!(op, PlanOp::GroupAggregate { .. }))
248 {
249 return None;
250 }
251 if ops
254 .iter()
255 .any(|op| matches!(op, PlanOp::VarExpand { .. } | PlanOp::ShortestPath { .. }))
256 {
257 return None;
258 }
259 if ops.iter().any(|op| {
262 matches!(
263 op,
264 PlanOp::With { .. } | PlanOp::Unwind { .. } | PlanOp::LeftOuterApply { .. }
265 )
266 }) {
267 return None;
268 }
269 let limit_n = ops.iter().rev().find_map(|op| match op {
270 PlanOp::Limit(LimitSkip::Exact(n)) => Some(*n),
271 PlanOp::Limit(LimitSkip::Param(_)) => None, _ => None,
273 })?;
274 if ops
276 .iter()
277 .any(|op| matches!(op, PlanOp::Skip(LimitSkip::Param(_))))
278 {
279 return None;
280 }
281 let skip_n = ops
282 .iter()
283 .rev()
284 .find_map(|op| match op {
285 PlanOp::Skip(LimitSkip::Exact(n)) => Some(*n),
286 _ => None,
287 })
288 .unwrap_or(0);
289 Some((skip_n as usize).saturating_add(limit_n as usize))
290}
291
292pub fn is_subscribable(ops: &[PlanOp]) -> bool {
307 ops.iter().all(|op| {
311 matches!(
312 op,
313 PlanOp::ScanLabel { .. }
314 | PlanOp::ScanKey { .. }
315 | PlanOp::IndexScan { .. }
316 | PlanOp::LookupProps { .. }
317 | PlanOp::Expand { .. }
318 | PlanOp::Filter { .. }
319 | PlanOp::Project { .. }
320 | PlanOp::Limit(_)
321 )
322 })
323 && ops.iter().any(|op| {
325 matches!(
326 op,
327 PlanOp::ScanLabel { .. } | PlanOp::ScanKey { .. } | PlanOp::IndexScan { .. }
328 )
329 })
330 && ops.iter().any(|op| matches!(op, PlanOp::Project { .. }))
332 && ops
334 .iter()
335 .filter(|op| matches!(op, PlanOp::Expand { .. }))
336 .count()
337 <= 1
338}
339
340pub fn plan(q: &Query) -> Result<Vec<PlanOp>, String> {
342 let mut bound = BTreeSet::new();
343 let mut rel_bound = BTreeSet::new();
344 let mut ops = Vec::new();
345 let mut node_anon = 0u32;
346 let mut rel_anon = 0u32;
347
348 for pat in &q.matches {
349 compile_pattern(
350 pat,
351 &mut ops,
352 &mut bound,
353 &mut rel_bound,
354 &mut node_anon,
355 &mut rel_anon,
356 )?;
357 }
358
359 for oc in &q.optional_clauses {
361 compile_optional_clause(
362 oc,
363 &mut ops,
364 &mut bound,
365 &mut rel_bound,
366 &mut node_anon,
367 &mut rel_anon,
368 )?;
369 }
370
371 for uw in &q.unwinds {
373 check_unwind_bound(&uw.list, &bound)?;
374 bound.insert(uw.alias.clone());
375 ops.push(PlanOp::Unwind {
376 expr: uw.list.clone(),
377 alias: uw.alias.clone(),
378 });
379 }
380
381 if let Some(expr) = &q.where_expr {
382 check_expr_bound(expr, &bound)?;
383 ops.push(PlanOp::Filter { expr: expr.clone() });
384 }
385
386 if let Some(expr) = &q.post_unwind_where {
388 check_expr_bound(expr, &bound)?;
389 ops.push(PlanOp::Filter { expr: expr.clone() });
390 }
391
392 for stage in &q.stages {
394 compile_with_stage(
395 stage,
396 &mut ops,
397 &mut bound,
398 &mut rel_bound,
399 &mut node_anon,
400 &mut rel_anon,
401 )?;
402 }
403
404 check_return_bound(&q.returns, &bound, &rel_bound)?;
405 check_duplicate_aliases(&q.returns)?;
406 check_duplicate_columns(&q.returns)?;
407 if q.distinct
408 && q.returns
409 .iter()
410 .any(|r| matches!(&r.value, RetVal::Agg { .. }))
411 {
412 return Err(
413 "RETURN DISTINCT is not supported with aggregate functions; use grouping".to_string(),
414 );
415 }
416
417 let is_pipeline = !q.stages.is_empty()
421 || !q.unwinds.is_empty()
422 || q.post_unwind_where.is_some()
423 || !q.optional_clauses.is_empty();
424 let agg_count = q
425 .returns
426 .iter()
427 .filter(|r| matches!(&r.value, RetVal::Agg { .. }))
428 .count();
429
430 if agg_count == 1 && q.returns.len() == 1 && !is_pipeline {
431 let item = &q.returns[0];
433 let (func, arg) = match &item.value {
434 RetVal::Agg { func, arg } => (func.clone(), arg.clone()),
435 _ => unreachable!(),
436 };
437 if let (AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max, AggArg::Star) =
439 (&func, &arg)
440 {
441 return Err(format!(
442 "{name} does not accept '*'; use a property expression like `{name}(n.prop)`",
443 name = func_name(&func),
444 ));
445 }
446 let column = item
447 .alias
448 .clone()
449 .unwrap_or_else(|| agg_column_name(&func, &arg));
450 ops.push(PlanOp::Aggregate { func, arg, column });
451 return Ok(ops);
454 }
455
456 if agg_count > 0 {
457 let mut keys: Vec<(String, RetItem)> = Vec::new();
460 let mut aggs: Vec<(AggFunc, AggArg, String)> = Vec::new();
461 for item in &q.returns {
462 match &item.value {
463 RetVal::Agg { func, arg } => {
464 if let (
466 AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max,
467 AggArg::Star,
468 ) = (func, arg)
469 {
470 return Err(format!(
471 "{name} does not accept '*'; use a property expression like `{name}(n.prop)`",
472 name = func_name(func),
473 ));
474 }
475 let column = item
476 .alias
477 .clone()
478 .unwrap_or_else(|| agg_column_name(func, arg));
479 aggs.push((func.clone(), arg.clone(), column));
480 }
481 _ => {
482 keys.push((column_name(item), item.clone()));
483 }
484 }
485 }
486 ops.push(PlanOp::GroupAggregate { keys, aggs });
487 if !q.order_by.is_empty() {
489 let mut items = Vec::with_capacity(q.order_by.len());
490 for item in &q.order_by {
491 items.push(rewrite_order_item(item, &q.returns, &bound, &rel_bound)?);
492 }
493 ops.push(PlanOp::OrderBy { items });
494 }
495 if let Some(ls) = &q.skip {
496 ops.push(PlanOp::Skip(ls.clone()));
497 }
498 if let Some(ls) = &q.limit {
499 ops.push(PlanOp::Limit(ls.clone()));
500 }
501 return Ok(ops);
502 }
503
504 ops.push(PlanOp::Project {
505 items: q.returns.clone(),
506 });
507 if q.distinct {
508 ops.push(PlanOp::Distinct);
509 }
510
511 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
519 if let Some(ls) = &q.skip {
520 ops.push(PlanOp::Skip(ls.clone()));
521 }
522 if let Some(ls) = &q.limit {
523 ops.push(PlanOp::Limit(ls.clone()));
524 }
525
526 Ok(ops)
527}
528
529fn compile_with_stage(
531 stage: &WithStage,
532 ops: &mut Vec<PlanOp>,
533 bound: &mut BTreeSet<String>,
534 rel_bound: &mut BTreeSet<String>,
535 node_anon: &mut u32,
536 rel_anon: &mut u32,
537) -> Result<(), String> {
538 let agg_count = stage
539 .items
540 .iter()
541 .filter(|r| matches!(&r.value, RetVal::Agg { .. }))
542 .count();
543
544 if agg_count > 0 {
545 let mut keys: Vec<(String, RetItem)> = Vec::new();
547 let mut aggs: Vec<(AggFunc, AggArg, String)> = Vec::new();
548 for item in &stage.items {
549 match &item.value {
550 RetVal::Agg { func, arg } => {
551 if let (
552 AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max,
553 AggArg::Star,
554 ) = (func, arg)
555 {
556 return Err(format!(
557 "{name} does not accept '*'; use a property expression like `{name}(n.prop)`",
558 name = func_name(func),
559 ));
560 }
561 let col = item
562 .alias
563 .clone()
564 .unwrap_or_else(|| agg_column_name(func, arg));
565 aggs.push((func.clone(), arg.clone(), col));
566 }
567 _ => {
568 keys.push((column_name(item), item.clone()));
569 }
570 }
571 }
572 ops.push(PlanOp::GroupAggregate {
573 keys: keys.clone(),
574 aggs: aggs.clone(),
575 });
576
577 bound.clear();
579 rel_bound.clear();
580 for (col, _) in &keys {
581 bound.insert(col.clone());
582 }
583 for (_, _, col) in &aggs {
584 bound.insert(col.clone());
585 }
586
587 if let Some(expr) = &stage.where_expr {
589 check_expr_bound(expr, bound)?;
590 ops.push(PlanOp::Filter { expr: expr.clone() });
591 }
592 if !stage.order_by.is_empty() {
595 for item in &stage.order_by {
596 match &item.target {
597 OrderTarget::Prop { var, .. } | OrderTarget::Var(var) => {
598 require_bound(var, bound, "ORDER BY in aggregate WITH")?;
599 }
600 OrderTarget::Alias(name) => {
601 require_bound(name, bound, "ORDER BY in aggregate WITH")?;
602 }
603 }
604 }
605 ops.push(PlanOp::OrderBy {
606 items: stage.order_by.clone(),
607 });
608 }
609 if let Some(ls) = &stage.skip {
610 ops.push(PlanOp::Skip(ls.clone()));
611 }
612 if let Some(ls) = &stage.limit {
613 ops.push(PlanOp::Limit(ls.clone()));
614 }
615 } else {
616 check_return_bound(&stage.items, bound, rel_bound)?;
618
619 if let Some(expr) = &stage.where_expr {
623 check_expr_bound(expr, bound)?;
624 }
625 let with_col_names: BTreeSet<String> = stage.items.iter().map(column_name).collect();
630 for item in &stage.order_by {
631 match &item.target {
632 OrderTarget::Prop { var, .. } | OrderTarget::Var(var) => {
633 if !bound.contains(var.as_str()) && !with_col_names.contains(var.as_str()) {
634 return Err(format!("unbound variable `{var}` in ORDER BY in WITH"));
635 }
636 }
637 OrderTarget::Alias(name) => {
638 if !bound.contains(name.as_str()) && !with_col_names.contains(name.as_str()) {
639 return Err(format!("unbound variable `{name}` in ORDER BY in WITH"));
640 }
641 }
642 }
643 }
644 ops.push(PlanOp::With {
645 items: stage.items.clone(),
646 where_expr: stage.where_expr.clone(),
647 order_by: stage.order_by.clone(),
648 skip: stage.skip.clone(),
649 limit: stage.limit.clone(),
650 });
651
652 let mut new_bound: BTreeSet<String> = BTreeSet::new();
654 let mut new_rel_bound: BTreeSet<String> = BTreeSet::new();
655 for item in &stage.items {
656 let col = column_name(item);
657 new_bound.insert(col.clone());
658 match &item.value {
660 RetVal::Var(v) if rel_bound.contains(v.as_str()) => {
661 new_rel_bound.insert(col);
662 }
663 _ => {}
664 }
665 }
666 *bound = new_bound;
667 *rel_bound = new_rel_bound;
668 }
669
670 for pat in &stage.matches {
672 compile_pattern(pat, ops, bound, rel_bound, node_anon, rel_anon)?;
673 }
674 for oc in &stage.optional_clauses {
676 compile_optional_clause(oc, ops, bound, rel_bound, node_anon, rel_anon)?;
677 }
678 for uw in &stage.unwinds {
680 check_unwind_bound(&uw.list, bound)?;
681 bound.insert(uw.alias.clone());
682 ops.push(PlanOp::Unwind {
683 expr: uw.list.clone(),
684 alias: uw.alias.clone(),
685 });
686 }
687 if let Some(expr) = &stage.post_where {
689 check_expr_bound(expr, bound)?;
690 ops.push(PlanOp::Filter { expr: expr.clone() });
691 }
692
693 Ok(())
694}
695
696fn id_lookup(props: &[(String, Operand)]) -> Option<&Operand> {
697 if props.len() == 1 && props[0].0 == "id" {
698 Some(&props[0].1)
699 } else {
700 None
701 }
702}
703
704fn index_lookup(props: &[(String, Operand)]) -> Option<(&str, &Operand)> {
707 if props.len() == 1
708 && props[0].0 != "id"
709 && matches!(props[0].1, Operand::Lit(_) | Operand::Param(_))
710 {
711 Some((props[0].0.as_str(), &props[0].1))
712 } else {
713 None
714 }
715}
716
717fn invert_dir(d: RelDir) -> RelDir {
718 match d {
719 RelDir::Right => RelDir::Left,
720 RelDir::Left => RelDir::Right,
721 RelDir::Undirected => RelDir::Undirected,
722 }
723}
724
725fn compile_pattern(
726 pat: &Pattern,
727 ops: &mut Vec<PlanOp>,
728 bound: &mut BTreeSet<String>,
729 rel_bound: &mut BTreeSet<String>,
730 node_anon: &mut u32,
731 rel_anon: &mut u32,
732) -> Result<(), String> {
733 let start = name_node(&pat.start, node_anon, bound);
734 if pat.shortest {
735 if !bound.contains(&start) {
737 return Err(format!(
738 "shortestPath: source node `{start}` is not bound; \
739 bind both endpoints before shortestPath"
740 ));
741 }
742 ops.push(PlanOp::JoinBound {
743 var: start.clone(),
744 label: pat.start.label.clone(),
745 props: pat.start.props.clone(),
746 });
747 } else if bound.contains(&start) {
748 ops.push(PlanOp::JoinBound {
749 var: start.clone(),
750 label: pat.start.label.clone(),
751 props: pat.start.props.clone(),
752 });
753 } else if pat.chain.len() == 1
754 && pat.chain[0].0.hops.is_none()
755 && pat.chain[0]
756 .1
757 .var
758 .as_ref()
759 .is_some_and(|v| bound.contains(v))
760 {
761 let (rel, dest) = &pat.chain[0];
766 let dest_name = name_node(dest, node_anon, bound);
767 let rel_name = name_rel(rel, rel_anon, bound);
768 bound.insert(rel_name.clone());
769 rel_bound.insert(rel_name.clone());
770 if dest.label.is_some() || !dest.props.is_empty() {
771 ops.push(PlanOp::JoinBound {
772 var: dest_name.clone(),
773 label: dest.label.clone(),
774 props: dest.props.clone(),
775 });
776 }
777 ops.push(PlanOp::Expand {
778 from: dest_name,
779 rel_var: Some(rel_name),
780 etypes: rel.etypes.clone(),
781 dir: invert_dir(rel.dir),
782 to: start.clone(),
783 to_label: pat.start.label.clone(),
784 to_props: pat.start.props.clone(),
785 });
786 bound.insert(start);
787 return Ok(());
788 } else if let Some(key) = id_lookup(&pat.start.props) {
789 ops.push(PlanOp::ScanKey {
790 var: start.clone(),
791 key: key.clone(),
792 label: pat.start.label.clone(),
793 });
794 bound.insert(start.clone());
795 } else if let Some((field, value)) = index_lookup(&pat.start.props) {
796 ops.push(PlanOp::IndexScan {
797 var: start.clone(),
798 label: pat.start.label.clone(),
799 field: field.to_string(),
800 value: value.clone(),
801 });
802 bound.insert(start.clone());
803 } else {
804 ops.push(PlanOp::ScanLabel {
805 var: start.clone(),
806 label: pat.start.label.clone(),
807 });
808 if !pat.start.props.is_empty() {
809 ops.push(PlanOp::LookupProps {
810 var: start.clone(),
811 props: pat.start.props.clone(),
812 });
813 }
814 bound.insert(start.clone());
815 }
816
817 let mut from = start;
818 for (rel, dest) in &pat.chain {
819 let rel_name = name_rel(rel, rel_anon, bound);
820 bound.insert(rel_name.clone());
821 rel_bound.insert(rel_name.clone());
822 let to = name_node(dest, node_anon, bound);
823
824 if let Some(hops) = rel.hops {
825 if pat.shortest {
826 if !bound.contains(&to) {
828 return Err(format!(
829 "shortestPath: destination node `{to}` is not bound; \
830 bind both endpoints before shortestPath"
831 ));
832 }
833 if hops.min > 1 {
837 return Err(format!(
838 "shortestPath does not support a minimum hop count \
839 (got min={}); use a plain variable-length pattern \
840 if you need a minimum",
841 hops.min
842 ));
843 }
844 ops.push(PlanOp::ShortestPath {
845 from: from.clone(),
846 rel_var: Some(rel_name),
847 etypes: rel.etypes.clone(),
848 dir: rel.dir,
849 to: to.clone(),
850 max_hops: hops.max,
851 });
852 } else {
853 ops.push(PlanOp::VarExpand {
854 from: from.clone(),
855 rel_var: Some(rel_name),
856 etypes: rel.etypes.clone(),
857 dir: rel.dir,
858 to: to.clone(),
859 min: hops.min,
860 max: hops.max,
861 });
862 bound.insert(to.clone());
863 }
864 } else {
865 ops.push(PlanOp::Expand {
866 from: from.clone(),
867 rel_var: Some(rel_name),
868 etypes: rel.etypes.clone(),
869 dir: rel.dir,
870 to: to.clone(),
871 to_label: dest.label.clone(),
872 to_props: dest.props.clone(),
873 });
874 bound.insert(to.clone());
875 }
876 from = to;
877 }
878 Ok(())
879}
880
881fn compile_optional_clause(
888 oc: &OptionalClause,
889 ops: &mut Vec<PlanOp>,
890 bound: &mut BTreeSet<String>,
891 rel_bound: &mut BTreeSet<String>,
892 node_anon: &mut u32,
893 rel_anon: &mut u32,
894) -> Result<(), String> {
895 let mut inner_bound = bound.clone();
897 let mut inner_rel_bound = rel_bound.clone();
898 let mut inner_ops: Vec<PlanOp> = Vec::new();
899
900 for pat in &oc.patterns {
901 compile_pattern(
902 pat,
903 &mut inner_ops,
904 &mut inner_bound,
905 &mut inner_rel_bound,
906 node_anon,
907 rel_anon,
908 )?;
909 }
910 if let Some(expr) = &oc.where_expr {
911 check_expr_bound(expr, &inner_bound)?;
912 inner_ops.push(PlanOp::Filter { expr: expr.clone() });
913 }
914
915 let optional_vars: Vec<String> = inner_bound
917 .difference(bound)
918 .chain(inner_rel_bound.difference(rel_bound))
919 .cloned()
920 .collect();
921
922 for v in &optional_vars {
925 bound.insert(v.clone());
926 }
927 for v in inner_rel_bound
928 .difference(&*rel_bound)
929 .cloned()
930 .collect::<Vec<_>>()
931 {
932 rel_bound.insert(v);
933 }
934
935 ops.push(PlanOp::LeftOuterApply {
936 inner: inner_ops,
937 optional_vars,
938 });
939 Ok(())
940}
941
942fn name_node(node: &NodePat, counter: &mut u32, bound: &BTreeSet<String>) -> String {
943 match &node.var {
944 Some(v) => v.clone(),
945 None => fresh("_n", counter, bound),
946 }
947}
948
949fn name_rel(rel: &RelPat, counter: &mut u32, bound: &BTreeSet<String>) -> String {
950 match &rel.var {
951 Some(v) => v.clone(),
952 None => fresh("_r", counter, bound),
953 }
954}
955
956fn fresh(prefix: &str, counter: &mut u32, bound: &BTreeSet<String>) -> String {
959 for _ in 0..=u32::MAX {
960 let name = format!("{prefix}{counter}");
961 *counter = counter.wrapping_add(1);
962 if !bound.contains(&name) {
963 return name;
964 }
965 }
966 format!("{prefix}x")
967}
968
969fn check_expr_bound(expr: &Expr, bound: &BTreeSet<String>) -> Result<(), String> {
970 match expr {
971 Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => {
972 check_expr_bound(lhs, bound)?;
973 check_expr_bound(rhs, bound)
974 }
975 Expr::Not(inner) => check_expr_bound(inner, bound),
976 Expr::Cmp { lhs, rhs, .. } => {
977 check_operand_bound(lhs, bound, "WHERE")?;
978 check_operand_bound(rhs, bound, "WHERE")
979 }
980 Expr::Truthy(op) => check_operand_bound(op, bound, "WHERE"),
981 Expr::IsNull(op) | Expr::IsNotNull(op) => check_operand_bound(op, bound, "WHERE"),
982 Expr::In { expr, list } => {
983 check_operand_bound(expr, bound, "WHERE")?;
984 for item in list {
985 check_operand_bound(item, bound, "WHERE")?;
986 }
987 Ok(())
988 }
989 }
990}
991
992fn check_operand_bound(
993 operand: &Operand,
994 bound: &BTreeSet<String>,
995 clause: &str,
996) -> Result<(), String> {
997 match operand {
998 Operand::Prop { var, .. } => require_bound(var, bound, clause),
999 Operand::Lit(_) | Operand::Param(_) => Ok(()),
1000 Operand::Var(name) => require_bound(name, bound, clause),
1001 Operand::BinArith { left, right, .. } => {
1002 check_operand_bound(left, bound, clause)?;
1003 check_operand_bound(right, bound, clause)
1004 }
1005 Operand::FuncCall { args, .. } => {
1006 for arg in args {
1007 check_operand_bound(arg, bound, clause)?;
1008 }
1009 Ok(())
1010 }
1011 Operand::Case { branches, default } => {
1012 for (cond, value) in branches {
1013 check_expr_bound(cond, bound)?;
1014 check_operand_bound(value, bound, clause)?;
1015 }
1016 if let Some(d) = default {
1017 check_operand_bound(d, bound, clause)?;
1018 }
1019 Ok(())
1020 }
1021 }
1022}
1023
1024fn check_unwind_bound(expr: &UnwindExpr, bound: &BTreeSet<String>) -> Result<(), String> {
1026 match expr {
1027 UnwindExpr::Lit(_) => Ok(()),
1028 UnwindExpr::Prop { var, .. } => require_bound(var, bound, "UNWIND"),
1029 UnwindExpr::Var(name) => require_bound(name, bound, "UNWIND"),
1030 }
1031}
1032
1033fn require_bound(var: &str, bound: &BTreeSet<String>, clause: &str) -> Result<(), String> {
1034 if bound.contains(var) {
1035 Ok(())
1036 } else {
1037 Err(format!("unbound variable `{var}` in {clause}"))
1038 }
1039}
1040
1041fn reject_bare_rel(var: &str, rel_bound: &BTreeSet<String>) -> Result<(), String> {
1042 if rel_bound.contains(var) {
1043 Err(format!(
1044 "cannot return relationship variable '{var}' bare; return its properties ({var}.field) instead"
1045 ))
1046 } else {
1047 Ok(())
1048 }
1049}
1050
1051fn check_return_bound(
1052 items: &[RetItem],
1053 bound: &BTreeSet<String>,
1054 rel_bound: &BTreeSet<String>,
1055) -> Result<(), String> {
1056 for item in items {
1057 match &item.value {
1058 RetVal::Var(v) => {
1059 require_bound(v, bound, "RETURN")?;
1060 reject_bare_rel(v, rel_bound)?;
1061 }
1062 RetVal::Prop { var, .. } => {
1063 require_bound(var, bound, "RETURN")?;
1064 }
1065 RetVal::Agg { arg, .. } => match arg {
1066 AggArg::Star => {}
1067 AggArg::Var(v) => {
1068 require_bound(v, bound, "RETURN")?;
1069 }
1070 AggArg::Prop { var, .. } => {
1071 require_bound(var, bound, "RETURN")?;
1072 }
1073 },
1074 RetVal::FuncCall { args, .. } => {
1075 for arg in args {
1076 check_operand_bound(arg, bound, "RETURN")?;
1077 }
1078 }
1079 RetVal::ScalarExpr(op) => {
1080 check_operand_bound(op, bound, "RETURN")?;
1081 }
1082 }
1083 }
1084 Ok(())
1085}
1086
1087fn check_duplicate_aliases(items: &[RetItem]) -> Result<(), String> {
1088 let mut seen = BTreeSet::new();
1089 for item in items {
1090 if let Some(alias) = &item.alias {
1091 if !seen.insert(alias.clone()) {
1092 return Err(format!("duplicate RETURN alias `{alias}`"));
1093 }
1094 }
1095 }
1096 Ok(())
1097}
1098
1099fn check_duplicate_columns(items: &[RetItem]) -> Result<(), String> {
1100 let mut seen = BTreeSet::new();
1101 for item in items {
1102 let col = column_name(item);
1103 if !seen.insert(col.clone()) {
1104 return Err(format!("duplicate RETURN column `{col}`"));
1105 }
1106 }
1107 Ok(())
1108}
1109
1110fn column_name(item: &RetItem) -> String {
1113 if let Some(alias) = &item.alias {
1114 return alias.clone();
1115 }
1116 match &item.value {
1117 RetVal::Var(v) => v.clone(),
1118 RetVal::Prop { var, field } => format!("{var}.{field}"),
1119 RetVal::Agg { func, arg } => agg_column_name(func, arg),
1120 RetVal::FuncCall { name, args } => {
1121 let arg_strs: Vec<String> = args
1122 .iter()
1123 .map(|a| match a {
1124 Operand::Var(v) => v.clone(),
1125 Operand::Prop { var, field } => format!("{var}.{field}"),
1126 Operand::Lit(_) => "<lit>".to_string(),
1127 Operand::Param(p) => format!("${p}"),
1128 Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
1129 Operand::BinArith { .. } => "<arith>".to_string(),
1130 Operand::Case { .. } => "<case>".to_string(),
1131 })
1132 .collect();
1133 format!("{name}({})", arg_strs.join(", "))
1134 }
1135 RetVal::ScalarExpr(_) => "<expr>".to_string(),
1136 }
1137}
1138
1139fn agg_column_name(func: &AggFunc, arg: &AggArg) -> String {
1142 let f = func_name(func);
1143 let a = match arg {
1144 AggArg::Star => "*".to_string(),
1145 AggArg::Var(v) => v.clone(),
1146 AggArg::Prop { var, field } => format!("{var}.{field}"),
1147 };
1148 format!("{f}({a})")
1149}
1150
1151fn func_name(func: &AggFunc) -> &'static str {
1152 match func {
1153 AggFunc::Count => "COUNT",
1154 AggFunc::Sum => "SUM",
1155 AggFunc::Avg => "AVG",
1156 AggFunc::Min => "MIN",
1157 AggFunc::Max => "MAX",
1158 AggFunc::Collect => "COLLECT",
1159 }
1160}
1161
1162fn rewrite_order_item(
1163 item: &OrderItem,
1164 returns: &[RetItem],
1165 bound: &BTreeSet<String>,
1166 rel_bound: &BTreeSet<String>,
1167) -> Result<OrderItem, String> {
1168 let column = match &item.target {
1169 OrderTarget::Alias(name) => {
1170 if returns
1171 .iter()
1172 .any(|r| r.alias.as_deref() == Some(name.as_str()))
1173 {
1174 name.clone()
1175 } else {
1176 return Err(format!("ORDER BY target `{name}` is not present in RETURN"));
1177 }
1178 }
1179 OrderTarget::Var(v) => {
1180 require_bound(v, bound, "ORDER BY")?;
1181 reject_bare_rel(v, rel_bound)?;
1182 match returns
1183 .iter()
1184 .find(|r| matches!(&r.value, RetVal::Var(x) if x == v))
1185 {
1186 Some(r) => column_name(r),
1187 None => {
1188 return Err(format!("ORDER BY target `{v}` is not present in RETURN"));
1189 }
1190 }
1191 }
1192 OrderTarget::Prop { var, field } => {
1193 require_bound(var, bound, "ORDER BY")?;
1194 match returns.iter().find(
1195 |r| matches!(&r.value, RetVal::Prop { var: v, field: f } if v == var && f == field),
1196 ) {
1197 Some(r) => column_name(r),
1198 None => {
1199 return Err(format!(
1200 "ORDER BY target `{var}.{field}` is not present in RETURN"
1201 ));
1202 }
1203 }
1204 }
1205 };
1206 Ok(OrderItem {
1207 target: OrderTarget::Alias(column),
1208 descending: item.descending,
1209 })
1210}
1211
1212#[cfg(test)]
1213mod tests {
1214 use super::{plan, PlanOp};
1215 use crate::cypher::ast::{Expr, LimitSkip, Operand, OrderItem, OrderTarget, RetItem, RetVal};
1216 use crate::cypher::{lex, parse, RelDir};
1217 use crate::filter::CmpOp;
1218 use core_storage::Value;
1219
1220 fn plan_src(src: &str) -> Result<Vec<PlanOp>, String> {
1221 plan(&parse(&lex(src)?)?)
1222 }
1223
1224 fn assert_plan_err(src: &str, needle: &str) -> String {
1225 let result = std::panic::catch_unwind(|| plan_src(src));
1226 assert!(result.is_ok(), "plan({src:?}) panicked");
1227 let err = result
1228 .unwrap()
1229 .expect_err(&format!("plan({src:?}) must be Err"));
1230 assert!(
1231 err.contains(needle),
1232 "error must mention {needle:?}, got: {err}"
1233 );
1234 err
1235 }
1236
1237 #[test]
1243 fn dogfood_query_exact_plan() {
1244 let src = "\
1245MATCH (t:Talent {id: $tid}) \
1246MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
1247MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
1248WHERE i.score >= 0.5 AND s.score >= 0.5 \
1249RETURN c, i.score AS industry, s.score AS specialty \
1250ORDER BY industry DESC, specialty DESC \
1251LIMIT 10";
1252 let got = plan_src(src).expect("dogfood query must plan");
1253 let expected = vec![
1254 PlanOp::ScanKey {
1255 var: "t".into(),
1256 key: Operand::Param("tid".into()),
1257 label: Some("Talent".into()),
1258 },
1259 PlanOp::Expand {
1260 from: "t".into(),
1261 rel_var: Some("i".into()),
1262 etypes: vec!["INDUSTRY_ALIGNMENT".into()],
1263 dir: RelDir::Left,
1264 to: "c".into(),
1265 to_label: Some("Company".into()),
1266 to_props: vec![],
1267 },
1268 PlanOp::JoinBound {
1269 var: "c".into(),
1270 label: None,
1271 props: vec![],
1272 },
1273 PlanOp::Expand {
1274 from: "c".into(),
1275 rel_var: Some("s".into()),
1276 etypes: vec!["SPECIALTY_MATCH".into()],
1277 dir: RelDir::Right,
1278 to: "t".into(),
1279 to_label: None,
1280 to_props: vec![],
1281 },
1282 PlanOp::Filter {
1283 expr: Expr::And(
1284 Box::new(Expr::Cmp {
1285 lhs: Operand::Prop {
1286 var: "i".into(),
1287 field: "score".into(),
1288 },
1289 op: CmpOp::Ge,
1290 rhs: Operand::Lit(Value::Float(0.5)),
1291 }),
1292 Box::new(Expr::Cmp {
1293 lhs: Operand::Prop {
1294 var: "s".into(),
1295 field: "score".into(),
1296 },
1297 op: CmpOp::Ge,
1298 rhs: Operand::Lit(Value::Float(0.5)),
1299 }),
1300 ),
1301 },
1302 PlanOp::Project {
1303 items: vec![
1304 RetItem {
1305 value: RetVal::Var("c".into()),
1306 alias: None,
1307 },
1308 RetItem {
1309 value: RetVal::Prop {
1310 var: "i".into(),
1311 field: "score".into(),
1312 },
1313 alias: Some("industry".into()),
1314 },
1315 RetItem {
1316 value: RetVal::Prop {
1317 var: "s".into(),
1318 field: "score".into(),
1319 },
1320 alias: Some("specialty".into()),
1321 },
1322 ],
1323 },
1324 PlanOp::OrderBy {
1325 items: vec![
1326 OrderItem {
1327 target: OrderTarget::Alias("industry".into()),
1328 descending: true,
1329 },
1330 OrderItem {
1331 target: OrderTarget::Alias("specialty".into()),
1332 descending: true,
1333 },
1334 ],
1335 },
1336 PlanOp::Limit(LimitSkip::Exact(10)),
1337 ];
1338 assert_eq!(got, expected);
1339 }
1340
1341 #[test]
1346 fn anonymous_node_and_rel_names_are_stable() {
1347 let got = plan_src("MATCH ()-[]->(a) MATCH ()-[]->(a) RETURN a").unwrap();
1348 assert_eq!(
1349 got,
1350 vec![
1351 PlanOp::ScanLabel {
1352 var: "_n0".into(),
1353 label: None,
1354 },
1355 PlanOp::Expand {
1356 from: "_n0".into(),
1357 rel_var: Some("_r0".into()),
1358 etypes: vec![],
1359 dir: RelDir::Right,
1360 to: "a".into(),
1361 to_label: None,
1362 to_props: vec![],
1363 },
1364 PlanOp::Expand {
1365 from: "a".into(),
1366 rel_var: Some("_r1".into()),
1367 etypes: vec![],
1368 dir: RelDir::Left,
1369 to: "_n1".into(),
1370 to_label: None,
1371 to_props: vec![],
1372 },
1373 PlanOp::Project {
1374 items: vec![RetItem {
1375 value: RetVal::Var("a".into()),
1376 alias: None,
1377 }],
1378 },
1379 ]
1380 );
1381 }
1382
1383 #[test]
1384 fn props_on_scan_node_emit_scan_then_lookup() {
1385 let got = plan_src("MATCH (t:Talent {id: $tid}) RETURN t").unwrap();
1386 assert_eq!(
1387 got,
1388 vec![
1389 PlanOp::ScanKey {
1390 var: "t".into(),
1391 key: Operand::Param("tid".into()),
1392 label: Some("Talent".into()),
1393 },
1394 PlanOp::Project {
1395 items: vec![RetItem {
1396 value: RetVal::Var("t".into()),
1397 alias: None,
1398 }],
1399 },
1400 ]
1401 );
1402 }
1403
1404 #[test]
1405 fn mixed_id_map_stays_scan_label_then_lookup() {
1406 let got = plan_src("MATCH (t:Talent {id: $k, name: 'x'}) RETURN t").unwrap();
1407 assert_eq!(
1408 got,
1409 vec![
1410 PlanOp::ScanLabel {
1411 var: "t".into(),
1412 label: Some("Talent".into()),
1413 },
1414 PlanOp::LookupProps {
1415 var: "t".into(),
1416 props: vec![
1417 ("id".into(), Operand::Param("k".into())),
1418 ("name".into(), Operand::Lit(Value::Str("x".into()))),
1419 ],
1420 },
1421 PlanOp::Project {
1422 items: vec![RetItem {
1423 value: RetVal::Var("t".into()),
1424 alias: None,
1425 }],
1426 },
1427 ]
1428 );
1429 }
1430
1431 #[test]
1432 fn plan_id_map_is_scan_key() {
1433 let toks = crate::cypher::lex("MATCH (n:Person {id: $k}) RETURN n").unwrap();
1434 let q = crate::cypher::parse(&toks).unwrap();
1435 let ops = plan(&q).unwrap();
1436 assert!(matches!(ops[0], PlanOp::ScanKey { .. }), "{ops:?}");
1437 }
1438
1439 #[test]
1440 fn plan_expands_from_bound_key() {
1441 let cy =
1442 "MATCH (t:Talent {id: $tid}) MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) RETURN c";
1443 let ops = plan(&crate::cypher::parse(&crate::cypher::lex(cy).unwrap()).unwrap()).unwrap();
1444 assert!(matches!(&ops[0], PlanOp::ScanKey { var, .. } if var == "t"));
1446 match &ops[1] {
1447 PlanOp::Expand { from, dir, to, .. } => {
1448 assert_eq!(from, "t");
1449 assert_eq!(to, "c");
1450 assert_eq!(*dir, RelDir::Left);
1451 }
1452 other => panic!("{other:?}"),
1453 }
1454 }
1455
1456 #[test]
1460 fn plan_does_not_reverse_variable_length_from_bound() {
1461 let cy = "MATCH (t {id: $tid}) MATCH (c:Company)-[*1..2]->(t) RETURN c";
1462 let ops = plan_src(cy).unwrap();
1463 assert!(
1464 matches!(&ops[0], PlanOp::ScanKey { var, .. } if var == "t"),
1465 "{ops:?}"
1466 );
1467 assert!(
1468 matches!(&ops[1], PlanOp::ScanLabel { var, label } if var == "c" && label.as_deref() == Some("Company")),
1469 "{ops:?}"
1470 );
1471 match &ops[2] {
1472 PlanOp::VarExpand {
1473 from,
1474 dir,
1475 to,
1476 min,
1477 max,
1478 ..
1479 } => {
1480 assert_eq!(from, "c");
1481 assert_eq!(to, "t");
1482 assert_eq!(*dir, RelDir::Right);
1483 assert_eq!(*min, 1);
1484 assert_eq!(*max, 2);
1485 }
1486 other => panic!("{other:?}"),
1487 }
1488 }
1489
1490 #[test]
1491 fn unbound_var_in_where_is_err() {
1492 let err = assert_plan_err("MATCH (a) WHERE b.x = 1 RETURN a", "b");
1493 assert!(
1494 err.to_ascii_lowercase().contains("unbound")
1495 && err.to_ascii_lowercase().contains("where"),
1496 "expected unbound-in-WHERE context, got: {err}"
1497 );
1498 }
1499
1500 #[test]
1501 fn unbound_var_in_return_is_err() {
1502 let err = assert_plan_err("MATCH (a) RETURN b", "b");
1503 assert!(
1504 err.to_ascii_lowercase().contains("unbound")
1505 && err.to_ascii_lowercase().contains("return"),
1506 "expected unbound-in-RETURN context, got: {err}"
1507 );
1508 }
1509
1510 #[test]
1511 fn unbound_var_in_order_by_is_err() {
1512 let err = assert_plan_err("MATCH (a) RETURN a ORDER BY b", "b");
1513 assert!(
1514 err.to_ascii_lowercase().contains("unbound")
1515 && (err.to_ascii_lowercase().contains("order")),
1516 "expected unbound-in-ORDER context, got: {err}"
1517 );
1518 }
1519
1520 #[test]
1521 fn duplicate_alias_is_err() {
1522 let err = assert_plan_err("MATCH (a) RETURN a AS x, a.id AS x", "x");
1523 assert!(
1524 err.to_ascii_lowercase().contains("duplicate")
1525 && err.to_ascii_lowercase().contains("alias"),
1526 "expected duplicate-alias context, got: {err}"
1527 );
1528 }
1529
1530 #[test]
1531 fn duplicate_column_name_is_err() {
1532 let err = assert_plan_err("MATCH (a) RETURN a, a", "a");
1533 assert!(
1534 err.to_ascii_lowercase().contains("duplicate")
1535 && err.to_ascii_lowercase().contains("column"),
1536 "expected duplicate-column context, got: {err}"
1537 );
1538 }
1539
1540 #[test]
1541 fn order_by_target_absent_from_return_is_err() {
1542 let err = assert_plan_err("MATCH (a) RETURN a ORDER BY a.x", "a.x");
1544 assert!(
1545 err.to_ascii_lowercase().contains("return"),
1546 "expected ORDER BY target-not-in-RETURN context, got: {err}"
1547 );
1548 }
1549
1550 #[test]
1553 fn order_by_targets_rewrite_to_projected_column_names() {
1554 let got = plan_src(
1555 "MATCH (a)-[r]->(b) \
1556 RETURN a, a.name AS nm, b.age \
1557 ORDER BY nm DESC, a ASC, b.age",
1558 )
1559 .unwrap();
1560 let order = got
1561 .iter()
1562 .find_map(|op| match op {
1563 PlanOp::OrderBy { items } => Some(items),
1564 _ => None,
1565 })
1566 .expect("plan must contain OrderBy");
1567 assert_eq!(
1568 order,
1569 &vec![
1570 OrderItem {
1571 target: OrderTarget::Alias("nm".into()),
1572 descending: true,
1573 },
1574 OrderItem {
1575 target: OrderTarget::Alias("a".into()),
1576 descending: false,
1577 },
1578 OrderItem {
1579 target: OrderTarget::Alias("b.age".into()),
1580 descending: false,
1581 },
1582 ]
1583 );
1584
1585 let aliased_var = plan_src("MATCH (a) RETURN a AS person ORDER BY a").unwrap();
1586 let order = aliased_var
1587 .iter()
1588 .find_map(|op| match op {
1589 PlanOp::OrderBy { items } => Some(items),
1590 _ => None,
1591 })
1592 .expect("plan must contain OrderBy");
1593 assert_eq!(
1594 order,
1595 &vec![OrderItem {
1596 target: OrderTarget::Alias("person".into()),
1597 descending: false,
1598 }]
1599 );
1600 }
1601
1602 #[test]
1603 fn bound_pattern_start_is_join_bound_then_expand() {
1604 let got = plan_src("MATCH (a:L) MATCH (a)-[r:T]->(b) RETURN a, b").unwrap();
1605 assert_eq!(
1606 got,
1607 vec![
1608 PlanOp::ScanLabel {
1609 var: "a".into(),
1610 label: Some("L".into()),
1611 },
1612 PlanOp::JoinBound {
1613 var: "a".into(),
1614 label: None,
1615 props: vec![],
1616 },
1617 PlanOp::Expand {
1618 from: "a".into(),
1619 rel_var: Some("r".into()),
1620 etypes: vec!["T".into()],
1621 dir: RelDir::Right,
1622 to: "b".into(),
1623 to_label: None,
1624 to_props: vec![],
1625 },
1626 PlanOp::Project {
1627 items: vec![
1628 RetItem {
1629 value: RetVal::Var("a".into()),
1630 alias: None,
1631 },
1632 RetItem {
1633 value: RetVal::Var("b".into()),
1634 alias: None,
1635 },
1636 ],
1637 },
1638 ]
1639 );
1640 }
1641
1642 #[test]
1643 fn bound_dest_extra_checks_ride_on_expand() {
1644 let got = plan_src("MATCH (t:Talent) MATCH (c)-[r]->(t:Talent {id: 1}) RETURN t").unwrap();
1645 assert_eq!(
1646 got,
1647 vec![
1648 PlanOp::ScanLabel {
1649 var: "t".into(),
1650 label: Some("Talent".into()),
1651 },
1652 PlanOp::JoinBound {
1653 var: "t".into(),
1654 label: Some("Talent".into()),
1655 props: vec![("id".into(), Operand::Lit(Value::Int(1)))],
1656 },
1657 PlanOp::Expand {
1658 from: "t".into(),
1659 rel_var: Some("r".into()),
1660 etypes: vec![],
1661 dir: RelDir::Left,
1662 to: "c".into(),
1663 to_label: None,
1664 to_props: vec![],
1665 },
1666 PlanOp::Project {
1667 items: vec![RetItem {
1668 value: RetVal::Var("t".into()),
1669 alias: None,
1670 }],
1671 },
1672 ]
1673 );
1674 }
1675
1676 #[test]
1677 fn return_distinct_emits_distinct_after_project() {
1678 let ops = plan_src("MATCH (n) RETURN DISTINCT n").expect("DISTINCT must plan");
1679 let proj = ops
1680 .iter()
1681 .position(|op| matches!(op, PlanOp::Project { .. }))
1682 .expect("Project");
1683 assert!(
1684 matches!(ops.get(proj + 1), Some(PlanOp::Distinct)),
1685 "DISTINCT must follow Project, got: {ops:?}"
1686 );
1687 let bounded = plan_src("MATCH (n) RETURN DISTINCT n LIMIT 1").unwrap();
1688 assert!(
1689 super::row_bound(&bounded).is_none(),
1690 "DISTINCT + LIMIT must not push LIMIT into producers"
1691 );
1692 }
1693
1694 #[test]
1695 fn skip_then_limit_follow_project() {
1696 let got = plan_src("MATCH (a) RETURN a SKIP 2 LIMIT 3").unwrap();
1697 assert_eq!(
1698 got,
1699 vec![
1700 PlanOp::ScanLabel {
1701 var: "a".into(),
1702 label: None,
1703 },
1704 PlanOp::Project {
1705 items: vec![RetItem {
1706 value: RetVal::Var("a".into()),
1707 alias: None,
1708 }],
1709 },
1710 PlanOp::Skip(LimitSkip::Exact(2)),
1711 PlanOp::Limit(LimitSkip::Exact(3)),
1712 ]
1713 );
1714 }
1715
1716 #[test]
1717 fn aliased_prop_order_by_rewrites_to_alias_column() {
1718 let got = plan_src("MATCH (a) RETURN a.name AS nm ORDER BY a.name").unwrap();
1719 let order = got
1720 .iter()
1721 .find_map(|op| match op {
1722 PlanOp::OrderBy { items } => Some(items),
1723 _ => None,
1724 })
1725 .unwrap();
1726 assert_eq!(
1727 order,
1728 &vec![OrderItem {
1729 target: OrderTarget::Alias("nm".into()),
1730 descending: false,
1731 }]
1732 );
1733 }
1734
1735 #[test]
1736 fn plan_never_panics_on_hand_built_query() {
1737 use crate::cypher::ast::{NodePat, Pattern, Query};
1738 let q = Query {
1739 matches: vec![],
1740 optional_clauses: vec![],
1741 where_expr: None,
1742 unwinds: vec![],
1743 post_unwind_where: None,
1744 stages: vec![],
1745 returns: vec![],
1746 order_by: vec![],
1747 distinct: false,
1748 skip: None,
1749 limit: None,
1750 };
1751 let result = std::panic::catch_unwind(|| plan(&q));
1752 assert!(result.is_ok(), "plan panicked on empty Query");
1753 let _ = result.unwrap();
1754
1755 let q = Query {
1756 matches: vec![Pattern {
1757 start: NodePat {
1758 var: None,
1759 label: None,
1760 props: vec![],
1761 },
1762 chain: vec![],
1763 shortest: false,
1764 }],
1765 optional_clauses: vec![],
1766 where_expr: Some(Expr::Not(Box::new(Expr::Cmp {
1767 lhs: Operand::Param("p".into()),
1768 op: CmpOp::Eq,
1769 rhs: Operand::Lit(Value::Int(1)),
1770 }))),
1771 unwinds: vec![],
1772 post_unwind_where: None,
1773 stages: vec![],
1774 returns: vec![],
1775 distinct: false,
1776 order_by: vec![OrderItem {
1777 target: OrderTarget::Alias("missing".into()),
1778 descending: true,
1779 }],
1780 skip: Some(LimitSkip::Exact(0)),
1781 limit: Some(LimitSkip::Exact(0)),
1782 };
1783 let result = std::panic::catch_unwind(|| plan(&q));
1784 assert!(result.is_ok(), "plan panicked on hand-built Query");
1785 let _ = result.unwrap();
1786 }
1787
1788 #[test]
1789 fn bare_relationship_var_in_return_is_err() {
1790 let err = assert_plan_err("MATCH (a)-[r:T]->(b) RETURN r", "r");
1791 assert!(
1792 err.to_ascii_lowercase().contains("relationship"),
1793 "expected bare-rel RETURN guidance, got: {err}"
1794 );
1795 }
1796
1797 #[test]
1798 fn relationship_prop_in_return_is_ok() {
1799 plan_src("MATCH (a)-[r:T]->(b) RETURN r.w").expect("rel prop RETURN must plan");
1800 }
1801
1802 #[test]
1803 fn bare_relationship_var_in_order_by_is_err() {
1804 let err = assert_plan_err("MATCH (a)-[r:T]->(b) RETURN r.w ORDER BY r", "r");
1807 assert!(
1808 err.to_ascii_lowercase().contains("relationship"),
1809 "expected bare-rel ORDER BY guidance, got: {err}"
1810 );
1811 }
1812
1813 #[test]
1814 fn relationship_prop_in_order_by_is_ok() {
1815 plan_src("MATCH (a)-[r:T]->(b) RETURN r.w ORDER BY r.w")
1816 .expect("rel prop ORDER BY must plan");
1817 }
1818
1819 #[test]
1822 fn var_expand_op_emitted_for_star_rel() {
1823 use super::row_bound;
1824 let ops = plan_src("MATCH (a)-[r:T*2..4]->(b) RETURN b").unwrap();
1825 let has_var = ops
1826 .iter()
1827 .any(|op| matches!(op, PlanOp::VarExpand { min: 2, max: 4, .. }));
1828 assert!(has_var, "expected VarExpand(2..4) in plan, got: {ops:?}");
1829 assert_eq!(
1831 row_bound(&ops),
1832 None,
1833 "VarExpand plan must not use pull path"
1834 );
1835 }
1836
1837 #[test]
1838 fn var_expand_with_limit_still_takes_staged_path() {
1839 use super::row_bound;
1840 let ops = plan_src("MATCH (a)-[r:T*1..3]->(b) RETURN b LIMIT 5").unwrap();
1841 assert_eq!(
1843 row_bound(&ops),
1844 None,
1845 "VarExpand + LIMIT must still use staged path"
1846 );
1847 let has_var = ops.iter().any(|op| matches!(op, PlanOp::VarExpand { .. }));
1848 assert!(has_var, "plan must contain VarExpand");
1849 let has_limit = ops
1850 .iter()
1851 .any(|op| matches!(op, PlanOp::Limit(LimitSkip::Exact(5))));
1852 assert!(has_limit, "plan must still emit Limit op");
1853 }
1854
1855 #[test]
1856 fn shortest_path_op_emitted_for_shortest_path_clause() {
1857 let ops =
1858 plan_src("MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*..3]->(b)) RETURN a")
1859 .unwrap();
1860 let has_sp = ops
1861 .iter()
1862 .any(|op| matches!(op, PlanOp::ShortestPath { max_hops: 3, .. }));
1863 assert!(
1864 has_sp,
1865 "expected ShortestPath op with max_hops=3, got: {ops:?}"
1866 );
1867 }
1868
1869 #[test]
1870 fn shortest_path_unbound_endpoint_is_err() {
1871 let err = assert_plan_err(
1872 "MATCH shortestPath((a)-[r:T*..3]->(b)) RETURN a",
1873 "shortestPath",
1874 );
1875 assert!(
1876 err.contains("not bound") || err.contains("bound"),
1877 "error must mention binding, got: {err}"
1878 );
1879 }
1880
1881 #[test]
1882 fn var_expand_rel_var_is_in_rel_bound() {
1883 plan_src("MATCH (a)-[r:T*1..3]->(b) RETURN r.length").expect("r.length must plan");
1885 assert_plan_err("MATCH (a)-[r:T*1..3]->(b) RETURN r", "r");
1887 }
1888
1889 #[test]
1890 fn shortest_path_min_gt_1_is_plan_err() {
1891 let err = assert_plan_err(
1893 "MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*2..5]->(b)) RETURN r.length",
1894 "shortestPath",
1895 );
1896 assert!(
1897 err.contains("minimum"),
1898 "error must mention minimum hop count, got: {err}"
1899 );
1900 }
1901
1902 fn subscribable(src: &str) -> bool {
1905 let ops = plan_src(src).expect("must plan");
1906 super::is_subscribable(&ops)
1907 }
1908
1909 #[test]
1910 fn is_subscribable_passes_simple_label_scan() {
1911 assert!(subscribable("MATCH (n:Person) RETURN n"));
1912 assert!(subscribable("MATCH (n:Person) WHERE n.age > 18 RETURN n"));
1913 assert!(subscribable("MATCH (n:Person) RETURN n LIMIT 100"));
1914 }
1915
1916 #[test]
1917 fn is_subscribable_passes_single_hop_expand() {
1918 assert!(subscribable(
1919 "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a"
1920 ));
1921 assert!(subscribable(
1922 "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a LIMIT 50"
1923 ));
1924 }
1925
1926 #[test]
1927 fn is_subscribable_rejects_multi_hop_expand() {
1928 assert!(
1930 !subscribable("MATCH (a:Person)-[r1:KNOWS]->(b:Person)-[r2:LIKES]->(c:Thing) RETURN a"),
1931 "two-hop chain must be rejected"
1932 );
1933 }
1934
1935 #[test]
1936 fn is_subscribable_rejects_skip() {
1937 assert!(
1939 !subscribable("MATCH (n:Person) RETURN n SKIP 10 LIMIT 50"),
1940 "SKIP must be rejected"
1941 );
1942 assert!(
1943 !subscribable("MATCH (n:Person) RETURN n SKIP 10"),
1944 "bare SKIP must be rejected"
1945 );
1946 }
1947
1948 #[test]
1949 fn is_subscribable_rejects_order_by() {
1950 assert!(!subscribable("MATCH (n:Person) RETURN n ORDER BY n"));
1951 }
1952
1953 #[test]
1954 fn is_subscribable_rejects_aggregates() {
1955 assert!(!subscribable("MATCH (n:Person) RETURN COUNT(*)"));
1956 }
1957
1958 #[test]
1959 fn is_subscribable_rejects_var_expand() {
1960 assert!(!subscribable(
1961 "MATCH (a:Person)-[r:KNOWS*1..3]->(b) RETURN b"
1962 ));
1963 }
1964}