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