1use crate::cypher::ast::{
4 ret_val_label, AggArg, AggFunc, Expr, LimitSkip, Operand, OrderItem, OrderTarget, RetItem,
5 RetVal, UnwindExpr,
6};
7use crate::cypher::plan::PlanOp;
8use crate::cypher::RelDir;
9use crate::filter::eval_cmp;
10use crate::result::ResultSet;
11use crate::traverse::{expand, Dir, EdgeRef};
12use crate::value_ops::{cmp_optional, values_equal};
13use crate::view::GraphView;
14use core_storage::{Value, ValueKey};
15use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
16
17#[cfg(test)]
21static FUSED_SCAN_FIRES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
22
23#[cfg(test)]
27static SCAN_KEY_FIRES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
28
29#[cfg(test)]
32pub static INDEX_SCAN_FIRES: std::sync::atomic::AtomicUsize =
33 std::sync::atomic::AtomicUsize::new(0);
34
35#[cfg(test)]
39pub static INDEX_INTERSECT_FIRES: std::sync::atomic::AtomicUsize =
40 std::sync::atomic::AtomicUsize::new(0);
41
42pub struct Params<'a>(pub &'a BTreeMap<String, Value>);
45
46#[derive(Clone, Debug)]
47enum Cell {
48 Node(u32),
49 Rel(EdgeRef),
50 Path(u8),
53 Scalar(Value),
55}
56
57type Row = Vec<Option<Cell>>;
60
61struct VarTable {
62 names: Vec<String>,
63}
64
65impl VarTable {
66 fn intern(&mut self, name: &str) -> usize {
67 if let Some(i) = self.names.iter().position(|n| n == name) {
68 return i;
69 }
70 self.names.push(name.to_string());
71 self.names.len() - 1
72 }
73
74 fn slot(&self, name: &str) -> Option<usize> {
75 self.names.iter().position(|n| n == name)
76 }
77}
78
79struct Projected {
80 columns: Vec<String>,
81 rows: Vec<Vec<Option<Value>>>,
82}
83
84const MAX_INTERMEDIATE_ROWS: usize = 1_000_000;
87
88const MAX_GROUPS: usize = 1_000_000;
90
91type GroupKey = Vec<Option<ValueKey>>;
94type GroupEntry = (Vec<Option<Value>>, Vec<AggAcc>);
99
100#[cfg(test)]
101thread_local! {
102 static TEST_MAX_INTERMEDIATE_ROWS: std::cell::Cell<Option<usize>> =
103 const { std::cell::Cell::new(None) };
104 static TEST_EXPAND_PRODUCED: std::cell::Cell<Option<usize>> =
107 const { std::cell::Cell::new(None) };
108 static TEST_MAX_GROUPS: std::cell::Cell<Option<usize>> =
110 const { std::cell::Cell::new(None) };
111}
112
113fn max_intermediate_rows() -> usize {
114 #[cfg(test)]
115 {
116 TEST_MAX_INTERMEDIATE_ROWS
117 .with(|c| c.get())
118 .unwrap_or(MAX_INTERMEDIATE_ROWS)
119 }
120 #[cfg(not(test))]
121 {
122 MAX_INTERMEDIATE_ROWS
123 }
124}
125
126fn max_groups() -> usize {
127 #[cfg(test)]
128 {
129 TEST_MAX_GROUPS.with(|c| c.get()).unwrap_or(MAX_GROUPS)
130 }
131 #[cfg(not(test))]
132 {
133 MAX_GROUPS
134 }
135}
136
137#[cfg(test)]
140pub(crate) fn with_max_groups<R>(cap: usize, f: impl FnOnce() -> R) -> R {
141 TEST_MAX_GROUPS.with(|c| {
142 let prev = c.replace(Some(cap));
143 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
144 c.set(prev);
145 match result {
146 Ok(v) => v,
147 Err(p) => std::panic::resume_unwind(p),
148 }
149 })
150}
151
152#[cfg(test)]
156pub(crate) fn with_max_intermediate_rows<R>(cap: usize, f: impl FnOnce() -> R) -> R {
157 TEST_MAX_INTERMEDIATE_ROWS.with(|c| {
158 let prev = c.replace(Some(cap));
159 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
160 c.set(prev);
161 match result {
162 Ok(v) => v,
163 Err(p) => std::panic::resume_unwind(p),
164 }
165 })
166}
167
168#[cfg(test)]
170fn record_expand_row() {
171 TEST_EXPAND_PRODUCED.with(|c| {
172 if let Some(prev) = c.get() {
173 c.set(Some(prev + 1));
174 }
175 });
176}
177
178#[cfg(test)]
184pub(crate) fn with_expand_counter<R>(f: impl FnOnce() -> R) -> (R, usize) {
185 TEST_EXPAND_PRODUCED.with(|c| c.set(Some(0)));
186 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
187 let count = TEST_EXPAND_PRODUCED.with(|c| c.get().unwrap_or(0));
188 TEST_EXPAND_PRODUCED.with(|c| c.set(None));
189 match result {
190 Ok(v) => (v, count),
191 Err(p) => std::panic::resume_unwind(p),
192 }
193}
194
195fn row_cap_err(cap: usize) -> String {
196 format!(
197 "intermediate result exceeds {cap} rows; add a LIMIT or constrain patterns with shared variables"
198 )
199}
200
201fn group_cap_err() -> String {
202 format!(
203 "group count exceeds {} distinct keys; add a WHERE clause or constrain the grouping key",
204 max_groups()
205 )
206}
207
208fn group_key_normalize(v: &Value) -> Option<ValueKey> {
219 match v {
220 Value::Int(n) => Some(ValueKey::FloatBits((*n as f64).to_bits())),
221 Value::Float(f) => Some(ValueKey::FloatBits(f.to_bits())),
222 _ => ValueKey::from_value(v),
223 }
224}
225
226pub fn execute(view: &GraphView, plan: &[PlanOp], params: &Params) -> Result<ResultSet, String> {
238 use crate::cypher::plan::row_bound;
239 execute_inner(view, plan, params, row_bound(plan))
240}
241
242pub fn execute_union(
247 view: &GraphView,
248 union: &crate::cypher::parser::UnionQuery,
249 params: &Params,
250) -> Result<ResultSet, String> {
251 let mut acc: Option<ResultSet> = None;
252 for (i, part) in union.parts.iter().enumerate() {
253 let ops = crate::cypher::plan::plan(part)?;
254 let rs = execute(view, &ops, params)?;
255 acc = Some(match acc {
256 None => rs,
257 Some(prev) => {
258 if prev.columns() != rs.columns() {
259 return Err(format!(
260 "UNION requires matching column names across all parts; \
261 got {:?} then {:?}",
262 prev.columns(),
263 rs.columns()
264 ));
265 }
266 let all = union.all_flags.get(i - 1).copied().unwrap_or(false);
268 combine_result_sets(prev, rs, all)
269 }
270 });
271 }
272 Ok(acc.expect("UNION query has at least one part"))
274}
275
276fn combine_result_sets(mut acc: ResultSet, other: ResultSet, all: bool) -> ResultSet {
277 for i in 0..other.len() {
278 acc.push_row(other.row(i).to_vec());
279 }
280 if !all {
281 let mut seen: Vec<Vec<Option<Value>>> = Vec::new();
283 for i in 0..acc.len() {
284 let r = acc.row(i).to_vec();
285 if !seen.contains(&r) {
286 seen.push(r);
287 }
288 }
289 let mut deduped = ResultSet::new(acc.columns().to_vec());
290 for r in seen {
291 deduped.push_row(r);
292 }
293 return deduped;
294 }
295 acc
296}
297
298#[cfg(test)]
303pub(crate) fn execute_unbounded(
304 view: &GraphView,
305 plan: &[PlanOp],
306 params: &Params,
307) -> Result<ResultSet, String> {
308 execute_inner(view, plan, params, None)
309}
310
311fn execute_inner(
312 view: &GraphView,
313 plan: &[PlanOp],
314 params: &Params,
315 row_bound: Option<usize>,
316) -> Result<ResultSet, String> {
317 check_params(plan, params)?;
318
319 let has_var_expand = plan
325 .iter()
326 .any(|op| matches!(op, PlanOp::VarExpand { .. } | PlanOp::ShortestPath { .. }));
327
328 let is_pipeline = plan.iter().any(|op| {
345 matches!(
346 op,
347 PlanOp::With { .. } | PlanOp::Unwind { .. } | PlanOp::LeftOuterApply { .. }
348 )
349 }) || {
350 let mut saw_gagg = false;
351 plan.iter().any(|op| {
352 if matches!(op, PlanOp::GroupAggregate { .. }) {
353 saw_gagg = true;
354 false
355 } else {
356 saw_gagg && matches!(op, PlanOp::Filter { .. } | PlanOp::Project { .. })
357 }
358 })
359 };
360
361 if !has_var_expand
365 && !is_pipeline
366 && plan
367 .iter()
368 .any(|op| matches!(op, PlanOp::GroupAggregate { .. }))
369 {
370 return execute_group_aggregate(view, plan, params);
371 }
372
373 if !has_var_expand
377 && !is_pipeline
378 && plan.iter().any(|op| matches!(op, PlanOp::Aggregate { .. }))
379 {
380 return execute_aggregate(view, plan, params);
381 }
382
383 if let Some(bound) = row_bound {
387 return execute_pull(view, plan, params, bound);
388 }
389
390 let vars = collect_vars(plan);
392 let mut rows: Vec<Row> = vec![vec![None; vars.names.len()]];
393 let mut projected: Option<Projected> = None;
394 let mut pipeline_group_columns: Option<Vec<String>> = None;
397
398 for op in plan {
399 match op {
400 PlanOp::ScanLabel { var, label } => {
401 rows = scan_label(view, &vars, &rows, var, label.as_deref())?;
402 }
403 PlanOp::ScanKey { var, key, label } => {
404 rows = scan_key(view, &vars, &rows, var, key, label.as_deref(), params)?;
405 }
406 PlanOp::IndexScan {
407 var,
408 label,
409 field,
410 value,
411 } => {
412 rows = scan_index(
413 view,
414 &vars,
415 &rows,
416 var,
417 label.as_deref(),
418 field,
419 value,
420 params,
421 )?;
422 }
423 PlanOp::IndexIntersect {
424 var,
425 label,
426 equalities,
427 } => {
428 rows = scan_intersect(
429 view,
430 &vars,
431 &rows,
432 var,
433 label.as_deref(),
434 equalities,
435 params,
436 )?;
437 }
438 PlanOp::LookupProps { var, props } => {
439 rows = retain_node(view, &vars, &rows, var, None, props, params)?;
440 }
441 PlanOp::JoinBound { var, label, props } => {
442 rows = retain_node(view, &vars, &rows, var, label.as_deref(), props, params)?;
443 }
444 PlanOp::Expand { .. } => {
445 rows = exec_expand(view, &vars, &rows, op, params)?;
446 }
447 PlanOp::VarExpand {
448 from,
449 rel_var,
450 etypes,
451 dir,
452 to,
453 min,
454 max,
455 } => {
456 rows = exec_var_expand(
457 view, &vars, &rows, from, rel_var, etypes, *dir, to, *min, *max,
458 )?;
459 }
460 PlanOp::ShortestPath {
461 from,
462 rel_var,
463 etypes,
464 dir,
465 to,
466 max_hops,
467 } => {
468 rows = exec_shortest_path(
469 view, &vars, &rows, from, rel_var, etypes, *dir, to, *max_hops,
470 )?;
471 }
472 PlanOp::Filter { expr } => {
473 rows = exec_filter(view, &vars, &rows, expr, params)?;
474 }
475 PlanOp::Project { items } => {
476 projected = Some(exec_project(view, &vars, &rows, items, params)?);
477 }
478 PlanOp::Distinct => {
479 if let Some(table) = projected.as_mut() {
480 exec_distinct(table)?;
481 } else {
482 return Err("DISTINCT requires a Project".into());
483 }
484 }
485 PlanOp::OrderBy { items } => {
486 if let Some(table) = projected.as_mut() {
487 exec_order_by(table, items)?;
488 } else {
489 exec_order_by_rows(&vars, &mut rows, items, view);
491 }
492 }
493 PlanOp::Skip(ls) => {
494 let n = resolve_ls(ls, params)?;
495 if let Some(table) = projected.as_mut() {
496 apply_skip(&mut table.rows, n);
497 } else {
498 apply_skip(&mut rows, n);
499 }
500 }
501 PlanOp::Limit(ls) => {
502 let n = resolve_ls(ls, params)?;
503 if let Some(table) = projected.as_mut() {
504 apply_limit(&mut table.rows, n);
505 } else {
506 apply_limit(&mut rows, n);
507 }
508 }
509 PlanOp::GroupAggregate { keys, aggs } => {
514 let mut grp_groups: HashMap<GroupKey, GroupEntry> = HashMap::new();
515 let mut grp_key_order: Vec<GroupKey> = Vec::new();
516 let mut grp_cells: HashMap<GroupKey, Vec<Option<Cell>>> = HashMap::new();
523 for row in &rows {
524 let mut gk: GroupKey = Vec::with_capacity(keys.len());
525 let mut display_vals: Vec<Option<Value>> = Vec::with_capacity(keys.len());
526 for (_, item) in keys {
527 let val = project_item(view, &vars, row, item, params)?;
528 gk.push(val.as_ref().and_then(group_key_normalize));
529 display_vals.push(val);
530 }
531 if !grp_groups.contains_key(&gk) {
532 if grp_groups.len() >= max_groups() {
533 return Err(group_cap_err());
534 }
535 grp_key_order.push(gk.clone());
536 grp_cells.insert(gk.clone(), key_source_cells(&vars, row, keys));
537 grp_groups.insert(
538 gk.clone(),
539 (
540 display_vals,
541 aggs.iter().map(|(f, a, _)| AggAcc::for_arg(f, a)).collect(),
542 ),
543 );
544 }
545 let (_, accs) = grp_groups.get_mut(&gk).unwrap();
546 for (acc, (func, arg, _)) in accs.iter_mut().zip(aggs.iter()) {
547 update_acc(view, &vars, row, func, arg, acc)?;
548 }
549 }
550 if keys.is_empty() && grp_key_order.is_empty() {
553 let empty_key: GroupKey = vec![];
554 grp_key_order.push(empty_key.clone());
555 grp_groups.insert(
556 empty_key,
557 (
558 vec![],
559 aggs.iter().map(|(f, a, _)| AggAcc::for_arg(f, a)).collect(),
560 ),
561 );
562 }
563 if is_pipeline {
564 rows = group_result_to_rows(
567 keys,
568 aggs,
569 grp_key_order,
570 &mut grp_groups,
571 &mut grp_cells,
572 &vars,
573 );
574 projected = None;
575 let mut cols: Vec<String> = keys.iter().map(|(c, _)| c.clone()).collect();
578 cols.extend(aggs.iter().map(|(_, _, c)| c.clone()));
579 pipeline_group_columns = Some(cols);
580 } else {
581 projected = Some(build_group_projected(
582 keys,
583 aggs,
584 grp_key_order,
585 &mut grp_groups,
586 ));
587 pipeline_group_columns = None;
588 }
589 }
590 PlanOp::With {
593 items,
594 where_expr,
595 order_by,
596 skip,
597 limit,
598 } => {
599 let row_len = vars.names.len();
603 let mut new_rows: Vec<Row> = Vec::with_capacity(rows.len());
604 for row in &rows {
605 let mut new_row: Row = vec![None; row_len];
606 for item in items {
607 let col = column_name(item);
608 let Some(dst_slot) = vars.slot(&col) else {
609 continue;
610 };
611 match &item.value {
612 RetVal::Var(v) => {
613 if let Some(src_slot) = vars.slot(v) {
615 new_row[dst_slot] = row.get(src_slot).cloned().flatten();
616 }
617 }
618 RetVal::Prop { var, field } => {
619 let val = resolve_prop(view, &vars, row, var, field)?;
620 new_row[dst_slot] = val.map(Cell::Scalar);
621 }
622 RetVal::Agg { .. } => {} RetVal::FuncCall { name, args } => {
624 let val = eval_func(name, args, view, &vars, row, params)?;
625 new_row[dst_slot] = val.map(Cell::Scalar);
626 }
627 RetVal::ScalarExpr(op) => {
628 let val = resolve_operand(view, &vars, row, op, params)?;
629 new_row[dst_slot] = val.map(Cell::Scalar);
630 }
631 }
632 }
633 new_rows.push(new_row);
634 }
635 rows = new_rows;
636 if let Some(expr) = where_expr {
638 rows = exec_filter(view, &vars, &rows, expr, params)?;
639 }
640 if !order_by.is_empty() {
642 exec_order_by_rows(&vars, &mut rows, order_by, view);
643 }
644 if let Some(ls) = skip {
645 let n = resolve_ls(ls, params)?;
646 apply_skip(&mut rows, n);
647 }
648 if let Some(ls) = limit {
649 let n = resolve_ls(ls, params)?;
650 apply_limit(&mut rows, n);
651 }
652 }
653 PlanOp::Unwind { expr, alias } => {
655 let alias_slot = vars
656 .slot(alias)
657 .ok_or_else(|| format!("UNWIND alias `{alias}` not in VarTable"))?;
658 let cap = max_intermediate_rows();
659 let mut new_rows: Vec<Row> = Vec::new();
660 for row in &rows {
661 let list_val: Option<Value> = match expr {
662 UnwindExpr::Lit(vals) => Some(Value::List(vals.clone())),
663 UnwindExpr::Prop { var, field } => {
664 resolve_prop(view, &vars, row, var, field)?
665 }
666 UnwindExpr::Var(name) => {
667 let slot = vars
668 .slot(name)
669 .ok_or_else(|| format!("UNWIND variable `{name}` is not bound"))?;
670 match row.get(slot).and_then(|c| c.as_ref()) {
671 Some(Cell::Scalar(v)) => Some(v.clone()),
672 Some(Cell::Node(_) | Cell::Rel(_) | Cell::Path(_)) => {
673 return Err(format!(
674 "UNWIND requires a list; `{name}` is bound to a graph element"
675 ));
676 }
677 None => None,
678 }
679 }
680 };
681 match list_val {
682 None => {} Some(Value::List(items_list)) => {
684 for item_val in items_list {
686 if new_rows.len() >= cap {
687 return Err(row_cap_err(cap));
688 }
689 let mut new_row = row.clone();
690 new_row[alias_slot] = Some(Cell::Scalar(item_val));
691 new_rows.push(new_row);
692 }
693 }
694 Some(other) => {
695 let type_name = match &other {
696 Value::Int(_) => "Int",
697 Value::Float(_) => "Float",
698 Value::Str(_) => "Str",
699 Value::Bool(_) => "Bool",
700 Value::List(_) => unreachable!(),
701 Value::Map(_) => "Map",
702 };
703 return Err(format!(
704 "UNWIND requires a list; got {type_name} value for `{alias}`"
705 ));
706 }
707 }
708 }
709 rows = new_rows;
710 }
711 PlanOp::Aggregate { func, arg, column } => {
716 let ctx = AggStreamCtx {
717 view,
718 vars: &vars,
719 params,
720 func,
721 arg,
722 };
723 let mut acc = AggAcc::for_arg(func, arg);
724 for row in &rows {
725 agg_stream(&ctx, &[], row, &mut acc)?;
727 }
728 let value = acc.finish();
729 let mut rs = ResultSet::new(vec![column.clone()]);
730 rs.push_row(vec![value]);
731 return Ok(rs);
732 }
733 PlanOp::LeftOuterApply {
739 inner,
740 optional_vars,
741 } => {
742 let cap = max_intermediate_rows();
743 let mut new_rows: Vec<Row> = Vec::new();
744 for outer_row in &rows {
745 let inner_seed: Vec<Row> = vec![outer_row.clone()];
747 let inner_result =
750 exec_left_outer_inner(view, &vars, inner_seed, inner, params)?;
751 if inner_result.is_empty() {
752 let mut null_row = outer_row.clone();
754 for v in optional_vars {
755 if let Some(slot) = vars.slot(v) {
756 null_row[slot] = None;
757 }
758 }
759 if new_rows.len() >= cap {
760 return Err(row_cap_err(cap));
761 }
762 new_rows.push(null_row);
763 } else {
764 for r in inner_result {
765 if new_rows.len() >= cap {
766 return Err(row_cap_err(cap));
767 }
768 new_rows.push(r);
769 }
770 }
771 }
772 rows = new_rows;
773 }
774 }
775 }
776
777 Ok(match projected {
778 Some(table) => finish(table),
779 None => {
780 if let Some(cols) = pipeline_group_columns {
783 let mut rs = ResultSet::new(cols.clone());
784 for row in rows {
785 let vals: Vec<Option<Value>> = cols
786 .iter()
787 .map(|col| {
788 vars.slot(col)
789 .and_then(|s| row.get(s))
790 .and_then(|c| c.as_ref())
791 .and_then(|c| match c {
792 Cell::Scalar(v) => Some(v.clone()),
793 Cell::Node(id) => {
794 view.ids.key_of(*id).map(|k| Value::Str(k.to_owned()))
795 }
796 Cell::Path(h) => Some(Value::Int(*h as i64)),
797 Cell::Rel(_) => None,
798 })
799 })
800 .collect();
801 rs.push_row(vals);
802 }
803 rs
804 } else {
805 ResultSet::new(vec![])
806 }
807 }
808 })
809}
810
811fn exec_left_outer_inner(
820 view: &GraphView,
821 vars: &VarTable,
822 mut rows: Vec<Row>,
823 inner: &[PlanOp],
824 params: &Params,
825) -> Result<Vec<Row>, String> {
826 for op in inner {
827 match op {
828 PlanOp::ScanLabel { var, label } => {
829 rows = scan_label(view, vars, &rows, var, label.as_deref())?;
830 }
831 PlanOp::ScanKey { var, key, label } => {
832 rows = scan_key(view, vars, &rows, var, key, label.as_deref(), params)?;
833 }
834 PlanOp::LookupProps { var, props } => {
835 rows = retain_node(view, vars, &rows, var, None, props, params)?;
836 }
837 PlanOp::JoinBound { var, label, props } => {
838 rows = retain_node(view, vars, &rows, var, label.as_deref(), props, params)?;
839 }
840 PlanOp::Expand { .. } => {
841 rows = exec_expand(view, vars, &rows, op, params)?;
842 }
843 PlanOp::Filter { expr } => {
844 rows = exec_filter(view, vars, &rows, expr, params)?;
845 }
846 other => {
847 return Err(format!(
848 "unsupported op inside OPTIONAL MATCH inner plan: {other:?}"
849 ));
850 }
851 }
852 }
853 Ok(rows)
854}
855
856fn finish(table: Projected) -> ResultSet {
857 let mut rs = ResultSet::new(table.columns);
858 for row in table.rows {
859 rs.push_row(row);
860 }
861 rs
862}
863
864fn check_params(plan: &[PlanOp], params: &Params) -> Result<(), String> {
865 let mut names = Vec::new();
866 let mut seen = BTreeSet::new();
867 collect_params_from_ops(plan, &mut names, &mut seen)?;
868 for name in names {
869 if !params.0.contains_key(&name) {
870 return Err(format!("missing parameter `{name}`"));
871 }
872 }
873 Ok(())
874}
875
876fn collect_params_from_ops(
877 plan: &[PlanOp],
878 names: &mut Vec<String>,
879 seen: &mut BTreeSet<String>,
880) -> Result<(), String> {
881 for op in plan {
882 match op {
883 PlanOp::ScanKey { key, .. } => collect_operand(key, names, seen),
884 PlanOp::LookupProps { props, .. }
885 | PlanOp::JoinBound { props, .. }
886 | PlanOp::Expand {
887 to_props: props, ..
888 } => {
889 for (_, operand) in props {
890 collect_operand(operand, names, seen);
891 }
892 }
893 PlanOp::Filter { expr } => collect_expr(expr, names, seen, 0)?,
894 PlanOp::LeftOuterApply { inner, .. } => {
895 collect_params_from_ops(inner, names, seen)?;
896 }
897 PlanOp::Project { items } => {
900 for item in items {
901 collect_ret_item_params(item, names, seen);
902 }
903 }
904 PlanOp::With {
905 items, where_expr, ..
906 } => {
907 for item in items {
908 collect_ret_item_params(item, names, seen);
909 }
910 if let Some(expr) = where_expr {
911 let _ = collect_expr(expr, names, seen, 0);
912 }
913 }
914 PlanOp::GroupAggregate { keys, aggs } => {
915 for (_, item) in keys {
916 collect_ret_item_params(item, names, seen);
917 }
918 for (_, arg, _) in aggs {
919 match arg {
920 AggArg::Star => {}
921 AggArg::Var(_) => {}
922 AggArg::Prop { .. } => {}
923 AggArg::Distinct(_) => {}
924 }
925 }
926 }
927 PlanOp::Skip(LimitSkip::Param(n)) | PlanOp::Limit(LimitSkip::Param(n))
930 if seen.insert(n.clone()) =>
931 {
932 names.push(n.clone());
933 }
934 _ => {}
935 }
936 }
937 Ok(())
938}
939
940fn collect_ret_item_params(item: &RetItem, names: &mut Vec<String>, seen: &mut BTreeSet<String>) {
942 match &item.value {
943 RetVal::FuncCall { args, .. } => {
944 for arg in args {
945 collect_operand(arg, names, seen);
946 }
947 }
948 RetVal::ScalarExpr(op) => {
949 collect_operand(op, names, seen);
950 }
951 RetVal::Prop { .. } | RetVal::Var(_) | RetVal::Agg { .. } => {}
952 }
953}
954
955fn collect_operand(op: &Operand, names: &mut Vec<String>, seen: &mut BTreeSet<String>) {
956 match op {
957 Operand::Param(n) => {
958 if seen.insert(n.clone()) {
959 names.push(n.clone());
960 }
961 }
962 Operand::FuncCall { args, .. } => {
963 for arg in args {
964 collect_operand(arg, names, seen);
965 }
966 }
967 Operand::BinArith { left, right, .. } => {
968 collect_operand(left, names, seen);
969 collect_operand(right, names, seen);
970 }
971 _ => {}
972 }
973}
974
975fn collect_expr(
976 expr: &Expr,
977 names: &mut Vec<String>,
978 seen: &mut BTreeSet<String>,
979 depth: u32,
980) -> Result<(), String> {
981 if depth > 256 {
982 return Err("expression nesting too deep".into());
983 }
984 match expr {
985 Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => {
986 collect_expr(lhs, names, seen, depth + 1)?;
987 collect_expr(rhs, names, seen, depth + 1)
988 }
989 Expr::Not(inner) => collect_expr(inner, names, seen, depth + 1),
990 Expr::Cmp { lhs, rhs, .. } => {
991 collect_operand(lhs, names, seen);
992 collect_operand(rhs, names, seen);
993 Ok(())
994 }
995 Expr::Truthy(op) => {
996 collect_operand(op, names, seen);
997 Ok(())
998 }
999 Expr::IsNull(op) | Expr::IsNotNull(op) => {
1000 collect_operand(op, names, seen);
1001 Ok(())
1002 }
1003 Expr::In { expr, list } => {
1004 collect_operand(expr, names, seen);
1005 for item in list {
1006 collect_operand(item, names, seen);
1007 }
1008 Ok(())
1009 }
1010 }
1011}
1012
1013fn collect_vars(plan: &[PlanOp]) -> VarTable {
1014 let mut vars = VarTable { names: Vec::new() };
1015 for op in plan {
1016 match op {
1017 PlanOp::ScanLabel { var, .. } => {
1018 vars.intern(var);
1019 }
1020 PlanOp::ScanKey { var, key, .. } => {
1021 vars.intern(var);
1022 intern_operand(&mut vars, key);
1023 }
1024 PlanOp::IndexScan { var, value, .. } => {
1025 vars.intern(var);
1026 intern_operand(&mut vars, value);
1027 }
1028 PlanOp::IndexIntersect {
1029 var, equalities, ..
1030 } => {
1031 vars.intern(var);
1032 for (_, operand) in equalities {
1033 intern_operand(&mut vars, operand);
1034 }
1035 }
1036 PlanOp::LookupProps { var, props } | PlanOp::JoinBound { var, props, .. } => {
1037 vars.intern(var);
1038 for (_, operand) in props {
1039 intern_operand(&mut vars, operand);
1040 }
1041 }
1042 PlanOp::Expand {
1043 from,
1044 rel_var,
1045 to,
1046 to_props,
1047 ..
1048 } => {
1049 vars.intern(from);
1050 vars.intern(to);
1051 if let Some(r) = rel_var {
1052 vars.intern(r);
1053 }
1054 for (_, operand) in to_props {
1055 intern_operand(&mut vars, operand);
1056 }
1057 }
1058 PlanOp::VarExpand {
1059 from, rel_var, to, ..
1060 } => {
1061 vars.intern(from);
1062 vars.intern(to);
1063 if let Some(r) = rel_var {
1064 vars.intern(r);
1065 }
1066 }
1067 PlanOp::ShortestPath {
1068 from, rel_var, to, ..
1069 } => {
1070 vars.intern(from);
1071 vars.intern(to);
1072 if let Some(r) = rel_var {
1073 vars.intern(r);
1074 }
1075 }
1076 PlanOp::Filter { expr } => intern_expr(&mut vars, expr),
1077 PlanOp::Project { items } => {
1078 for item in items {
1079 match &item.value {
1080 RetVal::Var(name) | RetVal::Prop { var: name, .. } => {
1081 vars.intern(name);
1082 }
1083 RetVal::Agg { .. } => {} RetVal::FuncCall { args, .. } => {
1085 for arg in args {
1086 intern_operand(&mut vars, arg);
1087 }
1088 }
1089 RetVal::ScalarExpr(op) => {
1090 intern_operand(&mut vars, op);
1091 }
1092 }
1093 }
1094 }
1095 PlanOp::Aggregate { arg, .. } => intern_agg_arg(&mut vars, arg),
1096 PlanOp::GroupAggregate { keys, aggs } => {
1097 for (col, item) in keys {
1099 match &item.value {
1100 RetVal::Var(name) | RetVal::Prop { var: name, .. } => {
1101 vars.intern(name);
1102 }
1103 RetVal::Agg { .. } => {}
1104 RetVal::FuncCall { args, .. } => {
1105 for arg in args {
1106 intern_operand(&mut vars, arg);
1107 }
1108 }
1109 RetVal::ScalarExpr(op) => {
1110 intern_operand(&mut vars, op);
1111 }
1112 }
1113 vars.intern(col);
1115 }
1116 for (_, arg, col) in aggs {
1117 intern_agg_arg(&mut vars, arg);
1118 vars.intern(col);
1120 }
1121 }
1122 PlanOp::With {
1123 items,
1124 where_expr,
1125 order_by,
1126 ..
1127 } => {
1128 for item in items {
1129 match &item.value {
1130 RetVal::Var(name) | RetVal::Prop { var: name, .. } => {
1131 vars.intern(name);
1132 }
1133 RetVal::Agg { .. } => {}
1134 RetVal::FuncCall { args, .. } => {
1135 for arg in args {
1136 intern_operand(&mut vars, arg);
1137 }
1138 }
1139 RetVal::ScalarExpr(op) => {
1140 intern_operand(&mut vars, op);
1141 }
1142 }
1143 if let Some(alias) = &item.alias {
1148 vars.intern(alias);
1149 } else if let Some(col) = ret_val_label(&item.value) {
1150 vars.intern(&col);
1151 }
1152 }
1153 if let Some(expr) = where_expr {
1154 intern_expr(&mut vars, expr);
1155 }
1156 for oi in order_by {
1157 match &oi.target {
1158 OrderTarget::Alias(name) | OrderTarget::Var(name) => {
1159 vars.intern(name);
1160 }
1161 OrderTarget::Prop { var, .. } => {
1162 vars.intern(var);
1163 }
1164 }
1165 }
1166 }
1167 PlanOp::Unwind { expr, alias } => {
1168 vars.intern(alias);
1169 match expr {
1170 UnwindExpr::Prop { var, .. } => {
1171 vars.intern(var);
1172 }
1173 UnwindExpr::Var(name) => {
1174 vars.intern(name);
1175 }
1176 UnwindExpr::Lit(_) => {}
1177 }
1178 }
1179 PlanOp::LeftOuterApply {
1180 inner,
1181 optional_vars,
1182 } => {
1183 for op in inner {
1185 match op {
1189 PlanOp::ScanLabel { var, .. } => {
1190 vars.intern(var);
1191 }
1192 PlanOp::ScanKey { var, key, .. } => {
1193 vars.intern(var);
1194 intern_operand(&mut vars, key);
1195 }
1196 PlanOp::IndexScan { var, value, .. } => {
1197 vars.intern(var);
1198 intern_operand(&mut vars, value);
1199 }
1200 PlanOp::IndexIntersect {
1201 var, equalities, ..
1202 } => {
1203 vars.intern(var);
1204 for (_, operand) in equalities {
1205 intern_operand(&mut vars, operand);
1206 }
1207 }
1208 PlanOp::Expand {
1209 from, rel_var, to, ..
1210 } => {
1211 vars.intern(from);
1212 vars.intern(to);
1213 if let Some(r) = rel_var {
1214 vars.intern(r);
1215 }
1216 }
1217 PlanOp::JoinBound { var, .. } | PlanOp::LookupProps { var, .. } => {
1218 vars.intern(var);
1219 }
1220 PlanOp::Filter { expr } => intern_expr(&mut vars, expr),
1221 _ => {}
1222 }
1223 }
1224 for v in optional_vars {
1225 vars.intern(v);
1226 }
1227 }
1228 _ => {}
1229 }
1230 }
1231 vars
1232}
1233
1234fn intern_operand(vars: &mut VarTable, operand: &Operand) {
1235 match operand {
1236 Operand::Prop { var, .. } | Operand::Var(var) => {
1237 vars.intern(var);
1238 }
1239 Operand::Lit(_) | Operand::Param(_) => {}
1240 Operand::BinArith { left, right, .. } => {
1241 intern_operand(vars, left);
1242 intern_operand(vars, right);
1243 }
1244 Operand::FuncCall { args, .. } => {
1245 for arg in args {
1246 intern_operand(vars, arg);
1247 }
1248 }
1249 Operand::Case { branches, default } => {
1250 for (cond, value) in branches {
1251 intern_expr(vars, cond);
1252 intern_operand(vars, value);
1253 }
1254 if let Some(d) = default {
1255 intern_operand(vars, d);
1256 }
1257 }
1258 Operand::Index { base, index } => {
1259 intern_operand(vars, base);
1260 intern_operand(vars, index);
1261 }
1262 }
1263}
1264
1265fn intern_agg_arg(vars: &mut VarTable, arg: &AggArg) {
1267 match arg {
1268 AggArg::Star => {}
1269 AggArg::Var(v) => {
1270 vars.intern(v);
1271 }
1272 AggArg::Prop { var, .. } => {
1273 vars.intern(var);
1274 }
1275 AggArg::Distinct(inner) => intern_agg_arg(vars, inner),
1276 }
1277}
1278
1279fn agg_arg_label(arg: &AggArg) -> String {
1281 match arg {
1282 AggArg::Star => "*".to_string(),
1283 AggArg::Var(v) => v.clone(),
1284 AggArg::Prop { var, field } => format!("{var}.{field}"),
1285 AggArg::Distinct(inner) => format!("DISTINCT {}", agg_arg_label(inner)),
1286 }
1287}
1288
1289fn intern_expr(vars: &mut VarTable, expr: &Expr) {
1290 match expr {
1291 Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => {
1292 intern_expr(vars, lhs);
1293 intern_expr(vars, rhs);
1294 }
1295 Expr::Not(inner) => intern_expr(vars, inner),
1296 Expr::Cmp { lhs, rhs, .. } => {
1297 intern_operand(vars, lhs);
1298 intern_operand(vars, rhs);
1299 }
1300 Expr::Truthy(op) => intern_operand(vars, op),
1301 Expr::IsNull(op) | Expr::IsNotNull(op) => intern_operand(vars, op),
1302 Expr::In { expr, list } => {
1303 intern_operand(vars, expr);
1304 for item in list {
1305 intern_operand(vars, item);
1306 }
1307 }
1308 }
1309}
1310
1311fn scan_ids(view: &GraphView, label: Option<&str>) -> Vec<u32> {
1312 let ids = match label {
1313 Some(label) => view.nodes_with_label(label),
1314 None => (0..view.ids.len() as u32)
1316 .filter(|&id| view.label_of(id).is_some())
1317 .collect(),
1318 };
1319 if view.mask.is_some() {
1320 ids.into_iter().filter(|&id| view.visible(id)).collect()
1321 } else {
1322 ids
1323 }
1324}
1325
1326fn resolve_scan_key_id(
1329 view: &GraphView,
1330 vars: &VarTable,
1331 row: &Row,
1332 key: &Operand,
1333 label: Option<&str>,
1334 params: &Params,
1335) -> Result<Option<u32>, String> {
1336 #[cfg(test)]
1337 SCAN_KEY_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1338
1339 let Some(val) = resolve_operand(view, vars, row, key, params)? else {
1340 return Ok(None);
1341 };
1342 let Value::Str(s) = val else {
1343 return Ok(None);
1344 };
1345 let Some(id) = view.node_id(&s) else {
1346 return Ok(None);
1347 };
1348 if !view.visible(id) {
1349 return Ok(None);
1350 }
1351 if let Some(want) = label {
1352 match view.label_of(id) {
1353 Some(got) if got == want => {}
1354 _ => return Ok(None),
1355 }
1356 }
1357 Ok(Some(id))
1358}
1359
1360fn scan_key(
1361 view: &GraphView,
1362 vars: &VarTable,
1363 rows: &[Row],
1364 var: &str,
1365 key: &Operand,
1366 label: Option<&str>,
1367 params: &Params,
1368) -> Result<Vec<Row>, String> {
1369 let slot = vars
1370 .slot(var)
1371 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1372 let cap = max_intermediate_rows();
1373 let mut out = Vec::new();
1374 for row in rows {
1375 let Some(id) = resolve_scan_key_id(view, vars, row, key, label, params)? else {
1376 continue;
1377 };
1378 if out.len() >= cap {
1379 return Err(row_cap_err(cap));
1380 }
1381 let mut next = row.clone();
1382 next[slot] = Some(Cell::Node(id));
1383 out.push(next);
1384 }
1385 Ok(out)
1386}
1387
1388fn scan_label(
1389 view: &GraphView,
1390 vars: &VarTable,
1391 rows: &[Row],
1392 var: &str,
1393 label: Option<&str>,
1394) -> Result<Vec<Row>, String> {
1395 let ids = scan_ids(view, label);
1396 let slot = vars
1397 .slot(var)
1398 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1399 let cap = max_intermediate_rows();
1400 let mut out = Vec::with_capacity(rows.len().saturating_mul(ids.len()).min(cap));
1401 for row in rows {
1402 for &id in &ids {
1403 if out.len() >= cap {
1404 return Err(row_cap_err(cap));
1405 }
1406 let mut next = row.clone();
1407 next[slot] = Some(Cell::Node(id));
1408 out.push(next);
1409 }
1410 }
1411 Ok(out)
1412}
1413
1414#[allow(clippy::too_many_arguments)]
1422fn index_scan_ids(
1423 view: &GraphView,
1424 vars: &VarTable,
1425 row: &Row,
1426 label: Option<&str>,
1427 field: &str,
1428 value: &Operand,
1429 params: &Params,
1430) -> Result<Vec<u32>, String> {
1431 if is_identity_eq_field(field) {
1432 return identity_eq_ids(view, vars, row, label, field, value, params);
1433 }
1434 let resolved = resolve_operand(view, vars, row, value, params)?;
1435 if let (Some(label_str), Some(val)) = (label, resolved.as_ref()) {
1436 if let Some(ids) = view.nodes_with_prop(label_str, field, val) {
1437 #[cfg(test)]
1438 INDEX_SCAN_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1439 return Ok(ids);
1440 }
1441 }
1442 let props = [(field.to_string(), value.clone())];
1444 let mut out = Vec::new();
1445 for id in scan_ids(view, label) {
1446 if node_matches(view, vars, row, id, None, &props, params)? {
1447 out.push(id);
1448 }
1449 }
1450 Ok(out)
1451}
1452
1453#[allow(clippy::too_many_arguments)]
1459fn identity_eq_ids(
1460 view: &GraphView,
1461 vars: &VarTable,
1462 row: &Row,
1463 label: Option<&str>,
1464 field: &str,
1465 value: &Operand,
1466 params: &Params,
1467) -> Result<Vec<u32>, String> {
1468 let resolved = resolve_operand(view, vars, row, value, params)?;
1469 let mut out = Vec::new();
1470 let mut seen = HashSet::new();
1471 if let Some(id) = resolve_scan_key_id(view, vars, row, value, label, params)? {
1472 let props = [(field.to_string(), value.clone())];
1473 if node_matches(view, vars, row, id, None, &props, params)? {
1474 seen.insert(id);
1475 out.push(id);
1476 }
1477 }
1478 if let Some(val) = resolved.as_ref() {
1479 for id in stored_identity_hits(view, label, field, val) {
1480 if seen.insert(id) {
1481 out.push(id);
1482 }
1483 }
1484 }
1485 Ok(out)
1486}
1487
1488fn stored_identity_hits(
1490 view: &GraphView,
1491 label: Option<&str>,
1492 field: &str,
1493 val: &Value,
1494) -> Vec<u32> {
1495 if let Some(label_str) = label {
1496 if let Some(ids) = view.nodes_with_prop(label_str, field, val) {
1497 return ids;
1498 }
1499 }
1500 let mut out = Vec::new();
1501 for id in scan_ids(view, label) {
1502 if let Some(got) = view.prop(id, field).map(|vr| vr.into_value()) {
1503 if values_equal(&got, val) {
1504 out.push(id);
1505 }
1506 }
1507 }
1508 out
1509}
1510
1511#[allow(clippy::too_many_arguments)]
1514fn scan_index(
1515 view: &GraphView,
1516 vars: &VarTable,
1517 rows: &[Row],
1518 var: &str,
1519 label: Option<&str>,
1520 field: &str,
1521 value: &Operand,
1522 params: &Params,
1523) -> Result<Vec<Row>, String> {
1524 let Some(first) = rows.first() else {
1525 return Ok(Vec::new());
1526 };
1527 let ids = index_scan_ids(view, vars, first, label, field, value, params)?;
1528 let slot = vars
1529 .slot(var)
1530 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1531 let cap = max_intermediate_rows();
1532 let mut out = Vec::new();
1533 for row in rows {
1534 for &id in &ids {
1535 if out.len() >= cap {
1536 return Err(row_cap_err(cap));
1537 }
1538 let mut next = row.clone();
1539 next[slot] = Some(Cell::Node(id));
1540 out.push(next);
1541 }
1542 }
1543 Ok(out)
1544}
1545
1546#[allow(clippy::too_many_arguments)]
1555fn index_intersect_ids(
1556 view: &GraphView,
1557 vars: &VarTable,
1558 row: &Row,
1559 label: Option<&str>,
1560 equalities: &[(String, Operand)],
1561 params: &Params,
1562) -> Result<Vec<u32>, String> {
1563 let mut resolved: Vec<(String, Option<Value>)> = Vec::with_capacity(equalities.len());
1565 for (field, operand) in equalities {
1566 let val = resolve_operand(view, vars, row, operand, params)?;
1567 resolved.push((field.clone(), val));
1568 }
1569
1570 let mut indexed_lists: Vec<Vec<u32>> = Vec::new();
1572 let mut unindexed_props: Vec<(String, Operand)> = Vec::new();
1573
1574 for ((field, val_opt), (_, operand)) in resolved.iter().zip(equalities.iter()) {
1575 if let (Some(label_str), Some(val)) = (label, val_opt.as_ref()) {
1576 if let Some(ids) = view.nodes_with_prop(label_str, field, val) {
1577 indexed_lists.push(ids);
1578 continue;
1579 }
1580 }
1581 unindexed_props.push((field.clone(), operand.clone()));
1582 }
1583
1584 if indexed_lists.is_empty() {
1585 let mut out = Vec::new();
1587 for id in scan_ids(view, label) {
1588 if node_matches(view, vars, row, id, None, equalities, params)? {
1589 out.push(id);
1590 }
1591 }
1592 return Ok(out);
1593 }
1594
1595 #[cfg(test)]
1596 INDEX_INTERSECT_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1597
1598 indexed_lists.sort_unstable_by_key(|v| v.len());
1600 let mut result = indexed_lists.remove(0);
1601 for other in indexed_lists {
1602 let mut merged = Vec::new();
1603 let (mut i, mut j) = (0, 0);
1604 while i < result.len() && j < other.len() {
1605 match result[i].cmp(&other[j]) {
1606 std::cmp::Ordering::Equal => {
1607 merged.push(result[i]);
1608 i += 1;
1609 j += 1;
1610 }
1611 std::cmp::Ordering::Less => i += 1,
1612 std::cmp::Ordering::Greater => j += 1,
1613 }
1614 }
1615 result = merged;
1616 }
1617
1618 if !unindexed_props.is_empty() {
1622 let mut filtered = Vec::with_capacity(result.len());
1623 for id in result {
1624 if node_matches(view, vars, row, id, None, &unindexed_props, params)? {
1625 filtered.push(id);
1626 }
1627 }
1628 result = filtered;
1629 }
1630
1631 Ok(result)
1632}
1633
1634#[allow(clippy::too_many_arguments)]
1637fn scan_intersect(
1638 view: &GraphView,
1639 vars: &VarTable,
1640 rows: &[Row],
1641 var: &str,
1642 label: Option<&str>,
1643 equalities: &[(String, Operand)],
1644 params: &Params,
1645) -> Result<Vec<Row>, String> {
1646 let Some(first) = rows.first() else {
1647 return Ok(Vec::new());
1648 };
1649 let ids = index_intersect_ids(view, vars, first, label, equalities, params)?;
1650 let slot = vars
1651 .slot(var)
1652 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1653 let cap = max_intermediate_rows();
1654 let mut out = Vec::new();
1655 for row in rows {
1656 for &id in &ids {
1657 if out.len() >= cap {
1658 return Err(row_cap_err(cap));
1659 }
1660 let mut next = row.clone();
1661 next[slot] = Some(Cell::Node(id));
1662 out.push(next);
1663 }
1664 }
1665 Ok(out)
1666}
1667
1668fn require_cell<'a>(row: &'a Row, vars: &VarTable, var: &str) -> Result<&'a Cell, String> {
1669 let slot = vars
1670 .slot(var)
1671 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1672 row.get(slot)
1673 .and_then(|c| c.as_ref())
1674 .ok_or_else(|| format!("unbound variable `{var}`"))
1675}
1676
1677fn require_node(row: &Row, vars: &VarTable, var: &str) -> Result<u32, String> {
1678 match require_cell(row, vars, var)? {
1679 Cell::Node(id) => Ok(*id),
1680 Cell::Rel(_) => Err(format!("variable `{var}` is not a node")),
1681 Cell::Path(_) => Err(format!("variable `{var}` is a path, not a node")),
1682 Cell::Scalar(_) => Err(format!("variable `{var}` is a scalar value, not a node")),
1683 }
1684}
1685
1686const SCALAR_FUNCS: &[&str] = &[
1688 "toLower",
1689 "toUpper",
1690 "size",
1691 "coalesce",
1692 "type",
1693 "abs",
1694 "round",
1695 "textMatches",
1696 "contains",
1697 "startsWith",
1698 "endsWith",
1699 "toInteger",
1700 "toFloat",
1701 "toString",
1702 "decay",
1703 "key",
1704 "id",
1705 "labels",
1706];
1707
1708fn eval_string_predicate(
1711 name: &str,
1712 args: &[Operand],
1713 view: &GraphView,
1714 vars: &VarTable,
1715 row: &Row,
1716 params: &Params,
1717 f: impl Fn(&str, &str) -> bool,
1718) -> Result<Option<Value>, String> {
1719 if args.len() != 2 {
1720 return Err(format!(
1721 "{name}() requires exactly 2 arguments, got {}",
1722 args.len()
1723 ));
1724 }
1725 let a = resolve_operand(view, vars, row, &args[0], params)?;
1726 let b = resolve_operand(view, vars, row, &args[1], params)?;
1727 match (a, b) {
1728 (Some(Value::Str(s)), Some(Value::Str(sub))) => Ok(Some(Value::Bool(f(&s, &sub)))),
1729 (None, _) | (_, None) => Ok(None),
1730 _ => Ok(None),
1731 }
1732}
1733
1734fn eval_func(
1738 name: &str,
1739 args: &[Operand],
1740 view: &GraphView,
1741 vars: &VarTable,
1742 row: &Row,
1743 params: &Params,
1744) -> Result<Option<Value>, String> {
1745 let norm = name.to_ascii_lowercase();
1746 match norm.as_str() {
1747 "tolower" => {
1748 if args.len() != 1 {
1749 return Err(format!(
1750 "toLower() requires exactly 1 argument, got {}",
1751 args.len()
1752 ));
1753 }
1754 let v = resolve_operand(view, vars, row, &args[0], params)?;
1755 Ok(v.map(|val| match val {
1756 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
1757 other => other, }))
1759 }
1760 "toupper" => {
1761 if args.len() != 1 {
1762 return Err(format!(
1763 "toUpper() requires exactly 1 argument, got {}",
1764 args.len()
1765 ));
1766 }
1767 let v = resolve_operand(view, vars, row, &args[0], params)?;
1768 Ok(v.map(|val| match val {
1769 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
1770 other => other,
1771 }))
1772 }
1773 "size" => {
1774 if args.len() != 1 {
1775 return Err(format!(
1776 "size() requires exactly 1 argument, got {}",
1777 args.len()
1778 ));
1779 }
1780 let v = resolve_operand(view, vars, row, &args[0], params)?;
1781 match v {
1782 None => Ok(None), Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
1784 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
1785 Some(_) => Ok(None), }
1787 }
1788 "coalesce" => {
1789 for arg in args {
1791 if let Some(v) = resolve_operand(view, vars, row, arg, params)? {
1792 return Ok(Some(v));
1793 }
1794 }
1795 Ok(None)
1796 }
1797 "type" => {
1798 if args.len() != 1 {
1799 return Err(format!(
1800 "type() requires exactly 1 argument, got {}",
1801 args.len()
1802 ));
1803 }
1804 let Operand::Var(var_name) = &args[0] else {
1806 return Err(
1807 "type() argument must be a relationship variable (e.g. type(r))".to_string(),
1808 );
1809 };
1810 let slot = vars
1811 .slot(var_name)
1812 .ok_or_else(|| format!("unbound variable `{var_name}` in type()"))?;
1813 match row.get(slot).and_then(|c| c.as_ref()) {
1814 Some(Cell::Rel(e)) => {
1815 let etype = view.syms.resolve(e.etype).unwrap_or("").to_owned();
1817 Ok(Some(Value::Str(etype)))
1818 }
1819 Some(Cell::Node(_)) => Err(format!(
1820 "type() argument `{var_name}` is a node, not a relationship"
1821 )),
1822 Some(Cell::Scalar(_) | Cell::Path(_)) => Err(format!(
1823 "type() argument `{var_name}` is not a relationship"
1824 )),
1825 None => Ok(None), }
1827 }
1828 "key" | "id" => {
1829 let fname = if norm == "id" { "id" } else { "key" };
1830 if args.len() != 1 {
1831 return Err(format!(
1832 "{fname}() requires exactly 1 argument, got {}",
1833 args.len()
1834 ));
1835 }
1836 let Operand::Var(var_name) = &args[0] else {
1837 return Err(format!(
1838 "{fname}() argument must be a node variable (e.g. {fname}(n))"
1839 ));
1840 };
1841 let slot = vars
1842 .slot(var_name)
1843 .ok_or_else(|| format!("unbound variable `{var_name}` in {fname}()"))?;
1844 match row.get(slot).and_then(|c| c.as_ref()) {
1845 Some(Cell::Node(id)) => Ok(Some(Value::Str(view.key_of(*id).to_owned()))),
1846 Some(Cell::Rel(_)) => Err(format!(
1847 "{fname}() argument `{var_name}` is a relationship, not a node"
1848 )),
1849 Some(Cell::Scalar(_) | Cell::Path(_)) => {
1850 Err(format!("{fname}() argument `{var_name}` is not a node"))
1851 }
1852 None => Ok(None), }
1854 }
1855 "labels" => {
1856 if args.len() != 1 {
1857 return Err(format!(
1858 "labels() requires exactly 1 argument, got {}",
1859 args.len()
1860 ));
1861 }
1862 let Operand::Var(var_name) = &args[0] else {
1866 return Err(
1867 "labels() argument must be a node variable (e.g. labels(n))".to_string()
1868 );
1869 };
1870 let slot = vars
1871 .slot(var_name)
1872 .ok_or_else(|| format!("unbound variable `{var_name}` in labels()"))?;
1873 match row.get(slot).and_then(|c| c.as_ref()) {
1874 Some(Cell::Node(id)) => Ok(Some(Value::List(
1875 view.label_of(*id)
1876 .map(|l| vec![Value::Str(l.to_owned())])
1877 .unwrap_or_default(),
1878 ))),
1879 Some(Cell::Rel(_)) => Err(format!(
1880 "labels() argument `{var_name}` is a relationship, not a node"
1881 )),
1882 Some(Cell::Scalar(_) | Cell::Path(_)) => {
1883 Err(format!("labels() argument `{var_name}` is not a node"))
1884 }
1885 None => Ok(None), }
1887 }
1888 "abs" => {
1889 if args.len() != 1 {
1890 return Err(format!(
1891 "abs() requires exactly 1 argument, got {}",
1892 args.len()
1893 ));
1894 }
1895 let v = resolve_operand(view, vars, row, &args[0], params)?;
1896 match v {
1897 None => Ok(None),
1898 Some(Value::Int(n)) => Ok(Some(Value::Int(n.abs()))),
1899 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
1900 Some(_) => Ok(None), }
1902 }
1903 "round" => {
1904 if args.len() != 1 {
1905 return Err(format!(
1906 "round() requires exactly 1 argument, got {}",
1907 args.len()
1908 ));
1909 }
1910 let v = resolve_operand(view, vars, row, &args[0], params)?;
1911 match v {
1912 None => Ok(None),
1913 Some(Value::Int(n)) => Ok(Some(Value::Int(n))), Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
1915 Some(_) => Ok(None), }
1917 }
1918 "textmatches" => {
1919 if args.len() != 2 {
1931 return Err(format!(
1932 "textMatches() requires exactly 2 arguments (field_value, query), got {}",
1933 args.len()
1934 ));
1935 }
1936 let field_val = resolve_operand(view, vars, row, &args[0], params)?;
1937 let query_val = resolve_operand(view, vars, row, &args[1], params)?;
1938 match (field_val, query_val) {
1939 (None, _) | (_, None) => Ok(None),
1940 (Some(Value::Str(s)), Some(Value::Str(q))) => {
1941 Ok(Some(Value::Bool(core_storage::fulltext::eval_query_str(
1943 &s, &q,
1944 ))))
1945 }
1946 (Some(Value::List(items)), Some(Value::Str(q))) => Ok(Some(Value::Bool(
1947 core_storage::fulltext::eval_query_str_list(&items, &q),
1948 ))),
1949 _ => Ok(Some(Value::Bool(false))), }
1951 }
1952 "contains" => eval_string_predicate("contains", args, view, vars, row, params, |s, sub| {
1953 s.contains(sub)
1954 }),
1955 "startswith" => {
1956 eval_string_predicate("startsWith", args, view, vars, row, params, |s, p| {
1957 s.starts_with(p)
1958 })
1959 }
1960 "endswith" => eval_string_predicate("endsWith", args, view, vars, row, params, |s, p| {
1961 s.ends_with(p)
1962 }),
1963 "tointeger" => {
1964 if args.len() != 1 {
1965 return Err(format!(
1966 "toInteger() requires exactly 1 argument, got {}",
1967 args.len()
1968 ));
1969 }
1970 let v = resolve_operand(view, vars, row, &args[0], params)?;
1971 Ok(match v {
1972 None => None,
1973 Some(Value::Int(n)) => Some(Value::Int(n)),
1974 Some(Value::Float(f)) => Some(Value::Int(f.trunc() as i64)),
1975 Some(Value::Str(s)) => s
1978 .trim()
1979 .parse::<i64>()
1980 .ok()
1981 .or_else(|| s.trim().parse::<f64>().ok().map(|f| f.trunc() as i64))
1982 .map(Value::Int),
1983 Some(_) => None,
1984 })
1985 }
1986 "tofloat" => {
1987 if args.len() != 1 {
1988 return Err(format!(
1989 "toFloat() requires exactly 1 argument, got {}",
1990 args.len()
1991 ));
1992 }
1993 let v = resolve_operand(view, vars, row, &args[0], params)?;
1994 Ok(match v {
1995 None => None,
1996 Some(Value::Float(f)) => Some(Value::Float(f)),
1997 Some(Value::Int(n)) => Some(Value::Float(n as f64)),
1998 Some(Value::Str(s)) => s.trim().parse::<f64>().ok().map(Value::Float),
1999 Some(_) => None,
2000 })
2001 }
2002 "tostring" => {
2003 if args.len() != 1 {
2004 return Err(format!(
2005 "toString() requires exactly 1 argument, got {}",
2006 args.len()
2007 ));
2008 }
2009 let v = resolve_operand(view, vars, row, &args[0], params)?;
2010 Ok(match v {
2011 None => None,
2012 Some(Value::Str(s)) => Some(Value::Str(s)),
2013 Some(Value::Int(n)) => Some(Value::Str(n.to_string())),
2014 Some(Value::Float(f)) => Some(Value::Str(f.to_string())),
2015 Some(Value::Bool(b)) => Some(Value::Str(b.to_string())),
2016 Some(_) => None, })
2018 }
2019 "decay" => {
2020 if args.len() != 3 {
2025 return Err(format!(
2026 "decay() requires exactly 3 arguments, got {}",
2027 args.len()
2028 ));
2029 }
2030 let base = resolve_operand(view, vars, row, &args[0], params)?;
2031 let age = resolve_operand(view, vars, row, &args[1], params)?;
2032 let halflife = resolve_operand(view, vars, row, &args[2], params)?;
2033 match (base, age, halflife) {
2034 (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
2035 (Some(b), Some(a), Some(h)) => {
2036 let b = numeric_val(&b)
2037 .ok_or_else(|| "decay() requires numeric arguments".to_string())?;
2038 let a = numeric_val(&a)
2039 .ok_or_else(|| "decay() requires numeric arguments".to_string())?;
2040 let h = numeric_val(&h)
2041 .ok_or_else(|| "decay() requires numeric arguments".to_string())?;
2042 if h <= 0.0 {
2043 return Err("decay() requires halflife > 0".to_string());
2044 }
2045 Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
2046 }
2047 }
2048 }
2049 _ => Err(format!(
2050 "unknown function `{name}`; supported: {}",
2051 SCALAR_FUNCS.join(", ")
2052 )),
2053 }
2054}
2055
2056fn resolve_operand(
2057 view: &GraphView,
2058 vars: &VarTable,
2059 row: &Row,
2060 operand: &Operand,
2061 params: &Params,
2062) -> Result<Option<Value>, String> {
2063 match operand {
2064 Operand::Lit(v) => Ok(Some(v.clone())),
2065 Operand::Param(name) => match params.0.get(name) {
2066 Some(v) => Ok(Some(v.clone())),
2067 None => Err(format!("missing parameter `{name}`")),
2068 },
2069 Operand::Prop { var, field } => resolve_prop(view, vars, row, var, field),
2070 Operand::Var(name) => {
2071 match vars
2073 .slot(name)
2074 .and_then(|s| row.get(s))
2075 .and_then(|c| c.as_ref())
2076 {
2077 Some(Cell::Scalar(v)) => Ok(Some(v.clone())),
2078 Some(Cell::Node(id)) => match view.ids.key_of(*id) {
2079 Some(key) => Ok(Some(Value::Str(key.to_owned()))),
2080 None => Ok(None),
2081 },
2082 Some(Cell::Path(hops)) => Ok(Some(Value::Int(*hops as i64))),
2083 Some(Cell::Rel(_)) => Err(format!("variable `{name}` is a relationship")),
2084 None => Ok(None),
2085 }
2086 }
2087 Operand::FuncCall { name, args } => eval_func(name, args, view, vars, row, params),
2088 Operand::Index { base, index } => {
2089 let base_val = resolve_operand(view, vars, row, base, params)?;
2090 let idx_val = resolve_operand(view, vars, row, index, params)?;
2091 Ok(crate::value_ops::index_list(base_val, idx_val))
2092 }
2093 Operand::Case { branches, default } => {
2094 for (cond, value) in branches {
2095 if eval_expr(view, vars, row, cond, params, 0)? {
2096 return resolve_operand(view, vars, row, value, params);
2097 }
2098 }
2099 match default {
2100 Some(d) => resolve_operand(view, vars, row, d, params),
2101 None => Ok(None),
2102 }
2103 }
2104 Operand::BinArith { op, left, right } => {
2105 use super::ast::ArithOp;
2106 let lv = resolve_operand(view, vars, row, left, params)?;
2107 let rv = resolve_operand(view, vars, row, right, params)?;
2108 match (lv, rv) {
2109 (None, _) | (_, None) => Ok(None), (Some(Value::Int(a)), Some(Value::Int(b))) => {
2111 let result = match op {
2112 ArithOp::Sub => a.saturating_sub(b),
2113 ArithOp::Mul => a.saturating_mul(b),
2114 ArithOp::Add => a.saturating_add(b),
2115 ArithOp::Div => {
2116 if b == 0 {
2117 return Err("division by zero".into());
2118 }
2119 a.checked_div(b).unwrap_or(i64::MAX)
2120 }
2121 };
2122 Ok(Some(Value::Int(result)))
2123 }
2124 (Some(lv), Some(rv)) => {
2125 let a = match &lv {
2126 Value::Float(f) => *f,
2127 Value::Int(i) => *i as f64,
2128 _ => return Err(format!("arithmetic operand must be numeric, got {lv:?}")),
2129 };
2130 let b = match &rv {
2131 Value::Float(f) => *f,
2132 Value::Int(i) => *i as f64,
2133 _ => return Err(format!("arithmetic operand must be numeric, got {rv:?}")),
2134 };
2135 let result = match op {
2136 ArithOp::Sub => a - b,
2137 ArithOp::Mul => a * b,
2138 ArithOp::Add => a + b,
2139 ArithOp::Div => {
2140 if b == 0.0 {
2141 return Err("division by zero".into());
2142 }
2143 a / b
2144 }
2145 };
2146 Ok(Some(Value::Float(result)))
2147 }
2148 }
2149 }
2150 }
2151}
2152
2153fn is_identity_field(field: &str) -> bool {
2156 field == "key" || field == "id" || field == "label"
2157}
2158
2159fn is_identity_eq_field(field: &str) -> bool {
2162 field == "key" || field == "id"
2163}
2164
2165fn node_identity_prop(view: &GraphView, id: u32, field: &str) -> Option<Value> {
2172 match field {
2173 "key" | "id" => view.ids.key_of(id).map(|k| Value::Str(k.to_owned())),
2174 "label" => view.label_of(id).map(|l| Value::Str(l.to_owned())),
2175 _ => None,
2176 }
2177}
2178
2179fn resolve_prop(
2180 view: &GraphView,
2181 vars: &VarTable,
2182 row: &Row,
2183 var: &str,
2184 field: &str,
2185) -> Result<Option<Value>, String> {
2186 let slot = vars
2189 .slot(var)
2190 .ok_or_else(|| format!("unbound variable `{var}`"))?;
2191 let cell = match row.get(slot).and_then(|c| c.as_ref()) {
2192 Some(c) => c,
2193 None => return Ok(None),
2195 };
2196 match cell {
2197 Cell::Node(id) => Ok(view
2198 .prop(*id, field)
2199 .map(|vr| vr.into_value())
2200 .or_else(|| node_identity_prop(view, *id, field))),
2201 Cell::Rel(e) => Ok(view.edge_props.get(e.etype, e.src, e.dst, field)),
2202 Cell::Path(hops) => {
2204 if field == "length" {
2205 Ok(Some(Value::Int(*hops as i64)))
2206 } else {
2207 Ok(None)
2208 }
2209 }
2210 Cell::Scalar(_) => Ok(None),
2212 }
2213}
2214
2215fn node_matches(
2216 view: &GraphView,
2217 vars: &VarTable,
2218 row: &Row,
2219 id: u32,
2220 label: Option<&str>,
2221 props: &[(String, Operand)],
2222 params: &Params,
2223) -> Result<bool, String> {
2224 if let Some(want) = label {
2225 match view.label_of(id) {
2226 Some(got) if got == want => {}
2227 _ => return Ok(false),
2228 }
2229 }
2230 for (field, operand) in props {
2231 let Some(expected) = resolve_operand(view, vars, row, operand, params)? else {
2232 return Ok(false);
2233 };
2234 let got = view
2235 .prop(id, field)
2236 .map(|vr| vr.into_value())
2237 .or_else(|| node_identity_prop(view, id, field));
2238 match got {
2239 Some(got) if values_equal(&got, &expected) => {}
2240 _ => return Ok(false),
2241 }
2242 }
2243 Ok(true)
2244}
2245
2246fn retain_node(
2247 view: &GraphView,
2248 vars: &VarTable,
2249 rows: &[Row],
2250 var: &str,
2251 label: Option<&str>,
2252 props: &[(String, Operand)],
2253 params: &Params,
2254) -> Result<Vec<Row>, String> {
2255 let mut out = Vec::with_capacity(rows.len());
2256 for row in rows {
2257 let id = require_node(row, vars, var)?;
2258 if node_matches(view, vars, row, id, label, props, params)? {
2259 out.push(row.clone());
2260 }
2261 }
2262 Ok(out)
2263}
2264
2265fn map_dir(dir: RelDir) -> Dir {
2266 match dir {
2267 RelDir::Right => Dir::Out,
2268 RelDir::Left => Dir::In,
2269 RelDir::Undirected => Dir::Both,
2270 }
2271}
2272
2273fn neighbor(from: u32, e: &EdgeRef, dir: RelDir) -> u32 {
2274 match dir {
2275 RelDir::Right => e.dst,
2276 RelDir::Left => e.src,
2277 RelDir::Undirected => {
2278 if e.src == from {
2279 e.dst
2280 } else {
2281 e.src
2282 }
2283 }
2284 }
2285}
2286
2287fn row_has_edge(row: &Row, e: &EdgeRef) -> bool {
2288 row.iter()
2289 .any(|c| matches!(c, Some(Cell::Rel(existing)) if existing == e))
2290}
2291
2292fn resolve_etypes(view: &GraphView, etypes: &[String]) -> Option<Vec<u32>> {
2293 if etypes.is_empty() {
2294 None } else {
2296 Some(etypes.iter().filter_map(|n| view.syms.get(n)).collect())
2299 }
2300}
2301
2302fn exec_expand(
2303 view: &GraphView,
2304 vars: &VarTable,
2305 rows: &[Row],
2306 op: &PlanOp,
2307 params: &Params,
2308) -> Result<Vec<Row>, String> {
2309 let PlanOp::Expand {
2310 from,
2311 rel_var,
2312 etypes,
2313 dir,
2314 to,
2315 to_label,
2316 to_props,
2317 } = op
2318 else {
2319 return Err("internal: expected Expand".into());
2320 };
2321 let etypes = resolve_etypes(view, etypes);
2322 let exp_dir = map_dir(*dir);
2323 let to_slot = vars
2324 .slot(to)
2325 .ok_or_else(|| format!("unbound variable `{to}`"))?;
2326 let rel_slot = rel_var.as_ref().and_then(|rv| vars.slot(rv));
2327 let cap = max_intermediate_rows();
2328 let mut out = Vec::with_capacity(rows.len().saturating_mul(2).min(cap));
2329 for row in rows {
2330 let from_id = require_node(row, vars, from)?;
2331 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
2332 Some(Cell::Node(id)) => Some(*id),
2333 Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
2334 return Err(format!("variable `{to}` is not a node"))
2335 }
2336 None => None,
2337 };
2338 for e in expand(view, from_id, etypes.as_deref(), exp_dir) {
2339 if row_has_edge(row, &e) {
2340 continue;
2341 }
2342 let nbr = neighbor(from_id, &e, *dir);
2343 if !view.visible(nbr) {
2344 continue;
2345 }
2346 if let Some(want) = bound_to {
2347 if nbr != want {
2348 continue;
2349 }
2350 }
2351 if !node_matches(view, vars, row, nbr, to_label.as_deref(), to_props, params)? {
2352 continue;
2353 }
2354 if out.len() >= cap {
2355 return Err(row_cap_err(cap));
2356 }
2357 let mut next = row.clone();
2358 if let Some(slot) = rel_slot {
2359 next[slot] = Some(Cell::Rel(e));
2360 }
2361 if bound_to.is_none() {
2362 next[to_slot] = Some(Cell::Node(nbr));
2363 }
2364 out.push(next);
2365 #[cfg(test)]
2366 record_expand_row();
2367 }
2368 }
2369 Ok(out)
2370}
2371
2372#[allow(clippy::too_many_arguments)]
2379fn exec_var_expand(
2380 view: &GraphView,
2381 vars: &VarTable,
2382 rows: &[Row],
2383 from: &str,
2384 rel_var: &Option<String>,
2385 etypes: &[String],
2386 dir: RelDir,
2387 to: &str,
2388 min: u8,
2389 max: u8,
2390) -> Result<Vec<Row>, String> {
2391 let etypes = resolve_etypes(view, etypes);
2392 let exp_dir = map_dir(dir);
2393 let to_slot = vars
2394 .slot(to)
2395 .ok_or_else(|| format!("unbound variable `{to}`"))?;
2396 let rel_slot = rel_var.as_ref().and_then(|rv| vars.slot(rv));
2397 let cap = max_intermediate_rows();
2398 let mut out: Vec<Row> = Vec::new();
2399
2400 for row in rows {
2401 let from_id = require_node(row, vars, from)?;
2402 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
2403 Some(Cell::Node(id)) => Some(*id),
2404 Some(_) => return Err(format!("variable `{to}` is not a node")),
2405 None => None,
2406 };
2407
2408 struct PathState {
2411 node: u32,
2412 edges: Vec<EdgeRef>,
2413 }
2414
2415 let mut frontier: Vec<PathState> = vec![PathState {
2416 node: from_id,
2417 edges: Vec::new(),
2418 }];
2419
2420 let mut frontier_count: usize = 0;
2425
2426 for depth in 1u8..=max {
2427 let mut next_frontier: Vec<PathState> = Vec::new();
2428 for state in &frontier {
2429 for e in expand(view, state.node, etypes.as_deref(), exp_dir) {
2430 if state.edges.contains(&e) {
2432 continue;
2433 }
2434 let nbr = neighbor(state.node, &e, dir);
2435 if !view.visible(nbr) {
2438 continue;
2439 }
2440
2441 if depth >= min {
2444 let dest_matches = match bound_to {
2445 Some(want) => nbr == want,
2446 None => true,
2447 };
2448 if dest_matches {
2449 if out.len() >= cap {
2450 return Err(row_cap_err(cap));
2451 }
2452 let mut next = row.clone();
2453 if let Some(slot) = rel_slot {
2454 next[slot] = Some(Cell::Path(depth));
2455 }
2456 next[to_slot] = Some(Cell::Node(nbr));
2457 out.push(next);
2458 }
2459 }
2460
2461 if depth < max {
2465 frontier_count += 1;
2466 if frontier_count >= cap {
2467 return Err(row_cap_err(cap));
2468 }
2469 let mut new_edges = state.edges.clone();
2470 new_edges.push(e);
2471 next_frontier.push(PathState {
2472 node: nbr,
2473 edges: new_edges,
2474 });
2475 }
2476 }
2477 }
2478 frontier = next_frontier;
2479 if frontier.is_empty() {
2480 break;
2481 }
2482 }
2483 }
2484 Ok(out)
2485}
2486
2487#[allow(clippy::too_many_arguments)]
2493fn exec_shortest_path(
2494 view: &GraphView,
2495 vars: &VarTable,
2496 rows: &[Row],
2497 from: &str,
2498 rel_var: &Option<String>,
2499 etypes: &[String],
2500 dir: RelDir,
2501 to: &str,
2502 max_hops: u8,
2503) -> Result<Vec<Row>, String> {
2504 let etypes = resolve_etypes(view, etypes);
2505 let exp_dir = map_dir(dir);
2506 let rel_slot = rel_var.as_ref().and_then(|rv| vars.slot(rv));
2507 let mut out: Vec<Row> = Vec::new();
2508
2509 for row in rows {
2510 let from_id = require_node(row, vars, from)?;
2511 let to_id = require_node(row, vars, to)?;
2512
2513 let mut visited = std::collections::BTreeSet::new();
2515 visited.insert(from_id);
2516 let mut frontier: Vec<u32> = vec![from_id];
2517
2518 'bfs: for depth in 1u8..=max_hops {
2519 let mut next_frontier: Vec<u32> = Vec::new();
2520 for &node in &frontier {
2521 for e in expand(view, node, etypes.as_deref(), exp_dir) {
2522 let nbr = neighbor(node, &e, dir);
2523 if !view.visible(nbr) {
2527 continue;
2528 }
2529 if nbr == to_id {
2530 let mut next = row.clone();
2532 if let Some(slot) = rel_slot {
2533 next[slot] = Some(Cell::Path(depth));
2534 }
2535 out.push(next);
2536 break 'bfs;
2537 }
2538 if !visited.contains(&nbr) {
2539 visited.insert(nbr);
2540 next_frontier.push(nbr);
2541 }
2542 }
2543 }
2544 frontier = next_frontier;
2545 if frontier.is_empty() {
2546 break;
2547 }
2548 }
2549 }
2550 Ok(out)
2551}
2552
2553fn exec_filter(
2554 view: &GraphView,
2555 vars: &VarTable,
2556 rows: &[Row],
2557 expr: &Expr,
2558 params: &Params,
2559) -> Result<Vec<Row>, String> {
2560 let mut out = Vec::with_capacity(rows.len());
2561 for row in rows {
2562 if eval_expr(view, vars, row, expr, params, 0)? {
2563 out.push(row.clone());
2564 }
2565 }
2566 Ok(out)
2567}
2568
2569struct PullCtx<'a> {
2585 view: &'a GraphView<'a>,
2586 vars: &'a VarTable,
2587 project_items: &'a [RetItem],
2588 params: &'a Params<'a>,
2589 bound: usize,
2590}
2591
2592fn execute_pull(
2593 view: &GraphView,
2594 plan: &[PlanOp],
2595 params: &Params,
2596 bound: usize,
2597) -> Result<ResultSet, String> {
2598 let proj_pos = match plan
2599 .iter()
2600 .position(|op| matches!(op, PlanOp::Project { .. }))
2601 {
2602 Some(p) => p,
2603 None => return Ok(ResultSet::new(vec![])),
2604 };
2605 let producers = &plan[..proj_pos];
2606 let project_items = match &plan[proj_pos] {
2607 PlanOp::Project { items } => items,
2608 _ => unreachable!(),
2609 };
2610 let columns: Vec<String> = project_items.iter().map(column_name).collect();
2611 let vars = collect_vars(plan);
2612 let ctx = PullCtx {
2613 view,
2614 vars: &vars,
2615 project_items,
2616 params,
2617 bound,
2618 };
2619 let mut initial_row: Row = vec![None; vars.names.len()];
2620 let mut result_rows: Vec<Vec<Option<Value>>> = Vec::with_capacity(bound);
2621 pull_rows(&ctx, producers, &mut initial_row, &mut result_rows)?;
2622 let skip_n = plan[proj_pos + 1..]
2625 .iter()
2626 .find_map(|op| match op {
2627 PlanOp::Skip(ls) => Some(resolve_ls(ls, params)),
2628 _ => None,
2629 })
2630 .transpose()?
2631 .unwrap_or(0);
2632 let skip_n = usize::try_from(skip_n).unwrap_or(usize::MAX);
2633 let mut rs = ResultSet::new(columns);
2634 for row in result_rows.into_iter().skip(skip_n) {
2635 rs.push_row(row);
2636 }
2637 Ok(rs)
2638}
2639
2640enum AggAcc {
2654 Count(u64),
2655 Sum {
2658 ints: i64,
2659 floats: f64,
2660 any_float: bool,
2661 has_value: bool,
2662 },
2663 Avg {
2664 sum: f64,
2665 n: u64,
2666 },
2667 Min(Option<Value>),
2668 Max(Option<Value>),
2669 Collect(Vec<Value>),
2670 Distinct {
2673 seen: HashSet<Option<ValueKey>>,
2674 inner: Box<AggAcc>,
2675 },
2676}
2677
2678impl AggAcc {
2679 fn for_arg(func: &AggFunc, arg: &AggArg) -> Self {
2682 match arg {
2683 AggArg::Distinct(_) => AggAcc::Distinct {
2684 seen: HashSet::new(),
2685 inner: Box::new(AggAcc::new(func)),
2686 },
2687 _ => AggAcc::new(func),
2688 }
2689 }
2690
2691 fn new(func: &AggFunc) -> Self {
2692 match func {
2693 AggFunc::Count => AggAcc::Count(0),
2694 AggFunc::Sum => AggAcc::Sum {
2695 ints: 0,
2696 floats: 0.0,
2697 any_float: false,
2698 has_value: false,
2699 },
2700 AggFunc::Avg => AggAcc::Avg { sum: 0.0, n: 0 },
2701 AggFunc::Min => AggAcc::Min(None),
2702 AggFunc::Max => AggAcc::Max(None),
2703 AggFunc::Collect => AggAcc::Collect(Vec::new()),
2704 }
2705 }
2706
2707 fn finish(self) -> Option<Value> {
2708 match self {
2709 AggAcc::Count(n) => Some(Value::Int(i64::try_from(n).unwrap_or(i64::MAX))),
2714 AggAcc::Sum {
2715 ints,
2716 floats,
2717 any_float,
2718 has_value,
2719 } => {
2720 if !has_value {
2721 None
2722 } else if any_float {
2723 Some(Value::Float(ints as f64 + floats))
2724 } else {
2725 Some(Value::Int(ints))
2726 }
2727 }
2728 AggAcc::Avg { sum, n } => {
2729 if n > 0 {
2730 Some(Value::Float(sum / n as f64))
2731 } else {
2732 None
2733 }
2734 }
2735 AggAcc::Min(v) => v,
2736 AggAcc::Max(v) => v,
2737 AggAcc::Collect(items) => Some(Value::List(items)),
2739 AggAcc::Distinct { inner, .. } => inner.finish(),
2740 }
2741 }
2742}
2743
2744fn numeric_val(v: &Value) -> Option<f64> {
2747 match v {
2748 Value::Int(n) => Some(*n as f64),
2749 Value::Float(f) => Some(*f),
2750 _ => None,
2751 }
2752}
2753
2754struct AggStreamCtx<'a> {
2756 view: &'a GraphView<'a>,
2757 vars: &'a VarTable,
2758 params: &'a Params<'a>,
2759 func: &'a AggFunc,
2760 arg: &'a AggArg,
2761}
2762
2763fn execute_aggregate(
2768 view: &GraphView,
2769 plan: &[PlanOp],
2770 params: &Params,
2771) -> Result<ResultSet, String> {
2772 let agg_pos = match plan
2773 .iter()
2774 .position(|op| matches!(op, PlanOp::Aggregate { .. }))
2775 {
2776 Some(p) => p,
2777 None => return Ok(ResultSet::new(vec![])),
2778 };
2779 let producers = &plan[..agg_pos];
2780 let (func, arg, column) = match &plan[agg_pos] {
2781 PlanOp::Aggregate { func, arg, column } => (func, arg, column),
2782 _ => unreachable!(),
2783 };
2784 let vars = collect_vars(plan);
2785 let ctx = AggStreamCtx {
2786 view,
2787 vars: &vars,
2788 params,
2789 func,
2790 arg,
2791 };
2792 let initial_row: Row = vec![None; vars.names.len()];
2793 let mut acc = AggAcc::for_arg(func, arg);
2794 agg_stream(&ctx, producers, &initial_row, &mut acc)?;
2795
2796 let value = acc.finish();
2797 let mut rs = ResultSet::new(vec![column.clone()]);
2798 rs.push_row(vec![value]);
2799 Ok(rs)
2800}
2801
2802fn distinct_value(
2826 view: &GraphView,
2827 vars: &VarTable,
2828 row: &Row,
2829 arg: &AggArg,
2830) -> Result<Option<Value>, String> {
2831 match arg {
2832 AggArg::Star => Ok(None),
2833 AggArg::Var(v) => {
2834 let Some(slot) = vars.slot(v) else {
2835 return Ok(None);
2836 };
2837 Ok(match row.get(slot).and_then(|c| c.as_ref()) {
2838 Some(Cell::Node(id)) => view.ids.key_of(*id).map(|k| Value::Str(k.to_owned())),
2839 Some(Cell::Scalar(val)) => Some(val.clone()),
2840 Some(Cell::Path(hops)) => Some(Value::Int(*hops as i64)),
2841 Some(Cell::Rel(e)) => Some(Value::Str(format!(
2842 "{}\u{1}{}\u{1}{}",
2843 e.etype, e.src, e.dst
2844 ))),
2845 None => None,
2846 })
2847 }
2848 AggArg::Prop { var, field } => resolve_prop(view, vars, row, var, field),
2849 AggArg::Distinct(inner) => distinct_value(view, vars, row, inner),
2851 }
2852}
2853
2854fn scalar_agg_input(
2859 view: &GraphView,
2860 vars: &VarTable,
2861 row: &Row,
2862 arg: &AggArg,
2863) -> Result<Option<Value>, String> {
2864 Ok(match arg {
2865 AggArg::Prop { var, field } => resolve_prop(view, vars, row, var, field)?,
2866 AggArg::Var(v) => match vars
2867 .slot(v)
2868 .and_then(|s| row.get(s))
2869 .and_then(|c| c.as_ref())
2870 {
2871 Some(Cell::Scalar(x)) => Some(x.clone()),
2872 _ => None,
2873 },
2874 _ => None,
2875 })
2876}
2877
2878fn fold_extreme(current: &mut Option<Value>, next: Value, largest: bool) {
2882 if numeric_val(&next).is_none() && !matches!(next, Value::Bool(_)) {
2883 return;
2884 }
2885 *current = Some(match current.take() {
2886 None => next,
2887 Some(prev) => {
2888 if cmp_optional(Some(&prev), Some(&next), largest) == std::cmp::Ordering::Greater {
2889 next
2890 } else {
2891 prev
2892 }
2893 }
2894 });
2895}
2896
2897fn update_acc(
2903 view: &GraphView,
2904 vars: &VarTable,
2905 row: &Row,
2906 func: &AggFunc,
2907 arg: &AggArg,
2908 acc: &mut AggAcc,
2909) -> Result<(), String> {
2910 if let AggArg::Distinct(inner_arg) = arg {
2914 let AggAcc::Distinct { seen, inner } = acc else {
2915 return Ok(());
2916 };
2917 let val = distinct_value(view, vars, row, inner_arg)?;
2918 let Some(val) = val else {
2921 return Ok(());
2922 };
2923 if !seen.insert(group_key_normalize(&val)) {
2924 return Ok(());
2925 }
2926 return update_acc(view, vars, row, func, inner_arg, inner);
2927 }
2928 match (func, arg) {
2929 (AggFunc::Count, AggArg::Star) => {
2930 if let AggAcc::Count(n) = acc {
2931 *n += 1;
2932 }
2933 }
2934 (AggFunc::Count, AggArg::Var(v)) => {
2935 let slot = vars.slot(v);
2936 let is_bound = slot
2937 .and_then(|s| row.get(s))
2938 .and_then(|c| c.as_ref())
2939 .is_some();
2940 if is_bound {
2941 if let AggAcc::Count(n) = acc {
2942 *n += 1;
2943 }
2944 }
2945 }
2946 (AggFunc::Count, AggArg::Prop { var, field }) => {
2947 let val = resolve_prop(view, vars, row, var, field)?;
2948 if val.is_some() {
2949 if let AggAcc::Count(n) = acc {
2950 *n += 1;
2951 }
2952 }
2953 }
2954 (
2959 AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max,
2960 AggArg::Prop { .. } | AggArg::Var(_),
2961 ) => {
2962 let Some(v) = scalar_agg_input(view, vars, row, arg)? else {
2963 return Ok(());
2964 };
2965 match acc {
2966 AggAcc::Sum {
2967 ints,
2968 floats,
2969 any_float,
2970 has_value,
2971 } => match v {
2972 Value::Int(n) => {
2973 *ints = ints
2974 .checked_add(n)
2975 .ok_or_else(|| "sum(): integer overflow".to_string())?;
2976 *has_value = true;
2977 }
2978 Value::Float(f) => {
2979 *floats += f;
2980 *any_float = true;
2981 *has_value = true;
2982 }
2983 _ => {}
2984 },
2985 AggAcc::Avg { sum, n } => {
2986 if let Some(num) = numeric_val(&v) {
2987 *sum += num;
2988 *n += 1;
2989 }
2990 }
2991 AggAcc::Min(current) => fold_extreme(current, v, false),
2992 AggAcc::Max(current) => fold_extreme(current, v, true),
2993 _ => {}
2994 }
2995 }
2996 (AggFunc::Collect, AggArg::Var(v)) => {
2997 let val = vars
3000 .slot(v)
3001 .and_then(|s| row.get(s))
3002 .and_then(|c| c.as_ref())
3003 .and_then(|cell| match cell {
3004 Cell::Scalar(x) => Some(x.clone()),
3005 Cell::Node(id) => view.ids.key_of(*id).map(|k| Value::Str(k.to_owned())),
3006 Cell::Path(h) => Some(Value::Int(*h as i64)),
3007 Cell::Rel(_) => None,
3008 });
3009 if let Some(val) = val {
3010 if let AggAcc::Collect(items) = acc {
3011 items.push(val);
3012 }
3013 }
3014 }
3015 (AggFunc::Collect, AggArg::Prop { var, field }) => {
3016 if let Some(val) = resolve_prop(view, vars, row, var, field)? {
3017 if let AggAcc::Collect(items) = acc {
3018 items.push(val);
3019 }
3020 }
3021 }
3022 _ => {}
3025 }
3026 Ok(())
3027}
3028
3029fn agg_stream(
3032 ctx: &AggStreamCtx<'_>,
3033 ops: &[PlanOp],
3034 row: &Row,
3035 acc: &mut AggAcc,
3036) -> Result<(), String> {
3037 let (op, rest) = match ops.split_first() {
3038 Some(pair) => pair,
3039 None => {
3040 update_acc(ctx.view, ctx.vars, row, ctx.func, ctx.arg, acc)?;
3042 return Ok(());
3043 }
3044 };
3045
3046 match op {
3047 PlanOp::ScanLabel { var, label } => {
3048 let ids = scan_ids(ctx.view, label.as_deref());
3049 let slot = ctx
3050 .vars
3051 .slot(var)
3052 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3053 for &id in &ids {
3054 let mut next = row.clone();
3055 next[slot] = Some(Cell::Node(id));
3056 agg_stream(ctx, rest, &next, acc)?;
3057 }
3058 }
3059 PlanOp::ScanKey { var, key, label } => {
3060 let slot = ctx
3061 .vars
3062 .slot(var)
3063 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3064 if let Some(id) =
3065 resolve_scan_key_id(ctx.view, ctx.vars, row, key, label.as_deref(), ctx.params)?
3066 {
3067 let mut next = row.clone();
3068 next[slot] = Some(Cell::Node(id));
3069 agg_stream(ctx, rest, &next, acc)?;
3070 }
3071 }
3072 PlanOp::IndexScan {
3073 var,
3074 label,
3075 field,
3076 value,
3077 } => {
3078 let ids = index_scan_ids(
3079 ctx.view,
3080 ctx.vars,
3081 row,
3082 label.as_deref(),
3083 field,
3084 value,
3085 ctx.params,
3086 )?;
3087 let slot = ctx
3088 .vars
3089 .slot(var)
3090 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3091 for &id in &ids {
3092 let mut next = row.clone();
3093 next[slot] = Some(Cell::Node(id));
3094 agg_stream(ctx, rest, &next, acc)?;
3095 }
3096 }
3097 PlanOp::IndexIntersect {
3098 var,
3099 label,
3100 equalities,
3101 } => {
3102 let ids = index_intersect_ids(
3103 ctx.view,
3104 ctx.vars,
3105 row,
3106 label.as_deref(),
3107 equalities,
3108 ctx.params,
3109 )?;
3110 let slot = ctx
3111 .vars
3112 .slot(var)
3113 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3114 for id in ids {
3115 let mut next = row.clone();
3116 next[slot] = Some(Cell::Node(id));
3117 agg_stream(ctx, rest, &next, acc)?;
3118 }
3119 }
3120 PlanOp::Expand {
3121 from,
3122 rel_var,
3123 etypes,
3124 dir,
3125 to,
3126 to_label,
3127 to_props,
3128 } => {
3129 let etypes = resolve_etypes(ctx.view, etypes);
3130 let exp_dir = map_dir(*dir);
3131 let to_slot = ctx
3132 .vars
3133 .slot(to)
3134 .ok_or_else(|| format!("unbound variable `{to}`"))?;
3135 let rel_slot = rel_var.as_ref().and_then(|rv| ctx.vars.slot(rv));
3136 let from_id = require_node(row, ctx.vars, from)?;
3137 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
3138 Some(Cell::Node(id)) => Some(*id),
3139 Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
3140 return Err(format!("variable `{to}` is not a node"))
3141 }
3142 None => None,
3143 };
3144 for e in expand(ctx.view, from_id, etypes.as_deref(), exp_dir) {
3145 if row_has_edge(row, &e) {
3146 continue;
3147 }
3148 let nbr = neighbor(from_id, &e, *dir);
3149 if !ctx.view.visible(nbr) {
3150 continue;
3151 }
3152 if let Some(want) = bound_to {
3153 if nbr != want {
3154 continue;
3155 }
3156 }
3157 if !node_matches(
3158 ctx.view,
3159 ctx.vars,
3160 row,
3161 nbr,
3162 to_label.as_deref(),
3163 to_props,
3164 ctx.params,
3165 )? {
3166 continue;
3167 }
3168 let mut next = row.clone();
3169 if let Some(slot) = rel_slot {
3170 next[slot] = Some(Cell::Rel(e));
3171 }
3172 if bound_to.is_none() {
3173 next[to_slot] = Some(Cell::Node(nbr));
3174 }
3175 agg_stream(ctx, rest, &next, acc)?;
3176 }
3177 }
3178 PlanOp::Filter { expr } => {
3179 if eval_expr(ctx.view, ctx.vars, row, expr, ctx.params, 0)? {
3180 agg_stream(ctx, rest, row, acc)?;
3181 }
3182 }
3183 PlanOp::LookupProps { var, props } => {
3184 let id = require_node(row, ctx.vars, var)?;
3185 if node_matches(ctx.view, ctx.vars, row, id, None, props, ctx.params)? {
3186 agg_stream(ctx, rest, row, acc)?;
3187 }
3188 }
3189 PlanOp::JoinBound { var, label, props } => {
3190 let id = require_node(row, ctx.vars, var)?;
3191 if node_matches(
3192 ctx.view,
3193 ctx.vars,
3194 row,
3195 id,
3196 label.as_deref(),
3197 props,
3198 ctx.params,
3199 )? {
3200 agg_stream(ctx, rest, row, acc)?;
3201 }
3202 }
3203 PlanOp::VarExpand {
3204 from,
3205 rel_var,
3206 etypes,
3207 dir,
3208 to,
3209 min,
3210 max,
3211 } => {
3212 let new_rows = exec_var_expand(
3213 ctx.view,
3214 ctx.vars,
3215 std::slice::from_ref(row),
3216 from,
3217 rel_var,
3218 etypes,
3219 *dir,
3220 to,
3221 *min,
3222 *max,
3223 )?;
3224 for nr in &new_rows {
3225 agg_stream(ctx, rest, nr, acc)?;
3226 }
3227 }
3228 PlanOp::ShortestPath {
3229 from,
3230 rel_var,
3231 etypes,
3232 dir,
3233 to,
3234 max_hops,
3235 } => {
3236 let new_rows = exec_shortest_path(
3237 ctx.view,
3238 ctx.vars,
3239 std::slice::from_ref(row),
3240 from,
3241 rel_var,
3242 etypes,
3243 *dir,
3244 to,
3245 *max_hops,
3246 )?;
3247 for nr in &new_rows {
3248 agg_stream(ctx, rest, nr, acc)?;
3249 }
3250 }
3251 PlanOp::Project { .. } => {
3253 return Err(
3254 "agg executor: Project in producer slice — plan is structurally malformed"
3255 .to_string(),
3256 );
3257 }
3258 PlanOp::Distinct => {
3259 return Err(
3260 "agg executor: Distinct in producer slice — plan is structurally malformed"
3261 .to_string(),
3262 );
3263 }
3264 PlanOp::OrderBy { .. } => {
3265 return Err(
3266 "agg executor: OrderBy in producer slice — structurally malformed".to_string(),
3267 );
3268 }
3269 PlanOp::Skip(_) => {
3270 return Err(
3271 "agg executor: Skip in producer slice — structurally malformed".to_string(),
3272 );
3273 }
3274 PlanOp::Limit(_) => {
3275 return Err(
3276 "agg executor: Limit in producer slice — structurally malformed".to_string(),
3277 );
3278 }
3279 PlanOp::Aggregate { .. } => {
3280 return Err(
3281 "agg executor: nested Aggregate in producer slice — structurally malformed"
3282 .to_string(),
3283 );
3284 }
3285 PlanOp::GroupAggregate { .. } => {
3286 return Err(
3287 "agg executor: GroupAggregate in producer slice — structurally malformed"
3288 .to_string(),
3289 );
3290 }
3291 PlanOp::With { .. } => {
3292 return Err(
3293 "agg executor: With in producer slice — structurally malformed".to_string(),
3294 );
3295 }
3296 PlanOp::Unwind { .. } => {
3297 return Err(
3298 "agg executor: Unwind in producer slice — structurally malformed".to_string(),
3299 );
3300 }
3301 PlanOp::LeftOuterApply { .. } => {
3302 return Err(
3303 "agg executor: LeftOuterApply in producer slice — structurally malformed"
3304 .to_string(),
3305 );
3306 }
3307 }
3308 Ok(())
3309}
3310
3311struct GroupStreamCtx<'a> {
3319 view: &'a GraphView<'a>,
3320 vars: &'a VarTable,
3321 params: &'a Params<'a>,
3322 keys: &'a [(String, RetItem)],
3323 aggs: &'a [(AggFunc, AggArg, String)],
3324}
3325
3326fn build_group_projected(
3328 keys: &[(String, RetItem)],
3329 aggs: &[(AggFunc, AggArg, String)],
3330 key_order: Vec<GroupKey>,
3331 groups: &mut HashMap<GroupKey, GroupEntry>,
3332) -> Projected {
3333 let columns: Vec<String> = keys
3334 .iter()
3335 .map(|(col, _)| col.clone())
3336 .chain(aggs.iter().map(|(_, _, col)| col.clone()))
3337 .collect();
3338 let mut rows: Vec<Vec<Option<Value>>> = Vec::with_capacity(key_order.len());
3339 for gk in key_order {
3340 let (display_keys, accs) = groups.remove(&gk).unwrap_or_default();
3341 let mut row: Vec<Option<Value>> = Vec::with_capacity(columns.len());
3342 for display_val in display_keys {
3345 row.push(display_val);
3346 }
3347 for acc in accs {
3348 row.push(acc.finish());
3349 }
3350 rows.push(row);
3351 }
3352 Projected { columns, rows }
3353}
3354
3355fn key_source_cells(vars: &VarTable, row: &Row, keys: &[(String, RetItem)]) -> Vec<Option<Cell>> {
3366 keys.iter()
3367 .map(|(_, item)| match &item.value {
3368 RetVal::Var(v) => vars
3369 .slot(v)
3370 .and_then(|s| row.get(s))
3371 .and_then(|c| c.clone()),
3372 _ => None,
3373 })
3374 .collect()
3375}
3376
3377fn group_result_to_rows(
3378 keys: &[(String, RetItem)],
3379 aggs: &[(AggFunc, AggArg, String)],
3380 key_order: Vec<GroupKey>,
3381 groups: &mut HashMap<GroupKey, GroupEntry>,
3382 key_cells: &mut HashMap<GroupKey, Vec<Option<Cell>>>,
3383 vars: &VarTable,
3384) -> Vec<Row> {
3385 let row_len = vars.names.len();
3386 let mut out: Vec<Row> = Vec::with_capacity(key_order.len());
3387 for gk in key_order {
3388 let (display_keys, accs) = groups.remove(&gk).unwrap_or_default();
3389 let mut cells = key_cells.remove(&gk).unwrap_or_default();
3390 cells.resize(keys.len(), None);
3391 let mut row: Row = vec![None; row_len];
3392 for (((col, _), val), cell) in keys.iter().zip(display_keys).zip(cells) {
3393 if let Some(slot) = vars.slot(col) {
3394 row[slot] = cell.or_else(|| val.map(Cell::Scalar));
3395 }
3396 }
3397 for ((_, _, col), acc) in aggs.iter().zip(accs) {
3398 if let Some(slot) = vars.slot(col) {
3399 row[slot] = acc.finish().map(Cell::Scalar);
3400 }
3401 }
3402 out.push(row);
3403 }
3404 out
3405}
3406
3407fn exec_order_by_rows(vars: &VarTable, rows: &mut Vec<Row>, items: &[OrderItem], view: &GraphView) {
3414 let mut key_table: Vec<Vec<Option<Value>>> = Vec::with_capacity(rows.len());
3417 for row in rows.iter() {
3418 let mut row_key: Vec<Option<Value>> = Vec::with_capacity(items.len());
3419 for item in items {
3420 let val = match &item.target {
3421 OrderTarget::Alias(name) | OrderTarget::Var(name) => vars
3422 .slot(name)
3423 .and_then(|s| row.get(s))
3424 .and_then(|c| c.as_ref())
3425 .and_then(|c| match c {
3426 Cell::Scalar(v) => Some(v.clone()),
3427 Cell::Node(id) => view.ids.key_of(*id).map(|k| Value::Str(k.to_owned())),
3428 Cell::Path(hops) => Some(Value::Int(*hops as i64)),
3429 Cell::Rel(_) => None,
3430 }),
3431 OrderTarget::Prop { var, field } => vars
3432 .slot(var)
3433 .and_then(|s| row.get(s))
3434 .and_then(|c| c.as_ref())
3435 .and_then(|c| match c {
3436 Cell::Node(id) => view
3437 .prop(*id, field)
3438 .map(|vr| vr.into_value())
3439 .or_else(|| node_identity_prop(view, *id, field)),
3440 Cell::Rel(e) => view.edge_props.get(e.etype, e.src, e.dst, field),
3441 _ => None,
3442 }),
3443 };
3444 row_key.push(val);
3445 }
3446 key_table.push(row_key);
3447 }
3448 let mut indices: Vec<usize> = (0..rows.len()).collect();
3450 indices.sort_by(|&a, &b| {
3451 for (ki, item) in items.iter().enumerate() {
3452 let c = cmp_optional(
3453 key_table[a].get(ki).and_then(|x| x.as_ref()),
3454 key_table[b].get(ki).and_then(|x| x.as_ref()),
3455 item.descending,
3456 );
3457 if c != std::cmp::Ordering::Equal {
3458 return c;
3459 }
3460 }
3461 std::cmp::Ordering::Equal
3462 });
3463 let sorted: Vec<Row> = indices.into_iter().map(|i| rows[i].clone()).collect();
3464 *rows = sorted;
3465}
3466
3467fn execute_group_aggregate(
3473 view: &GraphView,
3474 plan: &[PlanOp],
3475 params: &Params,
3476) -> Result<ResultSet, String> {
3477 let gagg_pos = plan
3478 .iter()
3479 .position(|op| matches!(op, PlanOp::GroupAggregate { .. }))
3480 .ok_or_else(|| "internal: GroupAggregate op not found in plan".to_string())?;
3481 let producers = &plan[..gagg_pos];
3482 let (keys, aggs) = match &plan[gagg_pos] {
3483 PlanOp::GroupAggregate { keys, aggs } => (keys, aggs),
3484 _ => unreachable!(),
3485 };
3486 let tail = &plan[gagg_pos + 1..];
3487 let vars = collect_vars(plan);
3488 let initial_row: Row = vec![None; vars.names.len()];
3489 let mut groups: HashMap<GroupKey, GroupEntry> = HashMap::new();
3490 let mut key_order: Vec<GroupKey> = Vec::new();
3491 let ctx = GroupStreamCtx {
3492 view,
3493 vars: &vars,
3494 params,
3495 keys,
3496 aggs,
3497 };
3498 group_stream(&ctx, producers, &initial_row, &mut groups, &mut key_order)?;
3499 if keys.is_empty() && key_order.is_empty() {
3503 let empty_key: GroupKey = vec![];
3504 key_order.push(empty_key.clone());
3505 groups.insert(
3506 empty_key,
3507 (
3508 vec![],
3509 aggs.iter().map(|(f, a, _)| AggAcc::for_arg(f, a)).collect(),
3510 ),
3511 );
3512 }
3513 let mut projected = build_group_projected(keys, aggs, key_order, &mut groups);
3514 for op in tail {
3516 match op {
3517 PlanOp::OrderBy { items } => exec_order_by(&mut projected, items)?,
3518 PlanOp::Skip(ls) => {
3519 let n = resolve_ls(ls, params)?;
3520 apply_skip(&mut projected.rows, n);
3521 }
3522 PlanOp::Limit(ls) => {
3523 let n = resolve_ls(ls, params)?;
3524 apply_limit(&mut projected.rows, n);
3525 }
3526 _ => {} }
3528 }
3529 Ok(finish(projected))
3530}
3531
3532fn group_stream(
3539 ctx: &GroupStreamCtx<'_>,
3540 ops: &[PlanOp],
3541 row: &Row,
3542 groups: &mut HashMap<GroupKey, GroupEntry>,
3543 key_order: &mut Vec<GroupKey>,
3544) -> Result<(), String> {
3545 let (op, rest) = match ops.split_first() {
3546 Some(pair) => pair,
3547 None => {
3548 let mut gk: GroupKey = Vec::with_capacity(ctx.keys.len());
3551 let mut display_vals: Vec<Option<Value>> = Vec::with_capacity(ctx.keys.len());
3552 for (_, item) in ctx.keys {
3553 let val = project_item(ctx.view, ctx.vars, row, item, ctx.params)?;
3554 gk.push(val.as_ref().and_then(group_key_normalize));
3555 display_vals.push(val);
3556 }
3557 if !groups.contains_key(&gk) {
3558 if groups.len() >= max_groups() {
3559 return Err(group_cap_err());
3560 }
3561 key_order.push(gk.clone());
3562 let init: Vec<AggAcc> = ctx
3563 .aggs
3564 .iter()
3565 .map(|(f, a, _)| AggAcc::for_arg(f, a))
3566 .collect();
3567 groups.insert(gk.clone(), (display_vals, init));
3568 }
3569 let (_, accs) = groups.get_mut(&gk).unwrap();
3570 for (acc, (func, arg, _)) in accs.iter_mut().zip(ctx.aggs.iter()) {
3571 update_acc(ctx.view, ctx.vars, row, func, arg, acc)?;
3572 }
3573 return Ok(());
3574 }
3575 };
3576
3577 match op {
3578 PlanOp::ScanLabel { var, label } => {
3579 let ids = scan_ids(ctx.view, label.as_deref());
3580 let slot = ctx
3581 .vars
3582 .slot(var)
3583 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3584 for &id in &ids {
3585 let mut next = row.clone();
3586 next[slot] = Some(Cell::Node(id));
3587 group_stream(ctx, rest, &next, groups, key_order)?;
3588 }
3589 }
3590 PlanOp::ScanKey { var, key, label } => {
3591 let slot = ctx
3592 .vars
3593 .slot(var)
3594 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3595 if let Some(id) =
3596 resolve_scan_key_id(ctx.view, ctx.vars, row, key, label.as_deref(), ctx.params)?
3597 {
3598 let mut next = row.clone();
3599 next[slot] = Some(Cell::Node(id));
3600 group_stream(ctx, rest, &next, groups, key_order)?;
3601 }
3602 }
3603 PlanOp::IndexScan {
3604 var,
3605 label,
3606 field,
3607 value,
3608 } => {
3609 let ids = index_scan_ids(
3610 ctx.view,
3611 ctx.vars,
3612 row,
3613 label.as_deref(),
3614 field,
3615 value,
3616 ctx.params,
3617 )?;
3618 let slot = ctx
3619 .vars
3620 .slot(var)
3621 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3622 for &id in &ids {
3623 let mut next = row.clone();
3624 next[slot] = Some(Cell::Node(id));
3625 group_stream(ctx, rest, &next, groups, key_order)?;
3626 }
3627 }
3628 PlanOp::IndexIntersect {
3629 var,
3630 label,
3631 equalities,
3632 } => {
3633 let ids = index_intersect_ids(
3634 ctx.view,
3635 ctx.vars,
3636 row,
3637 label.as_deref(),
3638 equalities,
3639 ctx.params,
3640 )?;
3641 let slot = ctx
3642 .vars
3643 .slot(var)
3644 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3645 for id in ids {
3646 let mut next = row.clone();
3647 next[slot] = Some(Cell::Node(id));
3648 group_stream(ctx, rest, &next, groups, key_order)?;
3649 }
3650 }
3651 PlanOp::Expand {
3652 from,
3653 rel_var,
3654 etypes,
3655 dir,
3656 to,
3657 to_label,
3658 to_props,
3659 } => {
3660 let etypes = resolve_etypes(ctx.view, etypes);
3661 let exp_dir = map_dir(*dir);
3662 let to_slot = ctx
3663 .vars
3664 .slot(to)
3665 .ok_or_else(|| format!("unbound variable `{to}`"))?;
3666 let rel_slot = rel_var.as_ref().and_then(|rv| ctx.vars.slot(rv));
3667 let from_id = require_node(row, ctx.vars, from)?;
3668 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
3669 Some(Cell::Node(id)) => Some(*id),
3670 Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
3671 return Err(format!("variable `{to}` is not a node"))
3672 }
3673 None => None,
3674 };
3675 for e in expand(ctx.view, from_id, etypes.as_deref(), exp_dir) {
3676 if row_has_edge(row, &e) {
3677 continue;
3678 }
3679 let nbr = neighbor(from_id, &e, *dir);
3680 if !ctx.view.visible(nbr) {
3681 continue;
3682 }
3683 if let Some(want) = bound_to {
3684 if nbr != want {
3685 continue;
3686 }
3687 }
3688 if !node_matches(
3689 ctx.view,
3690 ctx.vars,
3691 row,
3692 nbr,
3693 to_label.as_deref(),
3694 to_props,
3695 ctx.params,
3696 )? {
3697 continue;
3698 }
3699 let mut next = row.clone();
3700 if let Some(slot) = rel_slot {
3701 next[slot] = Some(Cell::Rel(e));
3702 }
3703 if bound_to.is_none() {
3704 next[to_slot] = Some(Cell::Node(nbr));
3705 }
3706 group_stream(ctx, rest, &next, groups, key_order)?;
3707 }
3708 }
3709 PlanOp::Filter { expr } => {
3710 if eval_expr(ctx.view, ctx.vars, row, expr, ctx.params, 0)? {
3711 group_stream(ctx, rest, row, groups, key_order)?;
3712 }
3713 }
3714 PlanOp::LookupProps { var, props } => {
3715 let id = require_node(row, ctx.vars, var)?;
3716 if node_matches(ctx.view, ctx.vars, row, id, None, props, ctx.params)? {
3717 group_stream(ctx, rest, row, groups, key_order)?;
3718 }
3719 }
3720 PlanOp::JoinBound { var, label, props } => {
3721 let id = require_node(row, ctx.vars, var)?;
3722 if node_matches(
3723 ctx.view,
3724 ctx.vars,
3725 row,
3726 id,
3727 label.as_deref(),
3728 props,
3729 ctx.params,
3730 )? {
3731 group_stream(ctx, rest, row, groups, key_order)?;
3732 }
3733 }
3734 PlanOp::VarExpand {
3735 from,
3736 rel_var,
3737 etypes,
3738 dir,
3739 to,
3740 min,
3741 max,
3742 } => {
3743 let new_rows = exec_var_expand(
3744 ctx.view,
3745 ctx.vars,
3746 std::slice::from_ref(row),
3747 from,
3748 rel_var,
3749 etypes,
3750 *dir,
3751 to,
3752 *min,
3753 *max,
3754 )?;
3755 for nr in &new_rows {
3756 group_stream(ctx, rest, nr, groups, key_order)?;
3757 }
3758 }
3759 PlanOp::ShortestPath {
3760 from,
3761 rel_var,
3762 etypes,
3763 dir,
3764 to,
3765 max_hops,
3766 } => {
3767 let new_rows = exec_shortest_path(
3768 ctx.view,
3769 ctx.vars,
3770 std::slice::from_ref(row),
3771 from,
3772 rel_var,
3773 etypes,
3774 *dir,
3775 to,
3776 *max_hops,
3777 )?;
3778 for nr in &new_rows {
3779 group_stream(ctx, rest, nr, groups, key_order)?;
3780 }
3781 }
3782 PlanOp::Project { .. } => {
3784 return Err(
3785 "group executor: Project in producer slice — structurally malformed".to_string(),
3786 );
3787 }
3788 PlanOp::Distinct => {
3789 return Err(
3790 "group executor: Distinct in producer slice — structurally malformed".to_string(),
3791 );
3792 }
3793 PlanOp::OrderBy { .. } => {
3794 return Err(
3795 "group executor: OrderBy in producer slice — structurally malformed".to_string(),
3796 );
3797 }
3798 PlanOp::Skip(_) => {
3799 return Err(
3800 "group executor: Skip in producer slice — structurally malformed".to_string(),
3801 );
3802 }
3803 PlanOp::Limit(_) => {
3804 return Err(
3805 "group executor: Limit in producer slice — structurally malformed".to_string(),
3806 );
3807 }
3808 PlanOp::Aggregate { .. } => {
3809 return Err(
3810 "group executor: Aggregate in producer slice — structurally malformed".to_string(),
3811 );
3812 }
3813 PlanOp::GroupAggregate { .. } => {
3814 return Err(
3815 "group executor: nested GroupAggregate in producer slice — structurally malformed"
3816 .to_string(),
3817 );
3818 }
3819 PlanOp::With { .. } => {
3820 return Err(
3821 "group executor: With in producer slice — structurally malformed".to_string(),
3822 );
3823 }
3824 PlanOp::Unwind { .. } => {
3825 return Err(
3826 "group executor: Unwind in producer slice — structurally malformed".to_string(),
3827 );
3828 }
3829 PlanOp::LeftOuterApply { .. } => {
3830 return Err(
3831 "group executor: LeftOuterApply in producer slice — structurally malformed"
3832 .to_string(),
3833 );
3834 }
3835 }
3836 Ok(())
3837}
3838
3839fn pull_rows(
3853 ctx: &PullCtx<'_>,
3854 ops: &[PlanOp],
3855 row: &mut Row,
3856 result: &mut Vec<Vec<Option<Value>>>,
3857) -> Result<(), String> {
3858 if result.len() >= ctx.bound {
3859 return Ok(());
3860 }
3861 let (op, rest) = match ops.split_first() {
3862 Some(pair) => pair,
3863 None => {
3864 let mut cells = Vec::with_capacity(ctx.project_items.len());
3866 for item in ctx.project_items {
3867 cells.push(project_item(ctx.view, ctx.vars, row, item, ctx.params)?);
3868 }
3869 result.push(cells);
3870 return Ok(());
3871 }
3872 };
3873 match op {
3874 PlanOp::ScanLabel { var, label } => {
3875 let slot = ctx
3876 .vars
3877 .slot(var)
3878 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3879 let want_sym = label.as_deref().and_then(|l| ctx.view.syms.get(l));
3882 if label.is_some() && want_sym.is_none() {
3883 return Ok(());
3884 }
3885 let prev = row[slot].clone();
3887
3888 let fused_filter = rest.first().and_then(|next_op| {
3894 if let PlanOp::Filter {
3895 expr:
3896 Expr::Cmp {
3897 lhs:
3898 Operand::Prop {
3899 var: ref fv,
3900 field: ref f,
3901 },
3902 op: ref cmp_op_ref,
3903 rhs: Operand::Lit(ref lit),
3904 },
3905 } = *next_op
3906 {
3907 if fv == var {
3908 return Some((f.as_str(), cmp_op_ref, lit));
3909 }
3910 }
3911 None
3912 });
3913
3914 let fused_filter = fused_filter.filter(|(f, _, _)| !is_identity_field(f));
3918 if let Some((field, cmp_op_ref, lit)) = fused_filter {
3919 #[cfg(test)]
3924 FUSED_SCAN_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3925 let col = ctx.view.props.view_column(field);
3926 let rest_after_filter = &rest[1..];
3927 for (i, &sym) in ctx.view.labels.iter().enumerate() {
3928 if result.len() >= ctx.bound {
3929 break;
3930 }
3931 if sym == u32::MAX {
3932 continue;
3933 }
3934 if let Some(ws) = want_sym {
3935 if sym != ws {
3936 continue;
3937 }
3938 }
3939 let id = i as u32;
3940 if !ctx.view.visible(id) {
3941 continue;
3942 }
3943 if col.test(id, |v| eval_cmp(cmp_op_ref, v, lit)) {
3944 row[slot] = Some(Cell::Node(id));
3945 pull_rows(ctx, rest_after_filter, row, result)?;
3946 }
3947 }
3948 } else {
3949 for (i, &sym) in ctx.view.labels.iter().enumerate() {
3952 if result.len() >= ctx.bound {
3953 break;
3954 }
3955 if sym == u32::MAX {
3957 continue;
3958 }
3959 if let Some(ws) = want_sym {
3961 if sym != ws {
3962 continue;
3963 }
3964 }
3965 let id = i as u32;
3966 if !ctx.view.visible(id) {
3967 continue;
3968 }
3969 row[slot] = Some(Cell::Node(id));
3970 pull_rows(ctx, rest, row, result)?;
3971 }
3972 }
3973 row[slot] = prev;
3979 }
3980 PlanOp::ScanKey { var, key, label } => {
3981 let slot = ctx
3982 .vars
3983 .slot(var)
3984 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3985 if let Some(id) =
3986 resolve_scan_key_id(ctx.view, ctx.vars, row, key, label.as_deref(), ctx.params)?
3987 {
3988 let prev = row[slot].clone();
3989 row[slot] = Some(Cell::Node(id));
3990 pull_rows(ctx, rest, row, result)?;
3991 row[slot] = prev;
3992 }
3993 }
3994 PlanOp::IndexScan {
3995 var,
3996 label,
3997 field,
3998 value,
3999 } => {
4000 let slot = ctx
4001 .vars
4002 .slot(var)
4003 .ok_or_else(|| format!("unbound variable `{var}`"))?;
4004 let ids = index_scan_ids(
4005 ctx.view,
4006 ctx.vars,
4007 row,
4008 label.as_deref(),
4009 field,
4010 value,
4011 ctx.params,
4012 )?;
4013 let prev = row[slot].clone();
4014 for id in ids {
4015 if result.len() >= ctx.bound {
4016 break;
4017 }
4018 row[slot] = Some(Cell::Node(id));
4019 pull_rows(ctx, rest, row, result)?;
4020 }
4021 row[slot] = prev;
4022 }
4023 PlanOp::IndexIntersect {
4024 var,
4025 label,
4026 equalities,
4027 } => {
4028 let slot = ctx
4029 .vars
4030 .slot(var)
4031 .ok_or_else(|| format!("unbound variable `{var}`"))?;
4032 let ids = index_intersect_ids(
4033 ctx.view,
4034 ctx.vars,
4035 row,
4036 label.as_deref(),
4037 equalities,
4038 ctx.params,
4039 )?;
4040 let prev = row[slot].clone();
4041 for id in ids {
4042 if result.len() >= ctx.bound {
4043 break;
4044 }
4045 row[slot] = Some(Cell::Node(id));
4046 pull_rows(ctx, rest, row, result)?;
4047 }
4048 row[slot] = prev;
4049 }
4050 PlanOp::Expand {
4051 from,
4052 rel_var,
4053 etypes,
4054 dir,
4055 to,
4056 to_label,
4057 to_props,
4058 } => {
4059 let etypes = resolve_etypes(ctx.view, etypes);
4060 let exp_dir = map_dir(*dir);
4061 let to_slot = ctx
4062 .vars
4063 .slot(to)
4064 .ok_or_else(|| format!("unbound variable `{to}`"))?;
4065 let rel_slot = rel_var.as_ref().and_then(|rv| ctx.vars.slot(rv));
4066 let from_id = require_node(row, ctx.vars, from)?;
4067 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
4068 Some(Cell::Node(id)) => Some(*id),
4069 Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
4070 return Err(format!("variable `{to}` is not a node"))
4071 }
4072 None => None,
4073 };
4074 for e in expand(ctx.view, from_id, etypes.as_deref(), exp_dir) {
4075 if result.len() >= ctx.bound {
4076 break;
4077 }
4078 if row_has_edge(row, &e) {
4079 continue;
4080 }
4081 let nbr = neighbor(from_id, &e, *dir);
4082 if !ctx.view.visible(nbr) {
4083 continue;
4084 }
4085 if let Some(want) = bound_to {
4086 if nbr != want {
4087 continue;
4088 }
4089 }
4090 if !node_matches(
4091 ctx.view,
4092 ctx.vars,
4093 row,
4094 nbr,
4095 to_label.as_deref(),
4096 to_props,
4097 ctx.params,
4098 )? {
4099 continue;
4100 }
4101 let mut next = row.clone();
4102 if let Some(slot) = rel_slot {
4103 next[slot] = Some(Cell::Rel(e));
4104 }
4105 if bound_to.is_none() {
4106 next[to_slot] = Some(Cell::Node(nbr));
4107 }
4108 #[cfg(test)]
4109 record_expand_row();
4110 pull_rows(ctx, rest, &mut next, result)?;
4111 }
4112 }
4113 PlanOp::Filter { expr } => {
4114 if eval_expr(ctx.view, ctx.vars, row, expr, ctx.params, 0)? {
4115 pull_rows(ctx, rest, row, result)?;
4116 }
4117 }
4118 PlanOp::LookupProps { var, props } => {
4119 let id = require_node(row, ctx.vars, var)?;
4120 if node_matches(ctx.view, ctx.vars, row, id, None, props, ctx.params)? {
4121 pull_rows(ctx, rest, row, result)?;
4122 }
4123 }
4124 PlanOp::JoinBound { var, label, props } => {
4125 let id = require_node(row, ctx.vars, var)?;
4126 if node_matches(
4127 ctx.view,
4128 ctx.vars,
4129 row,
4130 id,
4131 label.as_deref(),
4132 props,
4133 ctx.params,
4134 )? {
4135 pull_rows(ctx, rest, row, result)?;
4136 }
4137 }
4138 PlanOp::Project { .. } => {
4143 return Err(
4144 "pull executor: Project reached pull_rows — plan is structurally malformed"
4145 .to_string(),
4146 );
4147 }
4148 PlanOp::Distinct => {
4149 return Err(
4150 "pull executor: Distinct reached pull_rows — DISTINCT queries must use \
4151 the staged path (row_bound returns None)"
4152 .to_string(),
4153 );
4154 }
4155 PlanOp::OrderBy { .. } => {
4156 return Err(
4157 "pull executor: OrderBy reached pull_rows — queries with ORDER BY \
4158 must use the staged path (row_bound returns None)"
4159 .to_string(),
4160 );
4161 }
4162 PlanOp::Skip(_) => {
4163 return Err(
4164 "pull executor: Skip reached pull_rows — Skip must appear after Project"
4165 .to_string(),
4166 );
4167 }
4168 PlanOp::Limit(_) => {
4169 return Err(
4170 "pull executor: Limit reached pull_rows — Limit must appear after Project"
4171 .to_string(),
4172 );
4173 }
4174 PlanOp::Aggregate { .. } => {
4175 return Err(
4176 "pull executor: Aggregate reached pull_rows — aggregate plans must use \
4177 the execute_aggregate path (routed before pull in execute_inner)"
4178 .to_string(),
4179 );
4180 }
4181 PlanOp::VarExpand { .. } => {
4186 return Err(
4187 "pull executor: VarExpand reached pull_rows — variable-length path \
4188 plans must use the staged path (row_bound returns None)"
4189 .to_string(),
4190 );
4191 }
4192 PlanOp::ShortestPath { .. } => {
4193 return Err(
4194 "pull executor: ShortestPath reached pull_rows — shortestPath plans \
4195 must use the staged path (row_bound returns None)"
4196 .to_string(),
4197 );
4198 }
4199 PlanOp::GroupAggregate { .. } => {
4200 return Err(
4201 "pull executor: GroupAggregate reached pull_rows — grouped aggregate plans \
4202 must use the execute_group_aggregate path (routed before pull in execute_inner)"
4203 .to_string(),
4204 );
4205 }
4206 PlanOp::With { .. } => {
4207 return Err(
4208 "pull executor: With reached pull_rows — pipeline plans must use the staged path \
4209 (row_bound returns None for plans containing With)"
4210 .to_string(),
4211 );
4212 }
4213 PlanOp::Unwind { .. } => {
4214 return Err(
4215 "pull executor: Unwind reached pull_rows — pipeline plans must use the staged path \
4216 (row_bound returns None for plans containing Unwind)"
4217 .to_string(),
4218 );
4219 }
4220 PlanOp::LeftOuterApply { .. } => {
4221 return Err(
4222 "pull executor: LeftOuterApply reached pull_rows — OPTIONAL MATCH plans must use \
4223 the staged path (row_bound returns None for plans containing LeftOuterApply)"
4224 .to_string(),
4225 );
4226 }
4227 }
4228 Ok(())
4229}
4230
4231fn eval_expr(
4232 view: &GraphView,
4233 vars: &VarTable,
4234 row: &Row,
4235 expr: &Expr,
4236 params: &Params,
4237 depth: u32,
4238) -> Result<bool, String> {
4239 if depth > 256 {
4240 return Err("expression nesting too deep".into());
4241 }
4242 match expr {
4243 Expr::And(lhs, rhs) => {
4244 let l = eval_expr(view, vars, row, lhs, params, depth + 1)?;
4245 let r = eval_expr(view, vars, row, rhs, params, depth + 1)?;
4246 Ok(l && r)
4247 }
4248 Expr::Or(lhs, rhs) => {
4249 let l = eval_expr(view, vars, row, lhs, params, depth + 1)?;
4250 let r = eval_expr(view, vars, row, rhs, params, depth + 1)?;
4251 Ok(l || r)
4252 }
4253 Expr::Not(inner) => Ok(!eval_expr(view, vars, row, inner, params, depth + 1)?),
4254 Expr::Cmp { lhs, op, rhs } => {
4255 let l = resolve_operand(view, vars, row, lhs, params)?;
4256 let r = resolve_operand(view, vars, row, rhs, params)?;
4257 match (l, r) {
4258 (Some(a), Some(b)) => Ok(eval_cmp(op, &a, &b)),
4259 _ => Ok(false),
4260 }
4261 }
4262 Expr::Truthy(op) => {
4263 let val = resolve_operand(view, vars, row, op, params)?;
4264 Ok(match val {
4265 None => false,
4266 Some(Value::Bool(b)) => b,
4267 Some(Value::Int(n)) => n != 0,
4268 Some(Value::Float(f)) => f != 0.0,
4269 Some(Value::Str(s)) => !s.is_empty(),
4270 Some(Value::List(v)) => !v.is_empty(),
4271 Some(Value::Map(m)) => !m.is_empty(),
4272 })
4273 }
4274 Expr::IsNull(op) => {
4275 let val = resolve_operand(view, vars, row, op, params)?;
4276 Ok(val.is_none())
4277 }
4278 Expr::IsNotNull(op) => {
4279 let val = resolve_operand(view, vars, row, op, params)?;
4280 Ok(val.is_some())
4281 }
4282 Expr::In { expr, list } => eval_in(view, vars, row, expr, list, params),
4283 }
4284}
4285
4286fn eval_in(
4287 view: &GraphView,
4288 vars: &VarTable,
4289 row: &Row,
4290 expr: &Operand,
4291 list: &[Operand],
4292 params: &Params,
4293) -> Result<bool, String> {
4294 let Some(needle) = resolve_operand(view, vars, row, expr, params)? else {
4295 return Ok(false);
4296 };
4297 for item_op in list {
4298 match resolve_operand(view, vars, row, item_op, params)? {
4299 None => {}
4300 Some(Value::List(items)) => {
4301 for item in items {
4302 if crate::filter::eval_cmp(&crate::filter::CmpOp::Eq, &needle, &item) {
4303 return Ok(true);
4304 }
4305 }
4306 }
4307 Some(item) if crate::filter::eval_cmp(&crate::filter::CmpOp::Eq, &needle, &item) => {
4308 return Ok(true);
4309 }
4310 Some(_) => {}
4311 }
4312 }
4313 Ok(false)
4314}
4315
4316fn exec_distinct(table: &mut Projected) -> Result<(), String> {
4318 let cap = max_intermediate_rows();
4319 let mut seen: BTreeSet<Vec<Option<ValueKey>>> = BTreeSet::new();
4320 let mut out = Vec::with_capacity(table.rows.len().min(cap));
4321 for row in table.rows.drain(..) {
4322 let key: Vec<Option<ValueKey>> = row
4323 .iter()
4324 .map(|cell| cell.as_ref().and_then(group_key_normalize))
4325 .collect();
4326 if seen.insert(key) {
4327 if out.len() >= cap {
4328 return Err(row_cap_err(cap));
4329 }
4330 out.push(row);
4331 }
4332 }
4333 table.rows = out;
4334 Ok(())
4335}
4336
4337fn column_name(item: &RetItem) -> String {
4338 if let Some(alias) = &item.alias {
4339 return alias.clone();
4340 }
4341 ret_val_label(&item.value).unwrap_or_else(|| match &item.value {
4345 RetVal::Agg { func, arg } => {
4346 let f = match func {
4347 AggFunc::Count => "COUNT",
4348 AggFunc::Sum => "SUM",
4349 AggFunc::Avg => "AVG",
4350 AggFunc::Min => "MIN",
4351 AggFunc::Max => "MAX",
4352 AggFunc::Collect => "COLLECT",
4353 };
4354 format!("{f}({})", agg_arg_label(arg))
4355 }
4356 _ => unreachable!("ret_val_label names every non-aggregate item"),
4357 })
4358}
4359
4360fn exec_project(
4361 view: &GraphView,
4362 vars: &VarTable,
4363 rows: &[Row],
4364 items: &[RetItem],
4365 params: &Params,
4366) -> Result<Projected, String> {
4367 let columns: Vec<String> = items.iter().map(column_name).collect();
4368 let mut out_rows = Vec::with_capacity(rows.len());
4369 for row in rows {
4370 let mut cells = Vec::with_capacity(items.len());
4371 for item in items {
4372 cells.push(project_item(view, vars, row, item, params)?);
4373 }
4374 out_rows.push(cells);
4375 }
4376 Ok(Projected {
4377 columns,
4378 rows: out_rows,
4379 })
4380}
4381
4382fn project_item(
4383 view: &GraphView,
4384 vars: &VarTable,
4385 row: &Row,
4386 item: &RetItem,
4387 params: &Params,
4388) -> Result<Option<Value>, String> {
4389 match &item.value {
4390 RetVal::Var(v) => {
4391 let slot = vars.slot(v).ok_or_else(|| format!("unbound variable `{v}`"))?;
4393 match row.get(slot).and_then(|c| c.as_ref()) {
4394 None => Ok(None),
4396 Some(Cell::Node(id)) => match view.ids.key_of(*id) {
4397 Some(key) => Ok(Some(Value::Str(key.to_owned()))),
4398 None => Err(format!("unknown node id {id}")),
4399 },
4400 Some(Cell::Scalar(val)) => Ok(Some(val.clone())),
4402 Some(Cell::Rel(_)) => Err(format!(
4403 "variable `{v}` is a relationship; return its properties ({v}.field) instead"
4404 )),
4405 Some(Cell::Path(hops)) => Ok(Some(Value::Int(*hops as i64))),
4406 }
4407 }
4408 RetVal::Prop { var, field } => resolve_prop(view, vars, row, var, field),
4409 RetVal::Agg { .. } => Err(
4412 "project_item: Agg variant reached exec_project — aggregate plans must not contain Project"
4413 .to_string(),
4414 ),
4415 RetVal::FuncCall { name, args } => eval_func(name, args, view, vars, row, params),
4416 RetVal::ScalarExpr(op) => resolve_operand(view, vars, row, op, params),
4417 }
4418}
4419
4420fn order_column(item: &OrderItem) -> String {
4421 match &item.target {
4422 OrderTarget::Alias(name) | OrderTarget::Var(name) => name.clone(),
4423 OrderTarget::Prop { var, field } => format!("{var}.{field}"),
4424 }
4425}
4426
4427fn exec_order_by(table: &mut Projected, items: &[OrderItem]) -> Result<(), String> {
4428 let mut keys = Vec::with_capacity(items.len());
4429 for item in items {
4430 let name = order_column(item);
4431 let idx = table
4432 .columns
4433 .iter()
4434 .position(|c| c == &name)
4435 .ok_or_else(|| format!("ORDER BY target `{name}` is not a projected column"))?;
4436 keys.push((idx, item.descending));
4437 }
4438 table.rows.sort_by(|a, b| {
4439 for &(idx, desc) in &keys {
4440 let c = cmp_optional(
4441 a.get(idx).and_then(|x| x.as_ref()),
4442 b.get(idx).and_then(|x| x.as_ref()),
4443 desc,
4444 );
4445 if c != std::cmp::Ordering::Equal {
4446 return c;
4447 }
4448 }
4449 std::cmp::Ordering::Equal
4450 });
4451 Ok(())
4452}
4453
4454fn resolve_ls(ls: &LimitSkip, params: &Params) -> Result<u64, String> {
4459 match ls {
4460 LimitSkip::Exact(n) => Ok(*n),
4461 LimitSkip::Param(name) => {
4462 let val = params
4463 .0
4464 .get(name)
4465 .ok_or_else(|| format!("missing parameter `{name}` (used in LIMIT/SKIP)"))?;
4466 match val {
4467 Value::Int(i) if *i >= 0 => Ok(*i as u64),
4468 Value::Int(i) => Err(format!(
4469 "LIMIT/SKIP parameter `{name}` must be a non-negative integer, got {i}"
4470 )),
4471 other => Err(format!(
4472 "LIMIT/SKIP parameter `{name}` must be an integer, got {other:?}"
4473 )),
4474 }
4475 }
4476 }
4477}
4478
4479fn apply_skip<T>(rows: &mut Vec<T>, n: u64) {
4480 let n = usize::try_from(n).unwrap_or(usize::MAX);
4481 if n >= rows.len() {
4482 rows.clear();
4483 } else {
4484 rows.drain(0..n);
4485 }
4486}
4487
4488fn apply_limit<T>(rows: &mut Vec<T>, n: u64) {
4489 let n = usize::try_from(n).unwrap_or(usize::MAX);
4490 rows.truncate(n);
4491}
4492
4493#[cfg(test)]
4494mod tests {
4495 use super::{execute, resolve_operand, Params, Row, VarTable};
4496 use crate::cypher::ast::{
4497 ArithOp, LimitSkip, Operand, OrderItem, OrderTarget, RetItem, RetVal,
4498 };
4499 use crate::cypher::plan::{plan, PlanOp};
4500 use crate::cypher::{lex, parse, RelDir};
4501 use crate::result::ResultSet;
4502 use crate::view::GraphView;
4503 use core_storage::v8::seam::{ColumnsView, EdgePropsView, TopologyView};
4504 use core_storage::{ColumnStore, EdgeProps, IdMap, Interner, Topology, Value};
4505 use proptest::prelude::*;
4506 use std::collections::BTreeMap;
4507
4508 struct Fx {
4509 ids: IdMap,
4510 syms: Interner,
4511 labels: Vec<u32>,
4512 props: ColumnStore,
4513 topo: Topology,
4514 eprops: EdgeProps,
4515 }
4516
4517 impl Fx {
4518 fn new() -> Self {
4519 Fx {
4520 ids: IdMap::new(),
4521 syms: Interner::new(),
4522 labels: vec![],
4523 props: ColumnStore::new(),
4524 topo: Topology::new(),
4525 eprops: EdgeProps::new(),
4526 }
4527 }
4528
4529 fn add(&mut self, label: &str, key: &str, props: Vec<(&str, Value)>) -> u32 {
4530 let id = self.ids.get_or_insert(key);
4531 let sym = self.syms.intern(label);
4532 self.labels.resize(id as usize + 1, u32::MAX);
4533 self.labels[id as usize] = sym;
4534 for (f, v) in props {
4535 self.props.set(id, f, v);
4536 }
4537 id
4538 }
4539
4540 fn edge(&mut self, etype: &str, src: u32, dst: u32, props: Vec<(&str, Value)>) {
4541 let et = self.syms.intern(etype);
4542 self.topo.add_edge(et, src, dst);
4543 for (f, v) in props {
4544 self.eprops.set(et, src, dst, f, v);
4545 }
4546 }
4547
4548 fn view(&self) -> GraphView<'_> {
4549 GraphView {
4550 ids: &self.ids,
4551 syms: &self.syms,
4552 labels: &self.labels,
4553 props: ColumnsView::owned(&self.props),
4554 topo: TopologyView::owned(&self.topo),
4555 edge_props: EdgePropsView::owned(&self.eprops),
4556 mask: None,
4557 prop_index: None,
4558 }
4559 }
4560
4561 fn view_indexed<'a>(
4562 &'a self,
4563 index: &'a core_storage::property_index::PropertyIndex,
4564 ) -> GraphView<'a> {
4565 GraphView {
4566 prop_index: Some(index),
4567 ..self.view()
4568 }
4569 }
4570 }
4571
4572 fn compile(src: &str) -> Vec<PlanOp> {
4573 plan(&parse(&lex(src).expect("lex")).expect("parse")).expect("plan")
4574 }
4575
4576 fn run(
4577 view: &GraphView,
4578 src: &str,
4579 params: &BTreeMap<String, Value>,
4580 ) -> Result<ResultSet, String> {
4581 execute(view, &compile(src), &Params(params))
4582 }
4583
4584 fn s(v: &str) -> Value {
4585 Value::Str(v.into())
4586 }
4587
4588 fn f(v: f64) -> Value {
4589 Value::Float(v)
4590 }
4591
4592 fn i(v: i64) -> Value {
4593 Value::Int(v)
4594 }
4595
4596 fn rows_of(rs: &ResultSet) -> Vec<Vec<Option<Value>>> {
4597 (0..rs.len()).map(|i| rs.row(i).to_vec()).collect()
4598 }
4599
4600 fn col(rs: &ResultSet, name: &str) -> Vec<Option<Value>> {
4601 (0..rs.len()).map(|i| rs.get(i, name).cloned()).collect()
4602 }
4603
4604 fn hop_graph() -> Fx {
4605 let mut fx = Fx::new();
4606 let ada = fx.add("Person", "ada", vec![]);
4607 let bob = fx.add("Person", "bob", vec![]);
4608 let cam = fx.add("Person", "cam", vec![]);
4609 let acme = fx.add("Company", "acme", vec![]);
4610 fx.edge("KNOWS", ada, bob, vec![]);
4611 fx.edge("KNOWS", ada, cam, vec![]);
4612 fx.edge("KNOWS", bob, cam, vec![]);
4613 fx.edge("LIKES", ada, acme, vec![]);
4614 fx
4615 }
4616
4617 fn undirected_graph() -> Fx {
4618 let mut fx = Fx::new();
4619 let a = fx.add("N", "a", vec![]);
4620 let b = fx.add("N", "b", vec![]);
4621 fx.edge("T", a, b, vec![("w", i(42))]);
4622 fx
4623 }
4624
4625 fn triangle() -> Fx {
4626 let mut fx = Fx::new();
4627 let a = fx.add("N", "a", vec![]);
4628 let b = fx.add("N", "b", vec![]);
4629 let c = fx.add("N", "c", vec![]);
4630 fx.edge("T", a, b, vec![("eid", i(1))]);
4631 fx.edge("T", b, c, vec![("eid", i(2))]);
4632 fx.edge("T", c, a, vec![("eid", i(3))]);
4633 fx
4634 }
4635
4636 fn single_edge() -> (Fx, u32, u32) {
4637 let mut fx = Fx::new();
4638 let a = fx.add("N", "a", vec![]);
4639 let b = fx.add("N", "b", vec![]);
4640 fx.edge("T", a, b, vec![]);
4641 (fx, a, b)
4642 }
4643
4644 fn dogfood_graph() -> Fx {
4646 let mut fx = Fx::new();
4647 let t1 = fx.add("Talent", "t1", vec![("id", s("t1"))]);
4648 let acme = fx.add("Company", "acme", vec![]);
4649 let beta = fx.add("Company", "beta", vec![]);
4650 let gamma = fx.add("Company", "gamma", vec![]);
4651 let delta = fx.add("Company", "delta", vec![]);
4652 let echo = fx.add("Company", "echo", vec![]);
4653 let foxtrot = fx.add("Company", "foxtrot", vec![]);
4654 let zeta = fx.add("Company", "zeta", vec![]);
4655 fx.edge("INDUSTRY_ALIGNMENT", acme, t1, vec![("score", f(0.9))]);
4656 fx.edge("SPECIALTY_MATCH", acme, t1, vec![("score", f(0.8))]);
4657 fx.edge("INDUSTRY_ALIGNMENT", beta, t1, vec![("score", f(0.6))]);
4658 fx.edge("SPECIALTY_MATCH", beta, t1, vec![("score", f(0.7))]);
4659 fx.edge("INDUSTRY_ALIGNMENT", gamma, t1, vec![("score", f(0.4))]);
4660 fx.edge("SPECIALTY_MATCH", gamma, t1, vec![("score", f(0.9))]);
4661 fx.edge("INDUSTRY_ALIGNMENT", delta, t1, vec![("score", f(0.8))]);
4662 fx.edge("SPECIALTY_MATCH", delta, t1, vec![("score", f(0.3))]);
4663 fx.edge("INDUSTRY_ALIGNMENT", echo, t1, vec![("score", f(0.5))]);
4664 fx.edge("SPECIALTY_MATCH", echo, t1, vec![("score", f(0.5))]);
4665 fx.edge("INDUSTRY_ALIGNMENT", foxtrot, t1, vec![("score", f(0.95))]);
4666 fx.edge("INDUSTRY_ALIGNMENT", zeta, t1, vec![("score", f(0.9))]);
4667 fx.edge("SPECIALTY_MATCH", zeta, t1, vec![("score", f(0.6))]);
4668 fx
4669 }
4670
4671 const DOGFOOD: &str = "\
4672MATCH (t:Talent {id: $tid}) \
4673MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
4674MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
4675WHERE i.score >= 0.5 AND s.score >= 0.5 \
4676RETURN c, i.score AS industry, s.score AS specialty \
4677ORDER BY industry DESC, specialty DESC \
4678LIMIT 10";
4679
4680 fn tid_params() -> BTreeMap<String, Value> {
4681 let mut p = BTreeMap::new();
4682 p.insert("tid".into(), s("t1"));
4683 p
4684 }
4685
4686 #[test]
4687 fn single_hop_match_label_and_etype_filters() {
4688 let fx = hop_graph();
4689 let v = fx.view();
4690 let rs = run(
4691 &v,
4692 "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b",
4693 &BTreeMap::new(),
4694 )
4695 .expect("single-hop");
4696 assert_eq!(rs.columns(), &["a".to_string(), "b".to_string()]);
4697 assert_eq!(
4698 rows_of(&rs),
4699 vec![
4700 vec![Some(s("ada")), Some(s("bob"))],
4701 vec![Some(s("ada")), Some(s("cam"))],
4702 vec![Some(s("bob")), Some(s("cam"))],
4703 ]
4704 );
4705 let likes = run(
4707 &v,
4708 "MATCH (a:Person)-[:LIKES]->(b:Company) RETURN a, b",
4709 &BTreeMap::new(),
4710 )
4711 .unwrap();
4712 assert_eq!(rows_of(&likes), vec![vec![Some(s("ada")), Some(s("acme"))]]);
4713 let no_combo = run(
4714 &v,
4715 "MATCH (a:Person)-[:KNOWS]->(b:Company) RETURN a, b",
4716 &BTreeMap::new(),
4717 )
4718 .unwrap();
4719 assert!(no_combo.is_empty());
4720 }
4721
4722 #[test]
4723 fn undirected_match_finds_both_orientations_and_binds_true_triple() {
4724 let fx = undirected_graph();
4725 let v = fx.view();
4726 let rs =
4729 run(&v, "MATCH (x)-[r:T]-(y) RETURN x, y, r.w", &BTreeMap::new()).expect("undirected");
4730 assert_eq!(
4731 rows_of(&rs),
4732 vec![
4733 vec![Some(s("a")), Some(s("b")), Some(i(42))],
4734 vec![Some(s("b")), Some(s("a")), Some(i(42))],
4735 ]
4736 );
4737
4738 let left = run(
4739 &v,
4740 "MATCH (y)<-[r:T]-(x) RETURN x, y, r.w",
4741 &BTreeMap::new(),
4742 )
4743 .unwrap();
4744 assert_eq!(
4745 rows_of(&left),
4746 vec![vec![Some(s("a")), Some(s("b")), Some(i(42))]]
4747 );
4748 }
4749
4750 #[test]
4751 fn relationship_uniqueness_triangle_and_two_hop_cycle() {
4752 let tri = triangle();
4753 let v = tri.view();
4754 let rs = run(
4755 &v,
4756 "MATCH (x)-[r1:T]->(y)-[r2:T]->(z) RETURN x, y, z, r1.eid, r2.eid",
4757 &BTreeMap::new(),
4758 )
4759 .expect("triangle 2-hop");
4760 assert_eq!(
4761 rows_of(&rs),
4762 vec![
4763 vec![
4764 Some(s("a")),
4765 Some(s("b")),
4766 Some(s("c")),
4767 Some(i(1)),
4768 Some(i(2))
4769 ],
4770 vec![
4771 Some(s("b")),
4772 Some(s("c")),
4773 Some(s("a")),
4774 Some(i(2)),
4775 Some(i(3))
4776 ],
4777 vec![
4778 Some(s("c")),
4779 Some(s("a")),
4780 Some(s("b")),
4781 Some(i(3)),
4782 Some(i(1))
4783 ],
4784 ]
4785 );
4786 for row in rows_of(&rs) {
4787 assert_ne!(row[3], row[4], "r1 must never bind the same edge as r2");
4788 }
4789
4790 let (mut one, a, b) = single_edge();
4791 let v = one.view();
4792 let cycle = run(
4793 &v,
4794 "MATCH (x)-[:T]->(y)-[:T]->(x) RETURN x",
4795 &BTreeMap::new(),
4796 )
4797 .expect("2-hop cycle");
4798 assert!(
4799 cycle.is_empty(),
4800 "single directed edge cannot close a 2-hop cycle"
4801 );
4802
4803 let undirected_cycle = run(&v, "MATCH (x)-[:T]-(y)-[:T]-(x) RETURN x", &BTreeMap::new())
4805 .expect("undirected uniqueness");
4806 assert!(
4807 undirected_cycle.is_empty(),
4808 "relationship uniqueness must reject walking the same triple back"
4809 );
4810
4811 one.edge("T", b, a, vec![]);
4812 let v = one.view();
4813 let with_recip = run(
4814 &v,
4815 "MATCH (x)-[:T]->(y)-[:T]->(x) RETURN x",
4816 &BTreeMap::new(),
4817 )
4818 .unwrap();
4819 assert_eq!(col(&with_recip, "x"), vec![Some(s("a")), Some(s("b"))]);
4820 }
4821
4822 #[test]
4823 fn multi_match_join_bound_and_bound_destination_expand() {
4824 let fx = dogfood_graph();
4825 let v = fx.view();
4826 let rs = run(
4829 &v,
4830 "MATCH (t:Talent {id: $tid}) \
4831 MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
4832 MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
4833 RETURN c",
4834 &tid_params(),
4835 )
4836 .expect("join + bound dest");
4837 assert_eq!(
4838 col(&rs, "c"),
4839 vec![
4840 Some(s("acme")),
4841 Some(s("beta")),
4842 Some(s("gamma")),
4843 Some(s("delta")),
4844 Some(s("echo")),
4845 Some(s("zeta")),
4846 ]
4847 );
4848
4849 let keep = run(&v, "MATCH (c:Company) MATCH (c) RETURN c", &BTreeMap::new()).unwrap();
4851 assert_eq!(keep.len(), 7);
4852 let drop = run(
4854 &v,
4855 "MATCH (c:Company) MATCH (c:Talent) RETURN c",
4856 &BTreeMap::new(),
4857 )
4858 .unwrap();
4859 assert!(drop.is_empty());
4860 }
4861
4862 #[test]
4863 fn scan_key_exec_does_not_use_label_scan() {
4864 let mut fx = Fx::new();
4865 fx.add("Person", "p1", vec![]);
4866 fx.add("Person", "p2", vec![]);
4867 fx.add("Person", "p3", vec![]);
4868 fx.add("Company", "c1", vec![]);
4869 let v = fx.view();
4870 let params = BTreeMap::new();
4871
4872 let fires_before = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
4873 let rs = run(&v, "MATCH (n:Person {id: 'p2'}) RETURN n", ¶ms).expect("scan key");
4874 let fires_after = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
4875 assert!(
4876 fires_after > fires_before,
4877 "SCAN_KEY_FIRES must increment; before={fires_before} after={fires_after}"
4878 );
4879 assert_eq!(rows_of(&rs), vec![vec![Some(s("p2"))]]);
4880
4881 let miss = run(&v, "MATCH (n:Person {id: 'nope'}) RETURN n", ¶ms).unwrap();
4882 assert!(
4883 miss.is_empty(),
4884 "missing key must be zero rows, not an error"
4885 );
4886
4887 let wrong = run(&v, "MATCH (n:Person {id: 'c1'}) RETURN n", ¶ms).unwrap();
4888 assert!(
4889 wrong.is_empty(),
4890 "wrong label must be zero rows, not an error"
4891 );
4892 }
4893
4894 #[test]
4895 fn rel_var_edge_prop_filter() {
4896 let mut fx = Fx::new();
4897 let a = fx.add("N", "a", vec![]);
4898 let b = fx.add("N", "b", vec![]);
4899 let c = fx.add("N", "c", vec![]);
4900 fx.edge("T", a, b, vec![("w", f(0.7))]);
4901 fx.edge("T", a, c, vec![("w", f(0.3))]);
4902 let v = fx.view();
4903 let rs = run(
4904 &v,
4905 "MATCH (x)-[r:T]->(y) WHERE r.w >= 0.5 RETURN y, r.w",
4906 &BTreeMap::new(),
4907 )
4908 .expect("edge-prop filter");
4909 assert_eq!(rows_of(&rs), vec![vec![Some(s("b")), Some(f(0.7))]]);
4910 let fail = run(
4911 &v,
4912 "MATCH (x)-[r:T]->(y) WHERE r.w >= 0.8 RETURN y",
4913 &BTreeMap::new(),
4914 )
4915 .unwrap();
4916 assert!(fail.is_empty());
4917 }
4918
4919 #[test]
4920 fn params_present_resolve_missing_is_err_before_rows() {
4921 let fx = dogfood_graph();
4922 let v = fx.view();
4923 let hit =
4924 run(&v, "MATCH (t:Talent {id: $tid}) RETURN t", &tid_params()).expect("present param");
4925 assert_eq!(col(&hit, "t"), vec![Some(s("t1"))]);
4926
4927 let err = run(
4929 &v,
4930 "MATCH (t:NoSuchLabel {id: $tid}) RETURN t",
4931 &BTreeMap::new(),
4932 )
4933 .expect_err("missing param must be Err, not Ok(empty)");
4934 assert!(
4935 err.contains("tid")
4936 && (err.contains("param") || err.contains("Param") || err.contains("missing")),
4937 "missing-param error must name the parameter, got: {err}"
4938 );
4939
4940 let err = run(
4941 &v,
4942 "MATCH (t:Talent) WHERE t.id = $tid RETURN t",
4943 &BTreeMap::new(),
4944 )
4945 .expect_err("missing WHERE param");
4946 assert!(err.contains("tid"), "got: {err}");
4947 }
4948
4949 #[test]
4950 fn order_by_none_last_then_skip_limit() {
4951 let mut fx = Fx::new();
4952 fx.add("Person", "ada", vec![("age", i(30))]);
4953 fx.add("Person", "bob", vec![]); fx.add("Person", "cam", vec![("age", i(10))]);
4955 fx.add("Person", "dan", vec![("age", i(20))]);
4956 let v = fx.view();
4957
4958 let asc = run(
4959 &v,
4960 "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age",
4961 &BTreeMap::new(),
4962 )
4963 .unwrap();
4964 assert_eq!(
4965 rows_of(&asc),
4966 vec![
4967 vec![Some(s("cam")), Some(i(10))],
4968 vec![Some(s("dan")), Some(i(20))],
4969 vec![Some(s("ada")), Some(i(30))],
4970 vec![Some(s("bob")), None],
4971 ]
4972 );
4973
4974 let desc = run(
4975 &v,
4976 "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age DESC",
4977 &BTreeMap::new(),
4978 )
4979 .unwrap();
4980 assert_eq!(
4981 rows_of(&desc),
4982 vec![
4983 vec![Some(s("ada")), Some(i(30))],
4984 vec![Some(s("dan")), Some(i(20))],
4985 vec![Some(s("cam")), Some(i(10))],
4986 vec![Some(s("bob")), None],
4987 ]
4988 );
4989
4990 let skip_lim = run(
4991 &v,
4992 "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age SKIP 1 LIMIT 2",
4993 &BTreeMap::new(),
4994 )
4995 .unwrap();
4996 assert_eq!(
4997 rows_of(&skip_lim),
4998 vec![
4999 vec![Some(s("dan")), Some(i(20))],
5000 vec![Some(s("ada")), Some(i(30))],
5001 ]
5002 );
5003
5004 let desc_sl = run(
5005 &v,
5006 "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age DESC SKIP 1 LIMIT 2",
5007 &BTreeMap::new(),
5008 )
5009 .unwrap();
5010 assert_eq!(
5011 rows_of(&desc_sl),
5012 vec![
5013 vec![Some(s("dan")), Some(i(20))],
5014 vec![Some(s("cam")), Some(i(10))],
5015 ]
5016 );
5017 }
5018
5019 #[test]
5020 fn unknown_label_and_etype_are_ok_empty() {
5021 let fx = hop_graph();
5022 let v = fx.view();
5023 let lab = run(&v, "MATCH (x:Nope) RETURN x", &BTreeMap::new()).expect("unknown label");
5024 assert!(lab.is_empty());
5025 let et = run(
5026 &v,
5027 "MATCH (a)-[:NO_SUCH_ETYPE]->(b) RETURN a",
5028 &BTreeMap::new(),
5029 )
5030 .expect("unknown etype");
5031 assert!(et.is_empty());
5032 }
5033
5034 #[test]
5035 fn execute_is_deterministic() {
5036 let fx = dogfood_graph();
5037 let v = fx.view();
5038 let p = tid_params();
5039 let a = run(&v, DOGFOOD, &p).expect("first");
5040 let b = run(&v, DOGFOOD, &p).expect("second");
5041 assert_eq!(a, b);
5042 let hop = hop_graph();
5043 let hv = hop.view();
5044 let q = "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b";
5045 assert_eq!(run(&hv, q, &BTreeMap::new()), run(&hv, q, &BTreeMap::new()));
5046 }
5047
5048 #[test]
5049 fn dogfood_pipeline_exact_rows() {
5050 let fx = dogfood_graph();
5051 let v = fx.view();
5052 let rs = run(&v, DOGFOOD, &tid_params()).expect("dogfood");
5053 assert_eq!(
5054 rs.columns(),
5055 &[
5056 "c".to_string(),
5057 "industry".to_string(),
5058 "specialty".to_string()
5059 ]
5060 );
5061 assert_eq!(
5063 rows_of(&rs),
5064 vec![
5065 vec![Some(s("acme")), Some(f(0.9)), Some(f(0.8))],
5066 vec![Some(s("zeta")), Some(f(0.9)), Some(f(0.6))],
5067 vec![Some(s("beta")), Some(f(0.6)), Some(f(0.7))],
5068 vec![Some(s("echo")), Some(f(0.5)), Some(f(0.5))],
5069 ]
5070 );
5071 }
5072
5073 #[test]
5074 fn unknown_var_in_op_is_err_not_panic() {
5075 let fx = hop_graph();
5076 let v = fx.view();
5077 let plan = vec![
5078 PlanOp::ScanLabel {
5079 var: "a".into(),
5080 label: None,
5081 },
5082 PlanOp::Expand {
5083 from: "zzz".into(),
5084 rel_var: Some("r".into()),
5085 etypes: vec![],
5086 dir: RelDir::Right,
5087 to: "b".into(),
5088 to_label: None,
5089 to_props: vec![],
5090 },
5091 PlanOp::Project {
5092 items: vec![RetItem {
5093 value: RetVal::Var("a".into()),
5094 alias: None,
5095 }],
5096 },
5097 ];
5098 let params = BTreeMap::new();
5099 let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5100 execute(&v, &plan, &Params(¶ms))
5101 }));
5102 assert!(caught.is_ok(), "execute panicked on unknown var");
5103 let err = caught.unwrap().expect_err("unknown var must be Err");
5104 assert!(
5105 err.contains("zzz") && err.to_ascii_lowercase().contains("unbound"),
5106 "got: {err}"
5107 );
5108
5109 let join = vec![
5110 PlanOp::JoinBound {
5111 var: "ghost".into(),
5112 label: None,
5113 props: vec![],
5114 },
5115 PlanOp::Project {
5116 items: vec![RetItem {
5117 value: RetVal::Var("ghost".into()),
5118 alias: None,
5119 }],
5120 },
5121 ];
5122 let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5123 execute(&v, &join, &Params(¶ms))
5124 }));
5125 assert!(caught.is_ok(), "execute panicked on JoinBound unknown var");
5126 assert!(caught.unwrap().is_err());
5127 }
5128
5129 #[test]
5130 fn dest_props_on_bound_expand_are_applied() {
5131 let fx = dogfood_graph();
5132 let v = fx.view();
5133 let rs = run(
5134 &v,
5135 "MATCH (t:Talent {id: $tid}) \
5136 MATCH (c:Company)-[r:INDUSTRY_ALIGNMENT]->(t:Talent {id: $tid}) \
5137 RETURN c",
5138 &tid_params(),
5139 )
5140 .unwrap();
5141 assert_eq!(
5143 col(&rs, "c"),
5144 vec![
5145 Some(s("acme")),
5146 Some(s("beta")),
5147 Some(s("gamma")),
5148 Some(s("delta")),
5149 Some(s("echo")),
5150 Some(s("foxtrot")),
5151 Some(s("zeta")),
5152 ]
5153 );
5154 let miss = run(
5155 &v,
5156 "MATCH (t:Talent {id: $tid}) \
5157 MATCH (c:Company)-[r:INDUSTRY_ALIGNMENT]->(t {id: 'nope'}) \
5158 RETURN c",
5159 &tid_params(),
5160 )
5161 .unwrap();
5162 assert!(miss.is_empty());
5163 }
5164
5165 #[test]
5166 fn missing_node_prop_projects_none_and_pattern_misses() {
5167 let mut fx = Fx::new();
5168 fx.add("Person", "ada", vec![("age", i(30))]);
5169 fx.add("Person", "bob", vec![]);
5170 let v = fx.view();
5171 let rs = run(&v, "MATCH (p:Person) RETURN p.age", &BTreeMap::new()).unwrap();
5172 assert_eq!(col(&rs, "p.age"), vec![Some(i(30)), None]);
5173 let pat = run(&v, "MATCH (p:Person {age: 30}) RETURN p", &BTreeMap::new()).unwrap();
5174 assert_eq!(col(&pat, "p"), vec![Some(s("ada"))]);
5175 }
5176
5177 #[test]
5178 fn unlabeled_match_does_not_project_sentinel_ghost_key() {
5179 let mut fx = Fx::new();
5180 fx.add("Person", "ada", vec![]);
5181 fx.ids.get_or_insert("ghost");
5184 fx.labels.resize(fx.ids.len(), u32::MAX);
5185 fx.add("Person", "bob", vec![]);
5186 let v = fx.view();
5187 let rs = run(&v, "MATCH (n) RETURN n", &BTreeMap::new()).expect("unlabeled scan");
5188 assert_eq!(col(&rs, "n"), vec![Some(s("ada")), Some(s("bob"))]);
5189 assert!(
5190 !rows_of(&rs)
5191 .iter()
5192 .any(|row| row.iter().any(|c| *c == Some(s("ghost")))),
5193 "sentinel slot must not project a ghost key"
5194 );
5195 }
5196
5197 #[test]
5198 fn execute_never_panics_on_hostile_plans() {
5199 let fx = hop_graph();
5200 let v = fx.view();
5201 let params = BTreeMap::new();
5202 let hostile = vec![
5203 vec![],
5204 vec![PlanOp::Project { items: vec![] }],
5205 vec![PlanOp::OrderBy {
5206 items: vec![OrderItem {
5207 target: OrderTarget::Alias("nope".into()),
5208 descending: false,
5209 }],
5210 }],
5211 vec![PlanOp::Filter {
5212 expr: crate::cypher::ast::Expr::Cmp {
5213 lhs: Operand::Prop {
5214 var: "missing".into(),
5215 field: "x".into(),
5216 },
5217 op: crate::filter::CmpOp::Eq,
5218 rhs: Operand::Lit(i(1)),
5219 },
5220 }],
5221 vec![
5222 PlanOp::ScanLabel {
5223 var: "a".into(),
5224 label: None,
5225 },
5226 PlanOp::LookupProps {
5227 var: "zzz".into(),
5228 props: vec![("k".into(), Operand::Lit(i(1)))],
5229 },
5230 ],
5231 vec![
5232 PlanOp::Skip(LimitSkip::Exact(99)),
5233 PlanOp::Limit(LimitSkip::Exact(0)),
5234 ],
5235 ];
5236 for plan in hostile {
5237 let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5238 execute(&v, &plan, &Params(¶ms))
5239 }));
5240 assert!(caught.is_ok(), "execute panicked on hostile plan: {plan:?}");
5241 }
5242 }
5243
5244 #[test]
5245 fn unlabeled_and_labeled_scans_skip_tombstoned_ids() {
5246 let mut fx = Fx::new();
5247 let ada = fx.add("Person", "ada", vec![]);
5248 let bob = fx.add("Person", "bob", vec![]);
5249 fx.edge("KNOWS", ada, bob, vec![]);
5250 fx.ids.delete("ada");
5252 fx.labels[ada as usize] = u32::MAX;
5253 let knows = fx.syms.get("KNOWS").unwrap();
5254 fx.topo.remove_edge(knows, ada, bob);
5255 let v = fx.view();
5256
5257 let labeled = run(&v, "MATCH (p:Person) RETURN p", &BTreeMap::new()).unwrap();
5258 assert_eq!(col(&labeled, "p"), vec![Some(s("bob"))]);
5259 let unlabeled = run(&v, "MATCH (n) RETURN n", &BTreeMap::new()).unwrap();
5260 assert_eq!(col(&unlabeled, "n"), vec![Some(s("bob"))]);
5261 let hop = run(&v, "MATCH (x)-[:KNOWS]->(y) RETURN x, y", &BTreeMap::new()).unwrap();
5262 assert!(
5263 hop.is_empty(),
5264 "expand cannot yield edges to a deleted node once topology is swept"
5265 );
5266 }
5267
5268 proptest! {
5273 #[test]
5286 fn prop_bounded_equals_unbounded_slice(
5287 n_nodes in 2u32..10u32,
5288 edge_pairs in proptest::collection::vec(
5289 (any::<u32>(), any::<u32>()), 0..20usize
5290 ),
5291 recip_pairs in proptest::collection::vec(
5293 (any::<u32>(), any::<u32>()), 0..8usize
5294 ),
5295 n_hops in 1u32..4u32, use_filter in any::<bool>(),
5297 threshold in 0i64..8i64, limit in 1u64..10u64,
5299 skip in 0u64..4u64,
5300 ) {
5301 let mut fx = Fx::new();
5302 let mut node_ids = Vec::new();
5303 for idx in 0..n_nodes {
5304 let id = fx.add("N", &format!("n{idx}"), vec![("v", i(idx as i64 % 8))]);
5306 node_ids.push(id);
5307 }
5308 let n = node_ids.len();
5309
5310 for (si, di) in &edge_pairs {
5312 let si = (*si as usize) % n;
5313 let di = (*di as usize) % n;
5314 if si != di {
5315 fx.edge("T", node_ids[si], node_ids[di], vec![]);
5316 }
5317 }
5318 for (si, di) in &recip_pairs {
5321 let si = (*si as usize) % n;
5322 let di = (*di as usize) % n;
5323 if si != di {
5324 fx.edge("T", node_ids[si], node_ids[di], vec![]);
5325 fx.edge("T", node_ids[di], node_ids[si], vec![]);
5326 }
5327 }
5328
5329 let v = fx.view();
5330 let params = BTreeMap::new();
5331
5332 let var_names = ["a", "b", "c", "d"];
5335 let hop_count = n_hops as usize;
5336 let mut pattern = format!("({}:N)", var_names[0]);
5337 for h in 0..hop_count {
5338 pattern.push_str(&format!("-[:T]->({}", var_names[h + 1]));
5339 if h + 1 == hop_count {
5341 pattern.push_str(":N)");
5342 } else {
5343 pattern.push(')');
5344 }
5345 }
5346 let last_var = var_names[hop_count];
5347 let where_clause = if use_filter {
5348 format!(" WHERE {last_var}.v > {threshold}")
5349 } else {
5350 String::new()
5351 };
5352 let ret_vars: Vec<&str> = var_names[..=hop_count].to_vec();
5353 let ret_clause = ret_vars.join(", ");
5354
5355 let full_q = format!("MATCH {pattern}{where_clause} RETURN {ret_clause}");
5356 let bounded_q = format!(
5357 "MATCH {pattern}{where_clause} RETURN {ret_clause} SKIP {skip} LIMIT {limit}"
5358 );
5359
5360 let full_plan = compile(&full_q);
5361 let unbounded = super::with_max_intermediate_rows(100_000, || {
5364 super::execute_unbounded(&v, &full_plan, &Params(¶ms))
5365 });
5366 let unbounded = match unbounded {
5369 Ok(rs) => rs,
5370 Err(_) => return Ok(()),
5371 };
5372 let total = unbounded.len();
5373 let full_rows = rows_of(&unbounded);
5374
5375 let bounded = super::with_max_intermediate_rows(100_000, || {
5376 run(&v, &bounded_q, ¶ms)
5377 }).expect("bounded must not error");
5378
5379 let s = (skip as usize).min(total);
5380 let e = (skip as usize + limit as usize).min(total);
5381 prop_assert_eq!(
5382 rows_of(&bounded),
5383 full_rows[s..e].to_vec(),
5384 "hops={} filter={} threshold={} SKIP {} LIMIT {}: \
5385 bounded != unbounded[{}..{}]",
5386 hop_count, use_filter, threshold, skip, limit, s, e
5387 );
5388 }
5389 }
5390
5391 proptest! {
5392 #[test]
5410 fn prop_scan_filter_fused_equals_unbounded_slice(
5411 n_nodes in 0u32..20u32,
5412 prop_mask in any::<u32>(),
5414 float_mask in any::<u32>(),
5416 threshold in -1i64..8i64,
5419 op_idx in 0u32..6u32,
5421 skip in 0u64..5u64,
5422 limit in 1u64..8u64,
5423 ) {
5424 let op_str = match op_idx {
5425 0 => "=",
5426 1 => "<>",
5427 2 => "<",
5428 3 => "<=",
5429 4 => ">",
5430 _ => ">=",
5431 };
5432
5433 let mut fx = Fx::new();
5434 for idx in 0..n_nodes {
5435 let has_prop = (prop_mask >> (idx % 32)) & 1 == 1;
5436 let use_float = (float_mask >> (idx % 32)) & 1 == 1;
5437 let props: Vec<(&str, Value)> = if has_prop {
5439 if use_float {
5440 vec![("v", f(idx as f64 % 7.0))]
5441 } else {
5442 vec![("v", i(idx as i64 % 7))]
5443 }
5444 } else {
5445 vec![]
5446 };
5447 fx.add("N", &format!("n{idx}"), props);
5448 }
5449
5450 let v = fx.view();
5451 let params = BTreeMap::new();
5452
5453 let full_q = format!("MATCH (n:N) WHERE n.v {op_str} {threshold} RETURN n");
5455 let bounded_q = format!(
5456 "MATCH (n:N) WHERE n.v {op_str} {threshold} RETURN n SKIP {skip} LIMIT {limit}"
5457 );
5458
5459 let full_plan = compile(&full_q);
5460 let unbounded = super::execute_unbounded(&v, &full_plan, &Params(¶ms));
5461 let unbounded = match unbounded {
5462 Ok(rs) => rs,
5463 Err(_) => return Ok(()),
5464 };
5465 let total = unbounded.len();
5466 let full_rows = rows_of(&unbounded);
5467
5468 let fires_before =
5470 super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
5471 let bounded = run(&v, &bounded_q, ¶ms).expect("fused bounded must not error");
5472 let fires_after =
5473 super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
5474
5475 if n_nodes > 0 && op_idx != 0 {
5483 prop_assert!(
5484 fires_after > fires_before,
5485 "fused arm did NOT fire for op={} threshold={} n_nodes={}: \
5486 counter before={} after={}",
5487 op_str,
5488 threshold,
5489 n_nodes,
5490 fires_before,
5491 fires_after
5492 );
5493 }
5494
5495 let s = (skip as usize).min(total);
5496 let e = (skip as usize + limit as usize).min(total);
5497 prop_assert_eq!(
5498 rows_of(&bounded),
5499 full_rows[s..e].to_vec(),
5500 "fused path: op={} threshold={} n_nodes={} SKIP {} LIMIT {}: \
5501 bounded != unbounded[{}..{}]",
5502 op_str, threshold, n_nodes, skip, limit, s, e
5503 );
5504
5505 let compound_q = format!(
5509 "MATCH (n:N) WHERE n.v {} {} AND n.v >= -999 RETURN n SKIP {} LIMIT {}",
5510 op_str, threshold, skip, limit
5511 );
5512 let fires_before_compound =
5513 super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
5514 let compound_bounded =
5515 run(&v, &compound_q, ¶ms).expect("compound-AND bounded must not error");
5516 let fires_after_compound =
5517 super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
5518
5519 prop_assert_eq!(
5521 fires_after_compound,
5522 fires_before_compound,
5523 "fused arm fired for compound-AND shape (should use generic path): \
5524 op={} threshold={} n_nodes={}",
5525 op_str,
5526 threshold,
5527 n_nodes
5528 );
5529 prop_assert_eq!(
5530 rows_of(&compound_bounded),
5531 full_rows[s..e].to_vec(),
5532 "compound-AND fallback: op={} threshold={} n_nodes={} SKIP {} LIMIT {}: \
5533 result differs from fused",
5534 op_str, threshold, n_nodes, skip, limit
5535 );
5536 }
5537 }
5538
5539 #[test]
5550 fn dense_hop1_with_filter_survives_pull() {
5551 const LEAVES: usize = 120; const CAP: usize = 100;
5553
5554 let mut fx = Fx::new();
5555 let src = fx.add("Src", "src", vec![]);
5556 for idx in 0..LEAVES {
5557 let leaf = fx.add("Leaf", &format!("l{idx}"), vec![("v", i(idx as i64))]);
5558 fx.edge("T", src, leaf, vec![]);
5559 }
5560 let v = fx.view();
5561 let params = BTreeMap::new();
5562
5563 let staged_err = super::with_max_intermediate_rows(CAP, || {
5565 super::execute_unbounded(
5566 &v,
5567 &compile("MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v >= 110 RETURN l, l.v"),
5568 &Params(¶ms),
5569 )
5570 });
5571 assert!(
5572 staged_err.is_err(),
5573 "staged path must error on 120 leaves with cap={CAP}"
5574 );
5575 assert!(
5576 staged_err
5577 .unwrap_err()
5578 .contains("intermediate result exceeds"),
5579 "wrong error message"
5580 );
5581
5582 let ok = super::with_max_intermediate_rows(CAP, || {
5586 run(
5587 &v,
5588 "MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v >= 110 RETURN l, l.v LIMIT 5",
5589 ¶ms,
5590 )
5591 });
5592 let rs = ok.expect("pull-based must survive despite dense hop-1 exceeding cap");
5593 assert_eq!(rs.len(), 5, "LIMIT 5 must return exactly 5 rows");
5594
5595 let vs: Vec<i64> = (0..rs.len())
5597 .filter_map(|i| match rs.get(i, "l.v") {
5598 Some(Value::Int(n)) => Some(*n),
5599 _ => None,
5600 })
5601 .collect();
5602 assert_eq!(vs.len(), 5, "all projected rows must have v");
5603 for v_val in &vs {
5604 assert!(*v_val >= 110, "filter must hold: v={v_val} is not >= 110");
5605 }
5606
5607 let (pull_result, pull_produced) = super::with_expand_counter(|| {
5611 super::with_max_intermediate_rows(1_000_000, || {
5612 run(
5613 &v,
5614 "MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v < 10 RETURN l LIMIT 5",
5615 ¶ms,
5616 )
5617 })
5618 });
5619 pull_result.expect("pull must succeed without cap");
5620 assert!(
5622 pull_produced <= 5,
5623 "pull expand count {pull_produced} should be ≤ 5 (stops after 5 passing leaves)"
5624 );
5625
5626 let (staged_result, staged_produced) = super::with_expand_counter(|| {
5627 super::with_max_intermediate_rows(1_000_000, || {
5628 super::execute_unbounded(
5629 &v,
5630 &compile("MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v < 10 RETURN l"),
5631 &Params(¶ms),
5632 )
5633 })
5634 });
5635 staged_result.expect("staged must succeed with 1M cap");
5636 assert_eq!(
5637 staged_produced, LEAVES,
5638 "staged must expand all {LEAVES} leaves"
5639 );
5640
5641 assert!(
5642 staged_produced >= pull_produced * 10,
5643 "staged ({staged_produced}) must be ≥ 10× pull ({pull_produced})"
5644 );
5645 }
5646
5647 #[test]
5665 fn harness_shape_two_hop_dense_survives_pull() {
5666 const N_TALENT: usize = 70;
5667 const N_COMPANY: usize = 20;
5668 const N_INDUSTRY: usize = 3;
5669 const CAP: usize = 100;
5670 const LIMIT: usize = 10;
5671
5672 let mut fx = Fx::new();
5673
5674 let mut talent_ids: Vec<u32> = Vec::new();
5676 let mut talent_industry: Vec<usize> = Vec::new();
5677 for i in 0..N_TALENT {
5678 let ind = i % N_INDUSTRY;
5679 let id = fx.add(
5680 "Talent",
5681 &format!("t{i}"),
5682 vec![("industry", s(&ind.to_string()))],
5683 );
5684 talent_ids.push(id);
5685 talent_industry.push(ind);
5686 }
5687
5688 let mut company_ids: Vec<u32> = Vec::new();
5690 let mut company_industry: Vec<usize> = Vec::new();
5691 for i in 0..N_COMPANY {
5692 let ind = i % N_INDUSTRY;
5693 let id = fx.add(
5694 "Company",
5695 &format!("c{i}"),
5696 vec![("industry", s(&ind.to_string()))],
5697 );
5698 company_ids.push(id);
5699 company_industry.push(ind);
5700 }
5701
5702 for (ti, &tid) in talent_ids.iter().enumerate() {
5704 for (ci, &cid) in company_ids.iter().enumerate() {
5705 if talent_industry[ti] == company_industry[ci] {
5706 fx.edge("INDUSTRY_ALIGNMENT", tid, cid, vec![]);
5707 }
5708 }
5709 }
5710
5711 let v = fx.view();
5712 let params = BTreeMap::new();
5713 let query = format!(
5714 "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company)\
5715 <-[:INDUSTRY_ALIGNMENT]-(t2:Talent) RETURN t, c, t2 LIMIT {LIMIT}"
5716 );
5717
5718 const UNBOUNDED_Q: &str = "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company)\
5720 <-[:INDUSTRY_ALIGNMENT]-(t2:Talent) RETURN t, c, t2";
5721 let staged_err = super::with_max_intermediate_rows(CAP, || {
5722 super::execute_unbounded(&v, &compile(UNBOUNDED_Q), &Params(¶ms))
5723 });
5724 assert!(
5725 staged_err.is_err(),
5726 "staged must error with cap={CAP} on harness-shape graph"
5727 );
5728 assert!(
5729 staged_err
5730 .unwrap_err()
5731 .contains("intermediate result exceeds"),
5732 "wrong error"
5733 );
5734
5735 let ok = super::with_max_intermediate_rows(CAP, || run(&v, &query, ¶ms));
5737 let rs = ok.expect("pull-based must complete on harness-shape with LIMIT 10");
5738 assert_eq!(rs.len(), LIMIT, "must return exactly {LIMIT} rows");
5739
5740 for i in 0..rs.len() {
5745 let row = rs.row(i);
5746 assert_eq!(row.len(), 3, "each row must have 3 columns (t, c, t2)");
5747 assert!(row.iter().all(|c| c.is_some()), "all cells must be Some");
5748 }
5749 }
5750
5751 #[test]
5752 fn intermediate_row_cap_errors_on_scan_and_expand() {
5753 let cap_msg = |n: usize| {
5754 format!(
5755 "intermediate result exceeds {n} rows; add a LIMIT or constrain patterns with shared variables"
5756 )
5757 };
5758
5759 let mut scan_fx = Fx::new();
5760 scan_fx.add("N", "a", vec![]);
5761 scan_fx.add("N", "b", vec![]);
5762 scan_fx.add("N", "c", vec![]);
5763 let sv = scan_fx.view();
5764 let scan_err = super::with_max_intermediate_rows(2, || {
5765 run(&sv, "MATCH (n:N) RETURN n", &BTreeMap::new())
5766 })
5767 .expect_err("3-row scan must exceed cap 2");
5768 assert_eq!(scan_err, cap_msg(2));
5769
5770 let mut exp_fx = Fx::new();
5771 let src = exp_fx.add("Src", "s", vec![]);
5772 let d1 = exp_fx.add("Dst", "d1", vec![]);
5773 let d2 = exp_fx.add("Dst", "d2", vec![]);
5774 exp_fx.edge("T", src, d1, vec![]);
5775 exp_fx.edge("T", src, d2, vec![]);
5776 let ev = exp_fx.view();
5777 let exp_err = super::with_max_intermediate_rows(1, || {
5779 run(&ev, "MATCH (x:Src)-[:T]->(y) RETURN x, y", &BTreeMap::new())
5780 })
5781 .expect_err("2-row expand must exceed cap 1");
5782 assert_eq!(exp_err, cap_msg(1));
5783 }
5784
5785 #[test]
5795 fn bounded_matches_unbounded_slice_various_limits() {
5796 let fx = hop_graph();
5798 let v = fx.view();
5799 let params = BTreeMap::new();
5800
5801 let full_plan = compile("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b");
5803 let full_rs = super::execute_unbounded(&v, &full_plan, &Params(¶ms)).unwrap();
5804 let full_rows = rows_of(&full_rs);
5805 assert_eq!(full_rows.len(), 3, "hop_graph has exactly 3 KNOWS paths");
5806
5807 for limit in [1u64, 2, 3, 10] {
5808 let q = format!("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b LIMIT {limit}");
5809 let rs = run(&v, &q, ¶ms).unwrap();
5810 let expected_len = (limit as usize).min(full_rows.len());
5811 assert_eq!(
5812 rs.len(),
5813 expected_len,
5814 "LIMIT {limit}: expected {expected_len} rows, got {}",
5815 rs.len()
5816 );
5817 assert_eq!(
5818 rows_of(&rs),
5819 full_rows[..expected_len],
5820 "LIMIT {limit}: rows differ from reference slice"
5821 );
5822 }
5823
5824 let skip_rs = run(
5826 &v,
5827 "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b SKIP 1 LIMIT 2",
5828 ¶ms,
5829 )
5830 .unwrap();
5831 assert_eq!(rows_of(&skip_rs), full_rows[1..3]);
5832
5833 let mut fx2 = Fx::new();
5835 fx2.add("N", "a", vec![("v", i(1))]);
5836 fx2.add("N", "b", vec![("v", i(2))]);
5837 fx2.add("N", "c", vec![("v", i(3))]);
5838 let v2 = fx2.view();
5839 let filter_full = super::execute_unbounded(
5841 &v2,
5842 &compile("MATCH (n:N) WHERE n.v > 1 RETURN n"),
5843 &Params(¶ms),
5844 )
5845 .unwrap();
5846 assert_eq!(filter_full.len(), 2);
5847
5848 let filter_lim = run(&v2, "MATCH (n:N) WHERE n.v > 1 RETURN n LIMIT 1", ¶ms).unwrap();
5849 assert_eq!(filter_lim.len(), 1, "LIMIT 1 on filter query");
5850 assert_eq!(rows_of(&filter_lim), rows_of(&filter_full)[..1]);
5851
5852 let tri = triangle();
5854 let tv = tri.view();
5855 let tri_full = super::execute_unbounded(
5857 &tv,
5858 &compile("MATCH (x)-[r1:T]->(y)-[r2:T]->(z) RETURN x, y, z"),
5859 &Params(¶ms),
5860 )
5861 .unwrap();
5862 assert_eq!(tri_full.len(), 3, "triangle has 3 unique two-hop paths");
5863 for limit in [1u64, 2, 3, 5] {
5864 let q = format!("MATCH (x)-[r1:T]->(y)-[r2:T]->(z) RETURN x, y, z LIMIT {limit}");
5865 let rs = run(&tv, &q, ¶ms).unwrap();
5866 let expected_len = (limit as usize).min(3);
5867 assert_eq!(
5868 rs.len(),
5869 expected_len,
5870 "triangle LIMIT {limit}: got {} rows",
5871 rs.len()
5872 );
5873 assert_eq!(
5874 rows_of(&rs),
5875 rows_of(&tri_full)[..expected_len],
5876 "triangle LIMIT {limit}: rows differ"
5877 );
5878 }
5879 }
5880
5881 #[test]
5885 fn expand_terminates_early_with_row_bound() {
5886 const LEAVES: usize = 500;
5887 let mut fx = Fx::new();
5888 let hub = fx.add("Hub", "hub", vec![]);
5889 for i in 0..LEAVES {
5890 let leaf = fx.add("Leaf", &format!("leaf-{i}"), vec![]);
5891 fx.edge("T", hub, leaf, vec![]);
5892 }
5893 let v = fx.view();
5894 let params = BTreeMap::new();
5895 let full_plan = compile("MATCH (h:Hub)-[:T]->(x:Leaf) RETURN x");
5896
5897 let (bounded_result, bounded_produced) = super::with_expand_counter(|| {
5899 run(&v, "MATCH (h:Hub)-[:T]->(x:Leaf) RETURN x LIMIT 5", ¶ms)
5900 });
5901 let bounded_rs = bounded_result.unwrap();
5902 assert_eq!(bounded_rs.len(), 5, "LIMIT 5 must return exactly 5 rows");
5903 assert!(
5904 bounded_produced <= 5,
5905 "bounded: exec_expand emitted {bounded_produced} rows, expected ≤ 5"
5906 );
5907
5908 let (unbounded_result, unbounded_produced) = super::with_expand_counter(|| {
5910 super::execute_unbounded(&v, &full_plan, &Params(¶ms))
5911 });
5912 let unbounded_rs = unbounded_result.unwrap();
5913 assert_eq!(
5914 unbounded_rs.len(),
5915 LEAVES,
5916 "unbounded must return all {LEAVES} rows"
5917 );
5918 assert_eq!(
5919 unbounded_produced, LEAVES,
5920 "unbounded: exec_expand must emit all {LEAVES} rows"
5921 );
5922
5923 assert!(
5925 unbounded_produced >= bounded_produced * 100,
5926 "unbounded ({unbounded_produced}) must be ≥ 100× bounded ({bounded_produced})"
5927 );
5928 }
5929
5930 #[test]
5936 fn bounded_query_survives_low_intermediate_row_cap() {
5937 let mut fx = Fx::new();
5939 let src = fx.add("Src", "s", vec![]);
5940 for i in 0..20usize {
5941 let dst = fx.add("Dst", &format!("d{i}"), vec![]);
5942 fx.edge("T", src, dst, vec![]);
5943 }
5944 let v = fx.view();
5945 let params = BTreeMap::new();
5946 let full_plan = compile("MATCH (s:Src)-[:T]->(d:Dst) RETURN d");
5947
5948 let cap_err = super::with_max_intermediate_rows(10, || {
5950 super::execute_unbounded(&v, &full_plan, &Params(¶ms))
5951 });
5952 assert!(
5953 cap_err.is_err(),
5954 "unbounded must hit the intermediate-row cap"
5955 );
5956 assert!(
5957 cap_err.unwrap_err().contains("intermediate result exceeds"),
5958 "cap error message must be the budget message"
5959 );
5960
5961 let ok = super::with_max_intermediate_rows(10, || {
5963 run(&v, "MATCH (s:Src)-[:T]->(d:Dst) RETURN d LIMIT 5", ¶ms)
5964 });
5965 assert_eq!(
5966 ok.unwrap().len(),
5967 5,
5968 "bounded (LIMIT 5) must complete with 5 rows, not a cap error"
5969 );
5970
5971 let at_cap = super::with_max_intermediate_rows(10, || {
5973 run(&v, "MATCH (s:Src)-[:T]->(d:Dst) RETURN d LIMIT 10", ¶ms)
5974 });
5975 assert_eq!(
5976 at_cap.unwrap().len(),
5977 10,
5978 "bounded at LIMIT==cap must complete with 10 rows"
5979 );
5980 }
5981
5982 #[test]
5987 fn count_star_returns_total_node_count() {
5988 let mut fx = Fx::new();
5989 fx.add("Person", "ada", vec![]);
5990 fx.add("Person", "bob", vec![]);
5991 fx.add("Person", "cam", vec![]);
5992 let v = fx.view();
5993 let params = BTreeMap::new();
5994
5995 let rs = run(&v, "MATCH (n:Person) RETURN COUNT(*)", ¶ms).expect("COUNT(*)");
5996 assert_eq!(rs.columns(), &["COUNT(*)".to_string()]);
5997 assert_eq!(rs.len(), 1);
5998 assert_eq!(rs.row(0), &[Some(i(3))]);
5999
6000 let rs_empty = run(&v, "MATCH (n:Ghost) RETURN COUNT(*)", ¶ms).expect("COUNT(*) empty");
6002 assert_eq!(rs_empty.row(0), &[Some(i(0))]);
6003 }
6004
6005 #[test]
6006 fn count_star_alias_sets_column_name() {
6007 let mut fx = Fx::new();
6008 fx.add("N", "a", vec![]);
6009 let v = fx.view();
6010 let params = BTreeMap::new();
6011 let rs = run(&v, "MATCH (n:N) RETURN COUNT(*) AS total", ¶ms).expect("COUNT AS");
6012 assert_eq!(rs.columns(), &["total".to_string()]);
6013 assert_eq!(rs.row(0), &[Some(i(1))]);
6014 }
6015
6016 #[test]
6017 fn count_var_skips_null_node_bindings() {
6018 let mut fx = Fx::new();
6021 fx.add("N", "a", vec![]);
6022 fx.add("N", "b", vec![]);
6023 let v = fx.view();
6024 let params = BTreeMap::new();
6025 let rs = run(&v, "MATCH (n:N) RETURN COUNT(n)", ¶ms).expect("COUNT(n)");
6026 assert_eq!(rs.columns(), &["COUNT(n)".to_string()]);
6027 assert_eq!(rs.row(0), &[Some(i(2))]);
6028 }
6029
6030 #[test]
6031 fn sum_numeric_prop_ignores_null_and_non_numeric() {
6032 let mut fx = Fx::new();
6033 fx.add("N", "a", vec![("v", i(10))]);
6034 fx.add("N", "b", vec![("v", i(20))]);
6035 fx.add("N", "c", vec![]); let v = fx.view();
6037 let params = BTreeMap::new();
6038 let rs = run(&v, "MATCH (n:N) RETURN SUM(n.v)", ¶ms).expect("SUM");
6039 assert_eq!(rs.columns(), &["SUM(n.v)".to_string()]);
6040 assert_eq!(rs.row(0), &[Some(i(30))]);
6042
6043 let rs_null = run(&v, "MATCH (n:N) RETURN SUM(n.missing)", ¶ms).expect("SUM null");
6045 assert_eq!(rs_null.row(0), &[None]);
6046 }
6047
6048 #[test]
6049 fn avg_numeric_prop() {
6050 let mut fx = Fx::new();
6051 fx.add("N", "a", vec![("v", i(10))]);
6052 fx.add("N", "b", vec![("v", i(30))]);
6053 let v = fx.view();
6054 let params = BTreeMap::new();
6055 let rs = run(&v, "MATCH (n:N) RETURN AVG(n.v) AS avg_v", ¶ms).expect("AVG");
6056 assert_eq!(rs.columns(), &["avg_v".to_string()]);
6057 assert_eq!(rs.row(0), &[Some(f(20.0))]);
6059
6060 let rs_empty = run(&v, "MATCH (n:Ghost) RETURN AVG(n.v)", ¶ms).expect("AVG empty");
6062 assert_eq!(rs_empty.row(0), &[None]);
6063 }
6064
6065 #[test]
6066 fn min_max_numeric_prop() {
6067 let mut fx = Fx::new();
6068 fx.add("N", "a", vec![("v", i(5))]);
6069 fx.add("N", "b", vec![("v", i(1))]);
6070 fx.add("N", "c", vec![("v", i(9))]);
6071 fx.add("N", "d", vec![]); let v = fx.view();
6073 let params = BTreeMap::new();
6074
6075 let min_rs = run(&v, "MATCH (n:N) RETURN MIN(n.v)", ¶ms).expect("MIN");
6076 assert_eq!(min_rs.row(0), &[Some(i(1))]);
6077
6078 let max_rs = run(&v, "MATCH (n:N) RETURN MAX(n.v)", ¶ms).expect("MAX");
6079 assert_eq!(max_rs.row(0), &[Some(i(9))]);
6080 }
6081
6082 #[test]
6085 fn min_max_mixed_int_float_props() {
6086 let mut fx = Fx::new();
6087 fx.add("N", "a", vec![("v", i(3))]);
6089 fx.add("N", "b", vec![("v", f(1.5))]);
6090 fx.add("N", "c", vec![("v", i(7))]);
6091 fx.add("N", "d", vec![("v", f(2.0))]);
6092 let v = fx.view();
6093 let params = BTreeMap::new();
6094
6095 let min_rs = run(&v, "MATCH (n:N) RETURN MIN(n.v)", ¶ms).expect("MIN mixed");
6096 assert_eq!(min_rs.row(0), &[Some(f(1.5))]);
6098
6099 let max_rs = run(&v, "MATCH (n:N) RETURN MAX(n.v)", ¶ms).expect("MAX mixed");
6100 assert_eq!(max_rs.row(0), &[Some(i(7))]);
6102 }
6103
6104 #[test]
6107 fn aggregate_limit_skip_order_by_are_no_ops() {
6108 let mut fx = Fx::new();
6109 fx.add("N", "a", vec![]);
6110 fx.add("N", "b", vec![]);
6111 fx.add("N", "c", vec![]);
6112 let v = fx.view();
6113 let params = BTreeMap::new();
6114
6115 let rs_lim5 =
6117 run(&v, "MATCH (n:N) RETURN COUNT(*) LIMIT 5", ¶ms).expect("COUNT(*) LIMIT 5");
6118 assert_eq!(
6119 rs_lim5.len(),
6120 1,
6121 "aggregate with LIMIT 5 must still return 1 row"
6122 );
6123 assert_eq!(rs_lim5.row(0), &[Some(i(3))]);
6124
6125 let rs_lim0 =
6127 run(&v, "MATCH (n:N) RETURN COUNT(*) LIMIT 0", ¶ms).expect("COUNT(*) LIMIT 0");
6128 assert_eq!(
6129 rs_lim0.len(),
6130 1,
6131 "aggregate with LIMIT 0 must still return 1 row"
6132 );
6133 assert_eq!(rs_lim0.row(0), &[Some(i(3))]);
6134
6135 let rs_skip =
6137 run(&v, "MATCH (n:N) RETURN COUNT(*) SKIP 100", ¶ms).expect("COUNT(*) SKIP 100");
6138 assert_eq!(
6139 rs_skip.len(),
6140 1,
6141 "aggregate with large SKIP must still return 1 row"
6142 );
6143
6144 let rs_ord = plan_src("MATCH (n:N) RETURN COUNT(*) ORDER BY n");
6148 assert!(
6151 rs_ord.is_ok(),
6152 "COUNT(*) ORDER BY should plan without error (ORDER BY dropped)"
6153 );
6154 let plan_ops = rs_ord.unwrap();
6155 assert!(
6157 !plan_ops
6158 .iter()
6159 .any(|op| matches!(op, crate::cypher::plan::PlanOp::OrderBy { .. })),
6160 "aggregate plan must not contain OrderBy"
6161 );
6162 }
6163
6164 #[test]
6165 fn count_star_no_budget_cap_applies() {
6166 let mut fx = Fx::new();
6169 let src = fx.add("Src", "s", vec![]);
6170 for i in 0..30usize {
6171 let dst = fx.add("Dst", &format!("d{i}"), vec![]);
6172 fx.edge("T", src, dst, vec![]);
6173 }
6174 let v = fx.view();
6175 let params = BTreeMap::new();
6176
6177 let cap_err =
6179 super::with_max_intermediate_rows(10, || run(&v, "MATCH (n:Dst) RETURN n", ¶ms));
6180 assert!(
6181 cap_err.is_err(),
6182 "staged path must error on 30 nodes with cap=10"
6183 );
6184
6185 let agg_ok = super::with_max_intermediate_rows(10, || {
6187 run(&v, "MATCH (n:Dst) RETURN COUNT(*)", ¶ms)
6188 })
6189 .expect("aggregate must not hit the intermediate-row cap");
6190 assert_eq!(
6191 agg_ok.row(0),
6192 &[Some(i(30))],
6193 "COUNT(*) must count all 30 nodes regardless of cap"
6194 );
6195 }
6196
6197 fn plan_src(src: &str) -> Result<Vec<crate::cypher::plan::PlanOp>, String> {
6198 use crate::cypher::{lex, parse, plan};
6199 let toks = lex(src).map_err(|e| format!("lex: {e}"))?;
6200 let ast = parse(&toks).map_err(|e| format!("parse: {e}"))?;
6201 plan(&ast).map_err(|e| format!("plan: {e}"))
6202 }
6203
6204 #[test]
6207 fn grouped_aggregation_plan_routing() {
6208 use crate::cypher::plan::PlanOp;
6209
6210 let ops = plan_src("MATCH (a:N) RETURN a, COUNT(*)")
6212 .expect("grouped aggregation must now succeed");
6213 assert!(
6214 ops.iter()
6215 .any(|op| matches!(op, PlanOp::GroupAggregate { .. })),
6216 "grouped aggregation plan must contain GroupAggregate op, got: {ops:?}"
6217 );
6218
6219 let ops2 = plan_src("MATCH (a:N) RETURN COUNT(*), COUNT(a)")
6221 .expect("multi-aggregate must now succeed");
6222 assert!(
6223 ops2.iter()
6224 .any(|op| matches!(op, PlanOp::GroupAggregate { .. })),
6225 "multi-aggregate plan must contain GroupAggregate op, got: {ops2:?}"
6226 );
6227
6228 let err3 = plan_src("MATCH (a:N) RETURN SUM(*)").expect_err("SUM(*) must be plan error");
6230 assert!(
6231 err3.to_ascii_lowercase().contains("sum") || err3.to_ascii_lowercase().contains("*"),
6232 "error must mention SUM or *, got: {err3}"
6233 );
6234 }
6235
6236 #[test]
6239 fn grouped_single_key_count() {
6240 let mut fx = Fx::new();
6242 fx.add("N", "a", vec![("t", s("X"))]);
6243 fx.add("N", "b", vec![("t", s("X"))]);
6244 fx.add("N", "c", vec![("t", s("Y"))]);
6245 let v = fx.view();
6246 let params = BTreeMap::new();
6247
6248 let rs = run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", ¶ms)
6249 .expect("single-key grouped COUNT must succeed");
6250 assert_eq!(
6251 rs.columns(),
6252 &["n.t".to_string(), "cnt".to_string()],
6253 "columns must match RETURN clause"
6254 );
6255 assert_eq!(rs.len(), 2, "must produce exactly 2 groups (X and Y)");
6256
6257 let find = |label: &Value| (0..rs.len()).find(|&i| rs.row(i)[0].as_ref() == Some(label));
6259 let xi = find(&s("X")).expect("group X must exist");
6260 let yi = find(&s("Y")).expect("group Y must exist");
6261 assert_eq!(rs.row(xi)[1], Some(i(2)), "X group count must be 2");
6262 assert_eq!(rs.row(yi)[1], Some(i(1)), "Y group count must be 1");
6263 }
6264
6265 #[test]
6266 fn grouped_two_keys_sum_avg() {
6267 let mut fx = Fx::new();
6269 fx.add(
6270 "N",
6271 "a",
6272 vec![("cat", s("A")), ("sub", s("1")), ("v", i(10))],
6273 );
6274 fx.add(
6275 "N",
6276 "b",
6277 vec![("cat", s("A")), ("sub", s("1")), ("v", i(20))],
6278 );
6279 fx.add(
6280 "N",
6281 "c",
6282 vec![("cat", s("A")), ("sub", s("2")), ("v", i(5))],
6283 );
6284 fx.add(
6285 "N",
6286 "d",
6287 vec![("cat", s("B")), ("sub", s("1")), ("v", i(100))],
6288 );
6289 let v = fx.view();
6290 let params = BTreeMap::new();
6291
6292 let rs = run(
6293 &v,
6294 "MATCH (n:N) RETURN n.cat, n.sub, SUM(n.v) AS total, AVG(n.v) AS avg_v",
6295 ¶ms,
6296 )
6297 .expect("two-key SUM + AVG must succeed");
6298 assert_eq!(
6299 rs.columns(),
6300 &[
6301 "n.cat".to_string(),
6302 "n.sub".to_string(),
6303 "total".to_string(),
6304 "avg_v".to_string()
6305 ]
6306 );
6307 assert_eq!(rs.len(), 3, "must produce 3 groups: (A,1), (A,2), (B,1)");
6308
6309 let find = |cat: &Value, sub: &Value| {
6310 (0..rs.len())
6311 .find(|&i| rs.row(i)[0].as_ref() == Some(cat) && rs.row(i)[1].as_ref() == Some(sub))
6312 };
6313 let a1 = find(&s("A"), &s("1")).expect("group (A,1) must exist");
6314 assert_eq!(rs.row(a1)[2], Some(i(30)), "(A,1) SUM must be 30");
6315 assert_eq!(rs.row(a1)[3], Some(f(15.0)), "(A,1) AVG must be 15.0");
6316
6317 let a2 = find(&s("A"), &s("2")).expect("group (A,2) must exist");
6318 assert_eq!(rs.row(a2)[2], Some(i(5)), "(A,2) SUM must be 5");
6319
6320 let b1 = find(&s("B"), &s("1")).expect("group (B,1) must exist");
6321 assert_eq!(rs.row(b1)[2], Some(i(100)), "(B,1) SUM must be 100");
6322 assert_eq!(rs.row(b1)[3], Some(f(100.0)), "(B,1) AVG must be 100.0");
6323 }
6324
6325 #[test]
6326 fn grouped_order_by_count_desc_limit() {
6327 let mut fx = Fx::new();
6329 fx.add("N", "a1", vec![("cat", s("A"))]);
6330 fx.add("N", "a2", vec![("cat", s("A"))]);
6331 fx.add("N", "a3", vec![("cat", s("A"))]);
6332 fx.add("N", "b1", vec![("cat", s("B"))]);
6333 fx.add("N", "b2", vec![("cat", s("B"))]);
6334 fx.add("N", "c1", vec![("cat", s("C"))]);
6335 fx.add("N", "d1", vec![("cat", s("D"))]);
6336 fx.add("N", "d2", vec![("cat", s("D"))]);
6337 fx.add("N", "d3", vec![("cat", s("D"))]);
6338 fx.add("N", "d4", vec![("cat", s("D"))]);
6339 fx.add("N", "e1", vec![("cat", s("E"))]);
6340 let v = fx.view();
6341 let params = BTreeMap::new();
6342
6343 let rs = run(
6344 &v,
6345 "MATCH (n:N) RETURN n.cat, COUNT(*) AS cnt ORDER BY cnt DESC LIMIT 3",
6346 ¶ms,
6347 )
6348 .expect("ORDER BY count DESC LIMIT 3 must succeed");
6349 assert_eq!(rs.len(), 3, "LIMIT 3 must return exactly 3 groups");
6350 assert_eq!(rs.row(0)[1], Some(i(4)), "row 0 must be count 4");
6352 assert_eq!(rs.row(0)[0], Some(s("D")), "row 0 must be category D");
6353 assert_eq!(rs.row(1)[1], Some(i(3)), "row 1 must be count 3");
6354 assert_eq!(rs.row(1)[0], Some(s("A")), "row 1 must be category A");
6355 assert_eq!(rs.row(2)[1], Some(i(2)), "row 2 must be count 2");
6356 assert_eq!(rs.row(2)[0], Some(s("B")), "row 2 must be category B");
6357
6358 let plan_ops =
6360 plan_src("MATCH (n:N) RETURN n.cat, COUNT(*) AS cnt ORDER BY cnt DESC LIMIT 3")
6361 .expect("plan must succeed");
6362 assert_eq!(
6363 crate::cypher::plan::row_bound(&plan_ops),
6364 None,
6365 "GroupAggregate plan with LIMIT must have row_bound = None"
6366 );
6367 }
6368
6369 #[test]
6370 fn grouped_empty_input_yields_zero_groups() {
6371 let fx = Fx::new(); let v = fx.view();
6373 let params = BTreeMap::new();
6374
6375 let rs = run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", ¶ms)
6376 .expect("grouped aggregate on empty graph must succeed");
6377 assert_eq!(rs.len(), 0, "empty input must yield zero groups");
6378 assert_eq!(
6379 rs.columns(),
6380 &["n.t".to_string(), "cnt".to_string()],
6381 "columns must still be present even with zero rows"
6382 );
6383 }
6384
6385 #[test]
6386 fn grouped_null_key_groups_together() {
6387 let mut fx = Fx::new();
6389 fx.add("N", "a", vec![("t", s("X"))]);
6390 fx.add("N", "b", vec![]); fx.add("N", "c", vec![]); fx.add("N", "d", vec![("t", s("Y"))]);
6393 let v = fx.view();
6394 let params = BTreeMap::new();
6395
6396 let rs = run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", ¶ms)
6397 .expect("null-key grouped aggregate must succeed");
6398 assert_eq!(rs.len(), 3, "must produce 3 groups: X, null, Y");
6399
6400 let null_row = (0..rs.len())
6402 .find(|&i| rs.row(i)[0].is_none())
6403 .expect("null group must be present");
6404 assert_eq!(
6405 rs.row(null_row)[1],
6406 Some(i(2)),
6407 "null group must count 2 rows (b and c)"
6408 );
6409
6410 let x_row = (0..rs.len())
6411 .find(|&i| rs.row(i)[0] == Some(s("X")))
6412 .expect("X group must exist");
6413 assert_eq!(rs.row(x_row)[1], Some(i(1)));
6414
6415 let y_row = (0..rs.len())
6416 .find(|&i| rs.row(i)[0] == Some(s("Y")))
6417 .expect("Y group must exist");
6418 assert_eq!(rs.row(y_row)[1], Some(i(1)));
6419 }
6420
6421 #[test]
6422 fn grouped_cap_error_on_high_cardinality() {
6423 let mut fx = Fx::new();
6426 fx.add("N", "a", vec![("t", s("A"))]);
6427 fx.add("N", "b", vec![("t", s("B"))]);
6428 fx.add("N", "c", vec![("t", s("C"))]);
6429 let v = fx.view();
6430 let params = BTreeMap::new();
6431
6432 let err = super::with_max_groups(2, || {
6433 run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", ¶ms)
6434 })
6435 .expect_err("must error when group count exceeds cap");
6436 assert!(
6437 err.to_ascii_lowercase().contains("group count"),
6438 "error must mention group count, got: {err}"
6439 );
6440 }
6441
6442 #[test]
6443 fn multi_aggregate_no_keys() {
6444 let mut fx = Fx::new();
6447 fx.add("N", "a", vec![]);
6448 fx.add("N", "b", vec![]);
6449 let v = fx.view();
6450 let params = BTreeMap::new();
6451
6452 let rs = run(&v, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", ¶ms)
6453 .expect("multi-aggregate no keys must succeed");
6454 assert_eq!(rs.len(), 1, "must produce exactly one result row");
6455 assert_eq!(rs.row(0)[0], Some(i(2)), "COUNT(*) must be 2");
6456 assert_eq!(rs.row(0)[1], Some(i(2)), "COUNT(n) must be 2");
6457 }
6458
6459 #[test]
6460 fn aggregate_over_hop_counts_edges() {
6461 let fx = hop_graph();
6462 let v = fx.view();
6463 let params = BTreeMap::new();
6464 let rs = run(
6466 &v,
6467 "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN COUNT(*)",
6468 ¶ms,
6469 )
6470 .expect("COUNT(*) on hop graph");
6471 assert_eq!(rs.row(0), &[Some(i(3))]);
6472 }
6473
6474 #[test]
6479 fn multi_aggregate_no_keys_empty_graph() {
6480 let fx = Fx::new(); let v = fx.view();
6486 let params = BTreeMap::new();
6487
6488 let rs = run(&v, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", ¶ms)
6489 .expect("empty-graph multi-agg must succeed");
6490 assert_eq!(rs.len(), 1, "must produce exactly 1 row on empty input");
6491 assert_eq!(
6492 rs.row(0)[0],
6493 Some(i(0)),
6494 "COUNT(*) on empty graph must be 0"
6495 );
6496 assert_eq!(
6497 rs.row(0)[1],
6498 Some(i(0)),
6499 "COUNT(n) on empty graph must be 0"
6500 );
6501 }
6502
6503 #[test]
6504 fn fast_path_and_group_path_agree_on_empty_input() {
6505 let fx = Fx::new();
6511 let v = fx.view();
6512 let params = BTreeMap::new();
6513
6514 let fast = run(&v, "MATCH (n:N) RETURN COUNT(*)", ¶ms)
6515 .expect("fast-path COUNT on empty graph");
6516 assert_eq!(fast.len(), 1, "fast path: 1 row on empty input");
6517 let fast_count = fast.row(0)[0].clone();
6518
6519 let grouped = run(&v, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", ¶ms)
6520 .expect("group-path COUNT on empty graph");
6521 assert_eq!(grouped.len(), 1, "group path: 1 row on empty input");
6522 let group_count = grouped.row(0)[0].clone();
6523
6524 assert_eq!(
6525 fast_count, group_count,
6526 "fast path and group path must agree on COUNT(*) for empty input"
6527 );
6528
6529 let mut fx2 = Fx::new();
6531 fx2.add("N", "x", vec![]);
6532 fx2.add("N", "y", vec![]);
6533 let v2 = fx2.view();
6534
6535 let fast2 = run(&v2, "MATCH (n:N) RETURN COUNT(*)", ¶ms)
6536 .expect("fast-path COUNT on 2-node graph");
6537 assert_eq!(fast2.row(0)[0], Some(i(2)), "fast path: COUNT(*) = 2");
6538
6539 let grouped2 = run(&v2, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", ¶ms)
6540 .expect("group-path COUNT on 2-node graph");
6541 assert_eq!(grouped2.row(0)[0], Some(i(2)), "group path: COUNT(*) = 2");
6542 assert_eq!(grouped2.row(0)[1], Some(i(2)), "group path: COUNT(n) = 2");
6543 }
6544
6545 #[test]
6550 fn grouped_int_float_key_unification() {
6551 let mut fx = Fx::new();
6558 fx.add("N", "a", vec![("score", i(1))]); fx.add("N", "b", vec![("score", f(1.0))]);
6560 let v = fx.view();
6561 let params = BTreeMap::new();
6562
6563 let rs = run(&v, "MATCH (n:N) RETURN n.score, COUNT(*) AS cnt", ¶ms)
6564 .expect("int/float unification must succeed");
6565 assert_eq!(
6566 rs.len(),
6567 1,
6568 "Int(1) and Float(1.0) must group together into 1 group"
6569 );
6570 assert_eq!(
6572 rs.row(0)[0],
6573 Some(i(1)),
6574 "key column must display Int(1) (first-seen)"
6575 );
6576 assert_eq!(rs.row(0)[1], Some(i(2)), "unified group must have count=2");
6577 }
6578
6579 #[test]
6580 fn distinct_collapses_duplicate_projected_values() {
6581 let mut fx = Fx::new();
6582 fx.add("N", "a1", vec![("city", s("Austin"))]);
6583 fx.add("N", "a2", vec![("city", s("Austin"))]);
6584 let v = fx.view();
6585 let params = BTreeMap::new();
6586 let rs = run(&v, "MATCH (n:N) RETURN DISTINCT n.city", ¶ms).unwrap();
6587 assert_eq!(rs.len(), 1);
6588 assert_eq!(rs.row(0)[0], Some(s("Austin")));
6589 }
6590
6591 #[test]
6592 fn distinct_int_float_unify() {
6593 let mut fx = Fx::new();
6594 fx.add("N", "a", vec![("score", i(1))]);
6595 fx.add("N", "b", vec![("score", f(1.0))]);
6596 let v = fx.view();
6597 let params = BTreeMap::new();
6598 let rs = run(&v, "MATCH (n:N) RETURN DISTINCT n.score", ¶ms).unwrap();
6599 assert_eq!(
6600 rs.len(),
6601 1,
6602 "Int(1) and Float(1.0) must DISTINCT as one row"
6603 );
6604 assert_eq!(rs.row(0)[0], Some(i(1)), "first-seen Int(1) wins display");
6605 }
6606
6607 #[test]
6608 fn distinct_caps_at_intermediate_row_budget() {
6609 let mut fx = Fx::new();
6610 fx.add("N", "a", vec![("city", s("Austin"))]);
6611 fx.add("N", "b", vec![("city", s("Paris"))]);
6612 let v = fx.view();
6613 let params = BTreeMap::new();
6614 let err = super::with_max_intermediate_rows(1, || {
6615 run(&v, "MATCH (n:N) RETURN DISTINCT n.city", ¶ms)
6616 })
6617 .expect_err("two distinct cities must exceed cap=1");
6618 assert!(
6619 err.contains("1") || err.contains("row"),
6620 "cap error must mention the budget, got: {err}"
6621 );
6622 }
6623
6624 #[test]
6625 fn where_in_list_filters_rows() {
6626 let mut fx = Fx::new();
6627 fx.add("N", "a", vec![("city", s("Austin"))]);
6628 fx.add("N", "p", vec![("city", s("Paris"))]);
6629 fx.add("N", "l", vec![("city", s("London"))]);
6630 let v = fx.view();
6631 let mut params = BTreeMap::new();
6632 params.insert("c".into(), s("Paris"));
6633 let rs = run(
6634 &v,
6635 "MATCH (n:N) WHERE n.city IN ['Austin', $c] RETURN n.city",
6636 ¶ms,
6637 )
6638 .unwrap();
6639 assert_eq!(rs.len(), 2);
6640 }
6641
6642 #[test]
6643 fn pure_int_keys_display_as_int() {
6644 let mut fx = Fx::new();
6649 fx.add("N", "a", vec![("age", i(10))]);
6650 fx.add("N", "b", vec![("age", i(20))]);
6651 fx.add("N", "c", vec![("age", i(10))]);
6652 let v = fx.view();
6653 let params = BTreeMap::new();
6654
6655 let rs = run(
6656 &v,
6657 "MATCH (n:N) RETURN n.age, COUNT(*) AS cnt ORDER BY n.age",
6658 ¶ms,
6659 )
6660 .expect("pure-Int group keys must succeed");
6661 assert_eq!(rs.len(), 2, "must have 2 groups: age=10 and age=20");
6662 assert_eq!(
6664 rs.row(0)[0],
6665 Some(i(10)),
6666 "age=10 key column must display as Int(10)"
6667 );
6668 assert_eq!(rs.row(0)[1], Some(i(2)), "age=10 group has 2 nodes");
6669 assert_eq!(
6670 rs.row(1)[0],
6671 Some(i(20)),
6672 "age=20 key column must display as Int(20)"
6673 );
6674 assert_eq!(rs.row(1)[1], Some(i(1)), "age=20 group has 1 node");
6675 }
6676
6677 #[test]
6682 fn var_expand_group_aggregate_staged_path() {
6683 let mut fx = Fx::new();
6693 let a = fx.add("N", "a", vec![("key", s("a"))]);
6694 let b = fx.add("N", "b", vec![("key", s("b"))]);
6695 let c = fx.add("N", "c", vec![("key", s("c"))]);
6696 fx.edge("T", a, b, vec![]);
6697 fx.edge("T", b, c, vec![]);
6698 let v = fx.view();
6699 let params = BTreeMap::new();
6700
6701 let rs = run(
6702 &v,
6703 "MATCH (x:N)-[*1..2]->(y:N) RETURN y.key, COUNT(*) AS cnt ORDER BY y.key",
6704 ¶ms,
6705 )
6706 .expect("VarExpand + GroupAggregate must succeed");
6707
6708 assert_eq!(rs.len(), 2, "must have 2 destination groups: b and c");
6709 assert_eq!(rs.row(0)[0], Some(s("b")), "first group key must be 'b'");
6710 assert_eq!(rs.row(0)[1], Some(i(1)), "b is reached via 1 path");
6711 assert_eq!(rs.row(1)[0], Some(s("c")), "second group key must be 'c'");
6712 assert_eq!(
6713 rs.row(1)[1],
6714 Some(i(2)),
6715 "c is reached via 2 paths (1-hop and 2-hop)"
6716 );
6717 }
6718
6719 #[test]
6727 fn pull_rows_var_expand_arm_returns_named_err() {
6728 let fx = Fx::new();
6729 let view = fx.view();
6730 let vars = super::VarTable {
6731 names: vec!["a".into(), "b".into()],
6732 };
6733 let project_items: Vec<crate::cypher::ast::RetItem> = vec![];
6734 let empty_params = BTreeMap::new();
6735 let params = super::Params(&empty_params);
6736 let ctx = super::PullCtx {
6737 view: &view,
6738 vars: &vars,
6739 project_items: &project_items,
6740 params: ¶ms,
6741 bound: 100,
6742 };
6743 let ops = vec![PlanOp::VarExpand {
6744 from: "a".into(),
6745 rel_var: None,
6746 etypes: vec![],
6747 dir: crate::cypher::RelDir::Right,
6748 to: "b".into(),
6749 min: 1,
6750 max: 3,
6751 }];
6752 let mut row = vec![None; vars.names.len()];
6753 let mut result = Vec::new();
6754 let err = super::pull_rows(&ctx, &ops, &mut row, &mut result)
6755 .expect_err("VarExpand must Err in pull_rows");
6756 assert!(
6757 err.contains("VarExpand") && err.contains("pull executor"),
6758 "error must name VarExpand and pull executor, got: {err}"
6759 );
6760 }
6761
6762 #[test]
6763 fn pull_rows_shortest_path_arm_returns_named_err() {
6764 let fx = Fx::new();
6765 let view = fx.view();
6766 let vars = super::VarTable {
6767 names: vec!["a".into(), "b".into()],
6768 };
6769 let project_items: Vec<crate::cypher::ast::RetItem> = vec![];
6770 let empty_params = BTreeMap::new();
6771 let params = super::Params(&empty_params);
6772 let ctx = super::PullCtx {
6773 view: &view,
6774 vars: &vars,
6775 project_items: &project_items,
6776 params: ¶ms,
6777 bound: 100,
6778 };
6779 let ops = vec![PlanOp::ShortestPath {
6780 from: "a".into(),
6781 rel_var: None,
6782 etypes: vec![],
6783 dir: crate::cypher::RelDir::Right,
6784 to: "b".into(),
6785 max_hops: 5,
6786 }];
6787 let mut row = vec![None; vars.names.len()];
6788 let mut result = Vec::new();
6789 let err = super::pull_rows(&ctx, &ops, &mut row, &mut result)
6790 .expect_err("ShortestPath must Err in pull_rows");
6791 assert!(
6792 err.contains("ShortestPath") && err.contains("pull executor"),
6793 "error must name ShortestPath and pull executor, got: {err}"
6794 );
6795 }
6796
6797 fn city_graph() -> Fx {
6801 let mut fx = Fx::new();
6802 fx.add(
6803 "Person",
6804 "alice",
6805 vec![("city", s("Boston")), ("age", i(30))],
6806 );
6807 fx.add("Person", "bob", vec![("city", s("Boston")), ("age", i(25))]);
6808 fx.add(
6809 "Person",
6810 "carol",
6811 vec![("city", s("Austin")), ("age", i(35))],
6812 );
6813 fx.add(
6814 "Person",
6815 "dave",
6816 vec![("city", s("Austin")), ("age", i(28))],
6817 );
6818 fx.add("Person", "eve", vec![("city", s("Boston")), ("age", i(22))]);
6819 fx
6820 }
6821
6822 #[test]
6825 fn with_aggregate_having_filters_groups() {
6826 let fx = city_graph();
6827 let view = fx.view();
6828 let params = BTreeMap::new();
6829 let rs = run(
6830 &view,
6831 "MATCH (p:Person) WITH p.city AS city, COUNT(*) AS cnt WHERE cnt > 2 RETURN city, cnt",
6832 ¶ms,
6833 )
6834 .expect("WITH HAVING query must succeed");
6835 assert_eq!(rs.len(), 1, "only Boston group has cnt > 2");
6836 assert_eq!(rs.get(0, "city"), Some(&s("Boston")));
6837 assert_eq!(rs.get(0, "cnt"), Some(&i(3)));
6838 }
6839
6840 #[test]
6842 fn with_order_limit_then_return() {
6843 let fx = city_graph();
6844 let view = fx.view();
6845 let params = BTreeMap::new();
6846 let rs = run(&view, "MATCH (p:Person) WITH p, p.age AS age ORDER BY age DESC LIMIT 2 RETURN p.city AS city, age", ¶ms)
6848 .expect("WITH ORDER LIMIT must succeed");
6849 assert_eq!(rs.len(), 2, "LIMIT 2");
6850 let ages: Vec<Option<Value>> = (0..rs.len()).map(|i| rs.get(i, "age").cloned()).collect();
6852 assert_eq!(ages[0], Some(i(35)), "oldest person first");
6853 assert_eq!(ages[1], Some(i(30)), "second oldest");
6854 }
6855
6856 #[test]
6858 fn chained_with_stages() {
6859 let fx = city_graph();
6860 let view = fx.view();
6861 let params = BTreeMap::new();
6862 let rs = run(&view,
6864 "MATCH (p:Person) WITH p.city AS city, p.age AS age WITH city, age WHERE age > 25 RETURN city, age",
6865 ¶ms).expect("chained WITH must succeed");
6866 assert_eq!(rs.len(), 3, "3 people with age > 25");
6868 }
6869
6870 #[test]
6872 fn with_then_match_reentry() {
6873 let mut fx = Fx::new();
6874 let alice = fx.add("Person", "alice", vec![]);
6875 let bob = fx.add("Person", "bob", vec![]);
6876 let corp = fx.add("Company", "acme", vec![]);
6877 fx.edge("WORKS_AT", alice, corp, vec![("years", i(5))]);
6878 fx.edge("WORKS_AT", bob, corp, vec![("years", i(3))]);
6879 let view = fx.view();
6880 let params = BTreeMap::new();
6881 let rs = run(&view,
6883 "MATCH (p:Person)-[r:WORKS_AT]->(c:Company) WITH p, c MATCH (c)-[r2:WORKS_AT]-(colleague:Person) RETURN p, colleague",
6884 ¶ms).expect("WITH MATCH re-entry must succeed");
6885 assert!(rs.len() >= 2, "cross join via company: got {}", rs.len());
6887 }
6888
6889 #[test]
6891 fn unwind_literal_list_produces_rows() {
6892 let fx = city_graph();
6893 let view = fx.view();
6894 let params = BTreeMap::new();
6895 let rs = run(
6896 &view,
6897 "MATCH (p:Person) WHERE p.city = 'Boston' UNWIND [1, 2, 3] AS x RETURN p, x",
6898 ¶ms,
6899 )
6900 .expect("UNWIND literal must succeed");
6901 assert_eq!(rs.len(), 9, "UNWIND [1,2,3] × 3 Boston people = 9 rows");
6903 let xs: Vec<Option<Value>> = (0..rs.len())
6905 .map(|row_i| rs.get(row_i, "x").cloned())
6906 .collect();
6907 assert!(xs.contains(&Some(i(1))));
6908 assert!(xs.contains(&Some(i(2))));
6909 assert!(xs.contains(&Some(i(3))));
6910 }
6911
6912 #[test]
6914 fn unwind_list_property() {
6915 let mut fx = Fx::new();
6916 fx.add(
6917 "Tag",
6918 "post1",
6919 vec![("tags", Value::List(vec![s("rust"), s("graph")]))],
6920 );
6921 fx.add("Tag", "post2", vec![("tags", Value::List(vec![s("db")]))]);
6922 let view = fx.view();
6923 let params = BTreeMap::new();
6924 let rs = run(
6925 &view,
6926 "MATCH (p:Tag) UNWIND p.tags AS tag RETURN p, tag",
6927 ¶ms,
6928 )
6929 .expect("UNWIND property must succeed");
6930 assert_eq!(rs.len(), 3, "2+1 tag elements");
6931 }
6932
6933 #[test]
6935 fn unwind_empty_list_yields_zero_rows() {
6936 let fx = city_graph();
6937 let view = fx.view();
6938 let params = BTreeMap::new();
6939 let rs = run(
6940 &view,
6941 "MATCH (p:Person) WHERE p.city = 'Boston' UNWIND [] AS x RETURN x",
6942 ¶ms,
6943 )
6944 .expect("UNWIND [] must succeed with 0 rows");
6945 assert_eq!(rs.len(), 0, "UNWIND [] should produce 0 rows");
6946 }
6947
6948 #[test]
6950 fn unwind_non_list_is_named_error() {
6951 let fx = city_graph();
6952 let view = fx.view();
6953 let params = BTreeMap::new();
6954 let err = run(
6956 &view,
6957 "MATCH (p:Person) WHERE p.city = 'Boston' UNWIND p.city AS x RETURN x",
6958 ¶ms,
6959 )
6960 .expect_err("UNWIND non-list must error");
6961 assert!(
6962 err.contains("UNWIND") && err.contains("list"),
6963 "error must mention UNWIND and list: {err}"
6964 );
6965 }
6966
6967 #[test]
6969 fn unwind_cross_product_trips_budget() {
6970 let mut fx = Fx::new();
6971 for i in 0..10 {
6973 fx.add("N", &format!("n{i}"), vec![]);
6974 }
6975 let view = fx.view();
6976 let params = BTreeMap::new();
6977 let err = super::with_max_intermediate_rows(5, || {
6978 run(
6979 &view,
6980 "MATCH (n:N) UNWIND [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] AS x RETURN n, x",
6981 ¶ms,
6982 )
6983 })
6984 .expect_err("must hit budget");
6985 assert!(
6986 err.contains("intermediate result exceeds"),
6987 "error must mention intermediate result limit: {err}"
6988 );
6989 }
6990
6991 #[test]
6994 fn routing_with_plan_uses_staged_path() {
6995 use crate::cypher::plan::row_bound;
6996 let ops_with = compile("MATCH (a) WITH a RETURN a");
6997 assert_eq!(
6998 row_bound(&ops_with),
6999 None,
7000 "WITH plan must have row_bound=None"
7001 );
7002
7003 let ops_unwind = compile("MATCH (a) UNWIND [1, 2] AS x RETURN a");
7004 assert_eq!(
7005 row_bound(&ops_unwind),
7006 None,
7007 "UNWIND plan must have row_bound=None"
7008 );
7009
7010 let ops_plain = compile("MATCH (a) RETURN a LIMIT 5");
7012 assert!(
7013 row_bound(&ops_plain).is_some(),
7014 "plain LIMIT plan must have a row bound"
7015 );
7016 }
7017
7018 #[test]
7021 fn unwind_null_property_yields_zero_rows_for_that_node() {
7022 let mut fx = Fx::new();
7023 fx.add(
7025 "Post",
7026 "post1",
7027 vec![("tags", Value::List(vec![s("rust"), s("graph")]))],
7028 );
7029 fx.add("Post", "post2", vec![("other", Value::Str("hello".into()))]);
7030 let view = fx.view();
7031 let params = BTreeMap::new();
7032 let rs = run(
7033 &view,
7034 "MATCH (p:Post) UNWIND p.tags AS tag RETURN tag",
7035 ¶ms,
7036 )
7037 .expect("UNWIND null property must succeed (not error)");
7038 assert_eq!(
7040 rs.len(),
7041 2,
7042 "null property must yield 0 rows; list property yields N rows"
7043 );
7044 let tag0 = rs.get(0, "tag");
7045 let tag1 = rs.get(1, "tag");
7046 let tags = [tag0, tag1];
7047 assert!(tags.contains(&Some(&s("rust"))), "rust tag present");
7048 assert!(tags.contains(&Some(&s("graph"))), "graph tag present");
7049 }
7050
7051 #[test]
7053 fn where_before_unwind_filters_nodes() {
7054 let mut fx = Fx::new();
7055 fx.add(
7056 "P",
7057 "a",
7058 vec![
7059 ("group", Value::Str("keep".into())),
7060 ("items", Value::List(vec![Value::Int(1), Value::Int(2)])),
7061 ],
7062 );
7063 fx.add(
7064 "P",
7065 "b",
7066 vec![
7067 ("group", Value::Str("drop".into())),
7068 ("items", Value::List(vec![Value::Int(3), Value::Int(4)])),
7069 ],
7070 );
7071 let view = fx.view();
7072 let params = BTreeMap::new();
7073 let rs = run(
7075 &view,
7076 "MATCH (n:P) WHERE n.group = 'keep' UNWIND n.items AS x RETURN x",
7077 ¶ms,
7078 )
7079 .expect("WHERE before UNWIND must succeed");
7080 assert_eq!(rs.len(), 2, "only matching node expands; 2 items");
7081 }
7082
7083 #[test]
7085 fn where_after_unwind_filters_expanded_rows() {
7086 let mut fx = Fx::new();
7087 fx.add(
7088 "N",
7089 "n1",
7090 vec![(
7091 "vals",
7092 Value::List(vec![Value::Int(1), Value::Int(3), Value::Int(5)]),
7093 )],
7094 );
7095 let view = fx.view();
7096 let params = BTreeMap::new();
7097 let rs = run(
7099 &view,
7100 "MATCH (n:N) UNWIND n.vals AS x WHERE x > 2 RETURN x",
7101 ¶ms,
7102 )
7103 .expect("WHERE after UNWIND must succeed");
7104 assert_eq!(rs.len(), 2, "x=3 and x=5 pass; x=1 filtered out");
7105 let v0 = rs.get(0, "x").cloned();
7106 let v1 = rs.get(1, "x").cloned();
7107 let vals = [v0, v1];
7108 assert!(vals.contains(&Some(Value::Int(3))), "x=3 present");
7109 assert!(vals.contains(&Some(Value::Int(5))), "x=5 present");
7110 }
7111
7112 #[test]
7114 fn aggregate_with_order_by_unknown_alias_is_named_error() {
7115 use crate::cypher::{lex, parser::parse, plan::plan};
7116 let src = "MATCH (p:Person) WITH p.city AS city, COUNT(*) AS cnt ORDER BY cntt DESC RETURN city, cnt";
7118 let plan_result = plan(&parse(&lex(src).expect("lex")).expect("parse"));
7119 let err = plan_result
7120 .expect_err("typo'd ORDER BY alias in aggregate WITH must be a named plan error");
7121 assert!(
7122 err.contains("cntt") || err.contains("unbound"),
7123 "error must mention the unknown alias: {err}"
7124 );
7125 }
7126
7127 #[test]
7129 fn non_aggregate_with_order_by_unknown_alias_is_named_error() {
7130 use crate::cypher::{lex, parser::parse, plan::plan};
7131 let src = "MATCH (p:Person) WITH p, p.age AS age ORDER BY nope DESC RETURN p.city AS city";
7132 let plan_result = plan(&parse(&lex(src).expect("lex")).expect("parse"));
7133 let err = plan_result
7134 .expect_err("typo'd ORDER BY alias in non-aggregate WITH must be a named plan error");
7135 assert!(
7136 err.contains("nope") || err.contains("unbound"),
7137 "error must mention the unknown alias: {err}"
7138 );
7139 }
7140
7141 #[test]
7145 fn post_unwind_where_references_pre_match_variable() {
7146 let mut fx = Fx::new();
7147 fx.add(
7149 "N",
7150 "n1",
7151 vec![
7152 ("threshold", Value::Int(3)),
7153 (
7154 "xs",
7155 Value::List(vec![
7156 Value::Int(1),
7157 Value::Int(2),
7158 Value::Int(3),
7159 Value::Int(4),
7160 Value::Int(5),
7161 ]),
7162 ),
7163 ],
7164 );
7165 fx.add(
7167 "N",
7168 "n2",
7169 vec![
7170 ("threshold", Value::Int(10)),
7171 ("xs", Value::List(vec![Value::Int(1), Value::Int(2)])),
7172 ],
7173 );
7174 let view = fx.view();
7175 let params = BTreeMap::new();
7176 let rs = run(
7177 &view,
7178 "MATCH (n:N) UNWIND n.xs AS x WHERE x > n.threshold RETURN x",
7179 ¶ms,
7180 )
7181 .expect("post-UNWIND WHERE referencing MATCH variable must succeed");
7182 assert_eq!(rs.len(), 2, "exactly 2 rows: x=4 and x=5 from n1");
7184 let v0 = rs.get(0, "x").cloned();
7185 let v1 = rs.get(1, "x").cloned();
7186 let got = [v0, v1];
7187 assert!(got.contains(&Some(Value::Int(4))), "x=4 present");
7188 assert!(got.contains(&Some(Value::Int(5))), "x=5 present");
7189 }
7190
7191 #[test]
7195 fn with_stage_aggregate_order_by_descending() {
7196 let fx = city_graph();
7199 let view = fx.view();
7200 let params = BTreeMap::new();
7201 let rs = run(
7202 &view,
7203 "MATCH (p:Person) WITH p.city AS city, COUNT(*) AS cnt ORDER BY cnt DESC RETURN city, cnt",
7204 ¶ms,
7205 )
7206 .expect("aggregate WITH ORDER BY must succeed");
7207 assert_eq!(rs.len(), 2, "two city groups");
7208 assert_eq!(
7210 rs.get(0, "cnt"),
7211 Some(&Value::Int(3)),
7212 "first row must be the group with cnt=3 (Boston)"
7213 );
7214 assert_eq!(
7215 rs.get(1, "cnt"),
7216 Some(&Value::Int(2)),
7217 "second row must be the group with cnt=2 (Austin)"
7218 );
7219 }
7220
7221 #[test]
7225 fn unwind_then_with_composition() {
7226 let mut fx = Fx::new();
7227 fx.add(
7228 "Doc",
7229 "d1",
7230 vec![(
7231 "scores",
7232 Value::List(vec![Value::Int(10), Value::Int(20), Value::Int(30)]),
7233 )],
7234 );
7235 fx.add(
7236 "Doc",
7237 "d2",
7238 vec![("scores", Value::List(vec![Value::Int(5), Value::Int(15)]))],
7239 );
7240 let view = fx.view();
7241 let params = BTreeMap::new();
7242
7243 let rs = run(
7245 &view,
7246 "MATCH (n:Doc) UNWIND n.scores AS x WITH x RETURN x",
7247 ¶ms,
7248 )
7249 .expect("UNWIND then WITH pass-through must succeed");
7250 assert_eq!(rs.len(), 5, "3 + 2 = 5 expanded rows carried through WITH");
7251
7252 let rs2 = run(
7254 &view,
7255 "MATCH (n:Doc) UNWIND n.scores AS x WITH COUNT(*) AS total RETURN total",
7256 ¶ms,
7257 )
7258 .expect("UNWIND then aggregate WITH must succeed");
7259 assert_eq!(rs2.len(), 1, "single aggregate row");
7260 assert_eq!(
7261 rs2.get(0, "total"),
7262 Some(&Value::Int(5)),
7263 "total must be 5 (3+2 expanded rows)"
7264 );
7265 }
7266
7267 #[test]
7273 fn binarith_div_min_over_neg1_does_not_panic() {
7274 let fx = Fx::new();
7275 let view = fx.view();
7276 let vars = VarTable { names: vec![] };
7277 let row: Row = vec![];
7278 let params = BTreeMap::new();
7279
7280 let operand = Operand::BinArith {
7282 op: ArithOp::Div,
7283 left: Box::new(Operand::Lit(Value::Int(i64::MIN))),
7284 right: Box::new(Operand::Lit(Value::Int(-1))),
7285 };
7286
7287 let result = resolve_operand(&view, &vars, &row, &operand, &Params(¶ms));
7288 assert!(
7289 result.is_ok(),
7290 "overflow division must not return Err: {result:?}"
7291 );
7292 assert_eq!(
7293 result.unwrap(),
7294 Some(Value::Int(i64::MAX)),
7295 "i64::MIN / -1 must saturate to i64::MAX, not panic"
7296 );
7297 }
7298
7299 #[test]
7304 fn binarith_missing_param_caught_at_preflight() {
7305 let fx = Fx::new();
7306 let view = fx.view();
7307 let err = run(
7312 &view,
7313 "MATCH (n:X) RETURN abs($missing - 1)",
7314 &BTreeMap::new(),
7315 )
7316 .expect_err("missing param inside BinArith must be caught at preflight");
7317 assert!(
7318 err.contains("missing"),
7319 "error must name the param 'missing', got: {err}"
7320 );
7321 }
7322
7323 #[test]
7327 fn is_null_filters_absent_prop() {
7328 let mut fx = Fx::new();
7329 fx.add("Person", "alice", vec![("age", Value::Int(30))]);
7330 fx.add("Person", "bob", vec![]); let view = fx.view();
7332 let rs = run(
7333 &view,
7334 "MATCH (n:Person) WHERE n.age IS NULL RETURN n",
7335 &BTreeMap::new(),
7336 )
7337 .unwrap();
7338 assert_eq!(rs.len(), 1, "only bob lacks age");
7339 assert_eq!(rs.get(0, "n"), Some(&s("bob")));
7340 }
7341
7342 #[test]
7344 fn is_not_null_filters_present_prop() {
7345 let mut fx = Fx::new();
7346 fx.add("Person", "alice", vec![("age", Value::Int(30))]);
7347 fx.add("Person", "bob", vec![]); let view = fx.view();
7349 let rs = run(
7350 &view,
7351 "MATCH (n:Person) WHERE n.age IS NOT NULL RETURN n",
7352 &BTreeMap::new(),
7353 )
7354 .unwrap();
7355 assert_eq!(rs.len(), 1, "only alice has age");
7356 assert_eq!(rs.get(0, "n"), Some(&s("alice")));
7357 }
7358
7359 #[test]
7362 fn optional_match_is_null_anti_join() {
7363 let mut fx = Fx::new();
7364 let alice = fx.add("Person", "alice", vec![]);
7365 let bob = fx.add("Person", "bob", vec![]);
7366 let carol = fx.add("Person", "carol", vec![]);
7367 fx.edge("KNOWS", alice, carol, vec![]); fx.edge("KNOWS", carol, bob, vec![]); let view = fx.view();
7371 let rs = run(
7372 &view,
7373 "MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b) WITH a, b WHERE b IS NULL RETURN a",
7374 &BTreeMap::new(),
7375 )
7376 .unwrap();
7377 assert_eq!(rs.len(), 1, "only bob has no outgoing KNOWS edge");
7378 assert_eq!(rs.get(0, "a"), Some(&s("bob")));
7379 }
7380
7381 #[test]
7383 fn is_null_combined_with_and_exec() {
7384 let mut fx = Fx::new();
7385 fx.add("N", "a", vec![("x", Value::Int(1))]);
7386 fx.add("N", "b", vec![("x", Value::Int(2)), ("y", Value::Int(9))]);
7387 fx.add("N", "c", vec![]); let view = fx.view();
7389 let rs = run(
7391 &view,
7392 "MATCH (n:N) WHERE n.y IS NULL AND n.x IS NOT NULL RETURN n",
7393 &BTreeMap::new(),
7394 )
7395 .unwrap();
7396 assert_eq!(rs.len(), 1);
7397 assert_eq!(rs.get(0, "n"), Some(&s("a")));
7398 }
7399
7400 #[test]
7404 fn arithmetic_add_in_return() {
7405 let mut fx = Fx::new();
7406 fx.add("Person", "alice", vec![("age", Value::Int(29))]);
7407 let view = fx.view();
7408 let rs = run(
7409 &view,
7410 "MATCH (n:Person) RETURN n.age + 1 AS adjusted",
7411 &BTreeMap::new(),
7412 )
7413 .unwrap();
7414 assert_eq!(rs.len(), 1);
7415 assert_eq!(rs.get(0, "adjusted"), Some(&Value::Int(30)));
7416 }
7417
7418 #[test]
7420 fn arithmetic_precedence_parens_pin() {
7421 let mut fx = Fx::new();
7422 fx.add("N", "x", vec![]);
7423 let view = fx.view();
7424 let rs = run(
7425 &view,
7426 "MATCH (n:N) RETURN (1 + 2) * 3 AS r",
7427 &BTreeMap::new(),
7428 )
7429 .unwrap();
7430 assert_eq!(rs.len(), 1);
7431 assert_eq!(rs.get(0, "r"), Some(&Value::Int(9)));
7432 }
7433
7434 #[test]
7436 fn arithmetic_precedence_mul_over_add_pin() {
7437 let mut fx = Fx::new();
7438 fx.add("N", "x", vec![]);
7439 let view = fx.view();
7440 let rs = run(&view, "MATCH (n:N) RETURN 1 + 2 * 3 AS r", &BTreeMap::new()).unwrap();
7441 assert_eq!(rs.len(), 1);
7442 assert_eq!(rs.get(0, "r"), Some(&Value::Int(7)));
7443 }
7444
7445 #[test]
7447 fn arithmetic_div_by_zero_returns_error() {
7448 let mut fx = Fx::new();
7449 fx.add("N", "x", vec![]);
7450 let view = fx.view();
7451 let err = run(
7452 &view,
7453 "MATCH (n:N) WHERE 1 / 0 > 0 RETURN n",
7454 &BTreeMap::new(),
7455 )
7456 .expect_err("division by zero must error");
7457 assert!(
7458 err.contains("division by zero"),
7459 "error must mention division by zero, got: {err}"
7460 );
7461 }
7462
7463 #[test]
7465 fn arithmetic_null_propagates() {
7466 let mut fx = Fx::new();
7467 fx.add("N", "x", vec![]); let view = fx.view();
7469 let rs = run(
7471 &view,
7472 "MATCH (n:N) WHERE n.val + 1 > 0 RETURN n",
7473 &BTreeMap::new(),
7474 )
7475 .unwrap();
7476 assert_eq!(rs.len(), 0, "null arithmetic must not match");
7477 }
7478
7479 #[test]
7481 fn case_when_expression_in_return() {
7482 let mut fx = Fx::new();
7483 fx.add("N", "a", vec![("id", s("a")), ("age", i(20))]);
7484 fx.add("N", "b", vec![("id", s("b")), ("age", i(65))]);
7485 let v = fx.view();
7486 let rs = run(
7487 &v,
7488 "MATCH (n:N) RETURN n.id AS id, \
7489 CASE WHEN n.age >= 65 THEN 'senior' ELSE 'other' END AS band",
7490 &BTreeMap::new(),
7491 )
7492 .unwrap();
7493 let band_of = |who: &str| {
7494 (0..rs.len())
7495 .find(|&i| rs.get(i, "id") == Some(&s(who)))
7496 .and_then(|i| rs.get(i, "band").cloned())
7497 };
7498 assert_eq!(band_of("a"), Some(s("other")));
7499 assert_eq!(band_of("b"), Some(s("senior")));
7500 }
7501
7502 #[test]
7503 fn case_when_no_else_yields_null() {
7504 let mut fx = Fx::new();
7505 fx.add("N", "a", vec![("age", i(20))]);
7506 let v = fx.view();
7507 let rs = run(
7508 &v,
7509 "MATCH (n:N) RETURN CASE WHEN n.age >= 65 THEN 'senior' END AS band",
7510 &BTreeMap::new(),
7511 )
7512 .unwrap();
7513 assert_eq!(rs.get(0, "band"), None);
7514 }
7515
7516 #[test]
7517 fn multi_relationship_type_pattern_matches_either() {
7518 let mut fx = Fx::new();
7519 let a = fx.add("N", "a", vec![("id", s("a"))]);
7520 let b = fx.add("N", "b", vec![("id", s("b"))]);
7521 let c = fx.add("N", "c", vec![("id", s("c"))]);
7522 let d = fx.add("N", "d", vec![("id", s("d"))]);
7523 fx.edge("KNOWS", a, b, vec![]);
7524 fx.edge("LIKES", a, c, vec![]);
7525 fx.edge("HATES", a, d, vec![]);
7526 let v = fx.view();
7527 let rs = run(
7529 &v,
7530 "MATCH (a:N {id: 'a'})-[r:KNOWS|:LIKES]->(x) RETURN x",
7531 &BTreeMap::new(),
7532 )
7533 .unwrap();
7534 assert_eq!(rs.len(), 2, "KNOWS|LIKES reaches exactly b and c");
7535 }
7536
7537 #[test]
7538 fn collect_grouped_gathers_values_per_group() {
7539 let mut fx = Fx::new();
7540 fx.add("P", "a", vec![("city", s("austin")), ("name", s("Ann"))]);
7541 fx.add("P", "b", vec![("city", s("austin")), ("name", s("Bob"))]);
7542 fx.add("P", "c", vec![("city", s("boston")), ("name", s("Cy"))]);
7543 let v = fx.view();
7544 let rs = run(
7545 &v,
7546 "MATCH (n:P) RETURN n.city AS city, collect(n.name) AS names",
7547 &BTreeMap::new(),
7548 )
7549 .unwrap();
7550 assert_eq!(rs.len(), 2, "two city groups");
7551 let austin = (0..rs.len())
7553 .find(|&i| rs.get(i, "city") == Some(&s("austin")))
7554 .expect("austin group present");
7555 assert_eq!(
7556 rs.get(austin, "names"),
7557 Some(&Value::List(vec![s("Ann"), s("Bob")]))
7558 );
7559 }
7560
7561 #[test]
7562 fn collect_ungrouped_gathers_all_into_one_list() {
7563 let mut fx = Fx::new();
7564 fx.add("P", "a", vec![("name", s("Ann"))]);
7565 fx.add("P", "b", vec![("name", s("Bob"))]);
7566 let v = fx.view();
7567 let rs = run(
7568 &v,
7569 "MATCH (n:P) RETURN collect(n.name) AS names",
7570 &BTreeMap::new(),
7571 )
7572 .unwrap();
7573 assert_eq!(rs.len(), 1);
7574 assert_eq!(
7575 rs.get(0, "names"),
7576 Some(&Value::List(vec![s("Ann"), s("Bob")]))
7577 );
7578 }
7579
7580 #[test]
7581 fn string_predicate_functions_in_where() {
7582 let mut fx = Fx::new();
7583 fx.add("N", "a", vec![("email", s("alice@acme.com"))]);
7584 fx.add("N", "b", vec![("email", s("bob@other.org"))]);
7585 let v = fx.view();
7586 let p = BTreeMap::new();
7587 assert_eq!(
7588 run(
7589 &v,
7590 "MATCH (n:N) WHERE endsWith(n.email, '.com') RETURN n",
7591 &p
7592 )
7593 .unwrap()
7594 .len(),
7595 1
7596 );
7597 assert_eq!(
7598 run(
7599 &v,
7600 "MATCH (n:N) WHERE startsWith(n.email, 'bob') RETURN n",
7601 &p
7602 )
7603 .unwrap()
7604 .len(),
7605 1
7606 );
7607 assert_eq!(
7608 run(
7609 &v,
7610 "MATCH (n:N) WHERE contains(n.email, 'acme') RETURN n",
7611 &p
7612 )
7613 .unwrap()
7614 .len(),
7615 1
7616 );
7617 }
7618
7619 #[test]
7620 fn coercion_functions_in_return() {
7621 let mut fx = Fx::new();
7622 fx.add("N", "a", vec![("s", s("42")), ("n", i(7)), ("g", f(3.9))]);
7623 let v = fx.view();
7624 let rs = run(
7625 &v,
7626 "MATCH (n:N) RETURN toInteger(n.s) AS ti, toFloat(n.n) AS tf, toString(n.g) AS ts",
7627 &BTreeMap::new(),
7628 )
7629 .unwrap();
7630 assert_eq!(rs.get(0, "ti"), Some(&Value::Int(42)));
7631 assert_eq!(rs.get(0, "tf"), Some(&Value::Float(7.0)));
7632 assert_eq!(rs.get(0, "ts"), Some(&Value::Str("3.9".into())));
7633 }
7634
7635 #[test]
7636 fn to_integer_unparseable_string_is_null() {
7637 let mut fx = Fx::new();
7638 fx.add("N", "a", vec![("s", s("not-a-number"))]);
7639 let v = fx.view();
7640 let rs = run(
7641 &v,
7642 "MATCH (n:N) RETURN toInteger(n.s) AS ti",
7643 &BTreeMap::new(),
7644 )
7645 .unwrap();
7646 assert_eq!(rs.get(0, "ti"), None);
7647 }
7648
7649 #[test]
7650 fn index_scan_uses_index_and_matches_fallback() {
7651 use core_storage::property_index::PropertyIndex;
7652 use std::sync::atomic::Ordering;
7653
7654 let mut fx = Fx::new();
7655 let a = fx.add("Person", "a", vec![("city", s("austin"))]);
7656 let _b = fx.add("Person", "b", vec![("city", s("boston"))]);
7657 let c = fx.add("Person", "c", vec![("city", s("austin"))]);
7658
7659 let mut pi = PropertyIndex::new();
7660 pi.enable("Person", "city");
7661 pi.set("Person", "city", a, &s("austin"));
7662 pi.set("Person", "city", 1, &s("boston"));
7663 pi.set("Person", "city", c, &s("austin"));
7664
7665 let q = "MATCH (n:Person {city: 'austin'}) RETURN n";
7666
7667 let before = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7669 let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7670 let after = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7671 assert!(after > before, "IndexScan must take the indexed path");
7672 assert_eq!(indexed.len(), 2);
7673
7674 let before2 = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7677 let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7678 let after2 = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7679 assert_eq!(after2, before2, "fallback must not touch the index counter");
7680 assert_eq!(
7681 fallback.len(),
7682 indexed.len(),
7683 "fallback matches indexed result"
7684 );
7685 }
7686
7687 #[test]
7688 fn arithmetic_in_where_comparison() {
7689 let mut fx = Fx::new();
7690 fx.add("Person", "alice", vec![("age", Value::Int(5))]); fx.add("Person", "bob", vec![("age", Value::Int(4))]); let view = fx.view();
7693 let rs = run(
7694 &view,
7695 "MATCH (n:Person) WHERE n.age + 1 > 5 RETURN n",
7696 &BTreeMap::new(),
7697 )
7698 .unwrap();
7699 assert_eq!(rs.len(), 1);
7700 assert_eq!(rs.get(0, "n"), Some(&s("alice")));
7701 }
7702
7703 #[test]
7707 fn where_fold_indexed_matches_fallback() {
7708 use core_storage::property_index::PropertyIndex;
7709 use std::sync::atomic::Ordering;
7710
7711 let mut fx = Fx::new();
7712 let a = fx.add("Person", "alice", vec![("city", s("austin"))]);
7713 let b = fx.add("Person", "bob", vec![("city", s("boston"))]);
7714 let c = fx.add("Person", "carol", vec![("city", s("austin"))]);
7715
7716 let mut pi = PropertyIndex::new();
7717 pi.enable("Person", "city");
7718 pi.set("Person", "city", a, &s("austin"));
7719 pi.set("Person", "city", b, &s("boston"));
7720 pi.set("Person", "city", c, &s("austin"));
7721
7722 let q = "MATCH (n:Person) WHERE n.city = 'austin' RETURN n";
7723
7724 let before = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7726 let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7727 let after = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7728 assert!(
7729 after > before,
7730 "WHERE equality fold must take the indexed path"
7731 );
7732 assert_eq!(indexed.len(), 2);
7733
7734 let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7736 assert_eq!(
7737 rows_of(&fallback),
7738 rows_of(&indexed),
7739 "fallback must return identical rows, not just the same count"
7740 );
7741 }
7742
7743 #[test]
7745 fn where_fold_miss_returns_empty() {
7746 let mut fx = Fx::new();
7747 fx.add("Person", "alice", vec![("city", s("austin"))]);
7748 let q = "MATCH (n:Person) WHERE n.city = 'berlin' RETURN n";
7749 let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7750 assert_eq!(rs.len(), 0);
7751 }
7752
7753 #[test]
7755 fn where_fold_param_uses_index() {
7756 use core_storage::property_index::PropertyIndex;
7757 use std::sync::atomic::Ordering;
7758
7759 let mut fx = Fx::new();
7760 let a = fx.add("Person", "alice", vec![("city", s("austin"))]);
7761 let b = fx.add("Person", "bob", vec![("city", s("boston"))]);
7762
7763 let mut pi = PropertyIndex::new();
7764 pi.enable("Person", "city");
7765 pi.set("Person", "city", a, &s("austin"));
7766 pi.set("Person", "city", b, &s("boston"));
7767
7768 let q = "MATCH (n:Person) WHERE n.city = $c RETURN n";
7769 let mut params = BTreeMap::new();
7770 params.insert("c".to_string(), s("austin"));
7771
7772 let before = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7773 let rs = run(&fx.view_indexed(&pi), q, ¶ms).unwrap();
7774 let after = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7775 assert!(after > before, "$param WHERE equality must use index");
7776 assert_eq!(rs.len(), 1);
7777 }
7778
7779 #[test]
7781 fn where_fold_residual_filter_applied() {
7782 let mut fx = Fx::new();
7783 fx.add(
7784 "Person",
7785 "young-austin",
7786 vec![("city", s("austin")), ("age", Value::Int(20))],
7787 );
7788 fx.add(
7789 "Person",
7790 "old-austin",
7791 vec![("city", s("austin")), ("age", Value::Int(40))],
7792 );
7793 fx.add(
7794 "Person",
7795 "boston",
7796 vec![("city", s("boston")), ("age", Value::Int(20))],
7797 );
7798
7799 let q = "MATCH (n:Person) WHERE n.city = 'austin' AND n.age > 30 RETURN n";
7800 let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7801 assert_eq!(rs.len(), 1, "only old-austin should match city+age filter");
7802 assert_eq!(rs.get(0, "n"), Some(&s("old-austin")));
7803 }
7804
7805 #[test]
7811 fn where_fold_unindexed_field_fallback() {
7812 let mut fx = Fx::new();
7813 fx.add("Person", "a", vec![("notindexed", s("x"))]);
7814 fx.add("Person", "b", vec![("notindexed", s("y"))]);
7815 let rs = run(
7816 &fx.view(),
7817 "MATCH (n:Person) WHERE n.notindexed = 'x' RETURN n",
7818 &BTreeMap::new(),
7819 )
7820 .unwrap();
7821 assert_eq!(rs.len(), 1);
7822 assert_eq!(rs.get(0, "n"), Some(&s("a")));
7823 }
7824
7825 #[test]
7829 fn index_intersect_both_indexed_fires() {
7830 use core_storage::property_index::PropertyIndex;
7831 use std::sync::atomic::Ordering;
7832
7833 let mut fx = Fx::new();
7834 let a = fx.add(
7836 "Person",
7837 "alice",
7838 vec![("city", s("austin")), ("age", Value::Int(30))],
7839 );
7840 let b = fx.add(
7842 "Person",
7843 "bob",
7844 vec![("city", s("austin")), ("age", Value::Int(25))],
7845 );
7846 let c = fx.add(
7848 "Person",
7849 "carol",
7850 vec![("city", s("boston")), ("age", Value::Int(30))],
7851 );
7852
7853 let mut pi = PropertyIndex::new();
7854 pi.enable("Person", "city");
7855 pi.enable("Person", "age");
7856 pi.set("Person", "city", a, &s("austin"));
7857 pi.set("Person", "city", b, &s("austin"));
7858 pi.set("Person", "city", c, &s("boston"));
7859 pi.set("Person", "age", a, &Value::Int(30));
7860 pi.set("Person", "age", b, &Value::Int(25));
7861 pi.set("Person", "age", c, &Value::Int(30));
7862
7863 let q = "MATCH (n:Person) WHERE n.city = 'austin' AND n.age = 30 RETURN n";
7864
7865 let before = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7866 let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7867 let after = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7868 assert!(
7869 after > before,
7870 "IndexIntersect must advance counter on indexed path"
7871 );
7872 assert_eq!(indexed.len(), 1);
7873 assert_eq!(indexed.get(0, "n"), Some(&s("alice")));
7874
7875 let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7877 assert_eq!(
7878 rows_of(&fallback),
7879 rows_of(&indexed),
7880 "fallback must return identical rows"
7881 );
7882 }
7883
7884 #[test]
7886 fn index_intersect_one_indexed_one_not() {
7887 use core_storage::property_index::PropertyIndex;
7888 use std::sync::atomic::Ordering;
7889
7890 let mut fx = Fx::new();
7891 let a = fx.add(
7892 "Person",
7893 "alice",
7894 vec![("city", s("austin")), ("role", s("eng"))],
7895 );
7896 let b = fx.add(
7897 "Person",
7898 "bob",
7899 vec![("city", s("austin")), ("role", s("mgr"))],
7900 );
7901 let _c = fx.add(
7902 "Person",
7903 "carol",
7904 vec![("city", s("boston")), ("role", s("eng"))],
7905 );
7906
7907 let mut pi = PropertyIndex::new();
7909 pi.enable("Person", "city");
7910 pi.set("Person", "city", a, &s("austin"));
7911 pi.set("Person", "city", b, &s("austin"));
7912
7913 let q = "MATCH (n:Person) WHERE n.city = 'austin' AND n.role = 'eng' RETURN n";
7914
7915 let before = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7916 let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7917 let after = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7918 assert!(
7919 after > before,
7920 "IndexIntersect must fire when at least one field is indexed"
7921 );
7922 assert_eq!(indexed.len(), 1);
7923 assert_eq!(indexed.get(0, "n"), Some(&s("alice")));
7924
7925 let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7926 assert_eq!(
7927 rows_of(&fallback),
7928 rows_of(&indexed),
7929 "fallback must return identical rows"
7930 );
7931 }
7932
7933 #[test]
7938 fn index_intersect_no_indexed_fallback() {
7939 let mut fx = Fx::new();
7940 fx.add("Person", "alice", vec![("x", s("1")), ("y", s("a"))]);
7941 fx.add("Person", "bob", vec![("x", s("1")), ("y", s("b"))]);
7942 fx.add("Person", "carol", vec![("x", s("2")), ("y", s("a"))]);
7943
7944 let q = "MATCH (n:Person) WHERE n.x = '1' AND n.y = 'a' RETURN n";
7945 let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7946 assert_eq!(rs.len(), 1);
7947 assert_eq!(rs.get(0, "n"), Some(&s("alice")));
7948 }
7949
7950 #[test]
7952 fn index_intersect_empty_intersection() {
7953 use core_storage::property_index::PropertyIndex;
7954
7955 let mut fx = Fx::new();
7956 let a = fx.add(
7957 "Person",
7958 "alice",
7959 vec![("city", s("austin")), ("age", Value::Int(30))],
7960 );
7961 let b = fx.add(
7962 "Person",
7963 "bob",
7964 vec![("city", s("boston")), ("age", Value::Int(25))],
7965 );
7966
7967 let mut pi = PropertyIndex::new();
7968 pi.enable("Person", "city");
7969 pi.enable("Person", "age");
7970 pi.set("Person", "city", a, &s("austin"));
7971 pi.set("Person", "city", b, &s("boston"));
7972 pi.set("Person", "age", a, &Value::Int(30));
7973 pi.set("Person", "age", b, &Value::Int(25));
7974
7975 let q = "MATCH (n:Person) WHERE n.city = 'boston' AND n.age = 30 RETURN n";
7977 let rs = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7978 assert_eq!(rs.len(), 0);
7979 }
7980
7981 #[test]
7983 fn index_intersect_with_params() {
7984 use core_storage::property_index::PropertyIndex;
7985 use std::sync::atomic::Ordering;
7986
7987 let mut fx = Fx::new();
7988 let a = fx.add(
7989 "Person",
7990 "alice",
7991 vec![("city", s("austin")), ("age", Value::Int(30))],
7992 );
7993 let b = fx.add(
7994 "Person",
7995 "bob",
7996 vec![("city", s("boston")), ("age", Value::Int(30))],
7997 );
7998
7999 let mut pi = PropertyIndex::new();
8000 pi.enable("Person", "city");
8001 pi.enable("Person", "age");
8002 pi.set("Person", "city", a, &s("austin"));
8003 pi.set("Person", "city", b, &s("boston"));
8004 pi.set("Person", "age", a, &Value::Int(30));
8005 pi.set("Person", "age", b, &Value::Int(30));
8006
8007 let q = "MATCH (n:Person) WHERE n.city = $c AND n.age = $a RETURN n";
8008 let mut params = BTreeMap::new();
8009 params.insert("c".to_string(), s("austin"));
8010 params.insert("a".to_string(), Value::Int(30));
8011
8012 let before = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
8013 let indexed = run(&fx.view_indexed(&pi), q, ¶ms).unwrap();
8014 let after = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
8015 assert!(after > before, "$param intersect must fire indexed path");
8016 assert_eq!(indexed.len(), 1);
8017 assert_eq!(indexed.get(0, "n"), Some(&s("alice")));
8018
8019 let fallback = run(&fx.view(), q, ¶ms).unwrap();
8020 assert_eq!(
8021 rows_of(&fallback),
8022 rows_of(&indexed),
8023 "fallback must return identical rows"
8024 );
8025 }
8026
8027 #[test]
8029 fn index_intersect_three_fields() {
8030 use core_storage::property_index::PropertyIndex;
8031
8032 let mut fx = Fx::new();
8033 let a = fx.add(
8035 "Person",
8036 "alice",
8037 vec![
8038 ("city", s("austin")),
8039 ("age", Value::Int(30)),
8040 ("role", s("eng")),
8041 ],
8042 );
8043 let b = fx.add(
8045 "Person",
8046 "bob",
8047 vec![
8048 ("city", s("austin")),
8049 ("age", Value::Int(30)),
8050 ("role", s("mgr")),
8051 ],
8052 );
8053
8054 let mut pi = PropertyIndex::new();
8055 pi.enable("Person", "city");
8056 pi.enable("Person", "age");
8057 pi.set("Person", "city", a, &s("austin"));
8059 pi.set("Person", "city", b, &s("austin"));
8060 pi.set("Person", "age", a, &Value::Int(30));
8061 pi.set("Person", "age", b, &Value::Int(30));
8062
8063 let q =
8064 "MATCH (n:Person) WHERE n.city = 'austin' AND n.age = 30 AND n.role = 'eng' RETURN n";
8065 let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
8066 assert_eq!(indexed.len(), 1);
8067 assert_eq!(indexed.get(0, "n"), Some(&s("alice")));
8068
8069 let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
8070 assert_eq!(
8071 rows_of(&fallback),
8072 rows_of(&indexed),
8073 "fallback must return identical rows"
8074 );
8075 }
8076 fn assoc_graph() -> Fx {
8089 let mut fx = Fx::new();
8090 let t1 = fx.add(
8091 "Talent",
8092 "t1",
8093 vec![
8094 ("status", s("published")),
8095 ("years_of_experience", i(12)),
8096 (
8097 "specialties",
8098 Value::List(vec![s("hospitality"), s("retail")]),
8099 ),
8100 ("location", Value::List(vec![f(40.71), f(-74.01)])),
8101 ],
8102 );
8103 let t2 = fx.add(
8104 "Talent",
8105 "t2",
8106 vec![
8107 ("status", s("published")),
8108 ("years_of_experience", i(11)),
8109 ("location", Value::List(vec![f(41.88), f(-87.63)])),
8110 ],
8111 );
8112 let t3 = fx.add(
8113 "Talent",
8114 "t3",
8115 vec![("status", s("published")), ("years_of_experience", i(3))],
8116 );
8117 let c1 = fx.add("Company", "c1", vec![("name", s("Acme Design Works"))]);
8118 let c2 = fx.add("Company", "c2", vec![("name", s("Beta Studio"))]);
8119 let c3 = fx.add("Company", "c3", vec![("name", s("Gamma Works"))]);
8120 for (t, c) in [(t1, c1), (t2, c1), (t3, c3)] {
8121 fx.edge("INDUSTRY_ALIGNMENT", t, c, vec![]);
8122 fx.edge("SPECIALTY_MATCH", t, c, vec![]);
8123 fx.edge("LOCATION_FIT", t, c, vec![]);
8124 }
8125 fx.edge("INDUSTRY_ALIGNMENT", t1, c2, vec![]);
8127 fx.edge("SPECIALTY_MATCH", t1, c2, vec![]);
8128 fx
8129 }
8130
8131 #[test]
8134 fn node_key_reads_as_a_property() {
8135 let fx = assoc_graph();
8136 let rs = run(
8137 &fx.view(),
8138 "MATCH (n:Company) RETURN n.key",
8139 &BTreeMap::new(),
8140 )
8141 .unwrap();
8142 assert_eq!(
8143 rows_of(&rs),
8144 vec![
8145 vec![Some(s("c1"))],
8146 vec![Some(s("c2"))],
8147 vec![Some(s("c3"))]
8148 ]
8149 );
8150 }
8151
8152 #[test]
8156 fn node_key_survives_a_with_aggregation() {
8157 let fx = assoc_graph();
8158 let rs = run(
8159 &fx.view(),
8160 "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company) \
8161 WITH c, count(*) AS n WHERE n >= 1 RETURN c.key, key(c), n",
8162 &BTreeMap::new(),
8163 )
8164 .unwrap();
8165 assert_eq!(
8166 rows_of(&rs),
8167 vec![
8168 vec![Some(s("c1")), Some(s("c1")), Some(i(2))],
8169 vec![Some(s("c2")), Some(s("c2")), Some(i(1))],
8170 vec![Some(s("c3")), Some(s("c3")), Some(i(1))],
8171 ]
8172 );
8173 }
8174
8175 #[test]
8177 fn stored_key_property_wins_over_node_key() {
8178 let mut fx = Fx::new();
8179 fx.add("N", "a", vec![("key", s("stored"))]);
8180 let rs = run(&fx.view(), "MATCH (n:N) RETURN n.key", &BTreeMap::new()).unwrap();
8181 assert_eq!(rows_of(&rs), vec![vec![Some(s("stored"))]]);
8182 }
8183
8184 #[test]
8186 fn n_id_falls_back_to_key_when_unstored() {
8187 let mut fx = Fx::new();
8188 fx.add("Person", "alice", vec![]);
8189 let rs = run(
8190 &fx.view(),
8191 "MATCH (n:Person) WHERE n.id = 'alice' RETURN n.id, id(n), n.key",
8192 &BTreeMap::new(),
8193 )
8194 .unwrap();
8195 assert_eq!(
8196 rows_of(&rs),
8197 vec![vec![Some(s("alice")), Some(s("alice")), Some(s("alice"))]]
8198 );
8199 }
8200
8201 #[test]
8209 fn stored_id_property_wins_over_key() {
8210 let mut fx = Fx::new();
8211 fx.add("N", "k", vec![("id", s("other"))]);
8212 fx.add("N", "fallback", vec![]);
8213 let v = fx.view();
8214 let by_key = run(
8215 &v,
8216 "MATCH (n:N) WHERE n.id = 'fallback' RETURN key(n) AS k",
8217 &BTreeMap::new(),
8218 )
8219 .unwrap();
8220 assert_eq!(
8221 col(&by_key, "k"),
8222 vec![Some(s("fallback"))],
8223 "a node with no stored id is found by its key, and the stored-id \
8224 node is not swept in with it"
8225 );
8226 let miss = run(
8227 &v,
8228 "MATCH (n:N) WHERE n.id = 'k' RETURN n.id",
8229 &BTreeMap::new(),
8230 )
8231 .unwrap();
8232 assert!(
8233 miss.is_empty(),
8234 "stored id must win: WHERE n.id = key is empty"
8235 );
8236 let hit = run(
8237 &v,
8238 "MATCH (n:N) WHERE n.id = 'other' RETURN n.id",
8239 &BTreeMap::new(),
8240 )
8241 .unwrap();
8242 assert_eq!(rows_of(&hit), vec![vec![Some(s("other"))]]);
8243 let projected = run(&v, "MATCH (n:N) RETURN n.id AS i", &BTreeMap::new()).unwrap();
8244 let mut got: Vec<String> = col(&projected, "i")
8245 .into_iter()
8246 .map(|v| match v {
8247 Some(Value::Str(s)) => s,
8248 other => panic!("expected a string, got {other:?}"),
8249 })
8250 .collect();
8251 got.sort();
8252 assert_eq!(
8253 got,
8254 vec!["fallback".to_string(), "other".to_string()],
8255 "projection is stored-wins per node: the stored id for one, the \
8256 key fallback for the other"
8257 );
8258 }
8259
8260 #[test]
8262 fn id_function_rejects_non_node() {
8263 let (fx, _, _) = single_edge();
8264 let v = fx.view();
8265 let key_err = run(&v, "MATCH (a)-[r:T]->(b) RETURN key(r)", &BTreeMap::new())
8266 .expect_err("key() on a relationship must error");
8267 let id_err = run(&v, "MATCH (a)-[r:T]->(b) RETURN id(r)", &BTreeMap::new())
8268 .expect_err("id() on a relationship must error");
8269 assert!(
8270 key_err.contains("not a node"),
8271 "key() error class: {key_err}"
8272 );
8273 assert!(id_err.contains("not a node"), "id() error class: {id_err}");
8274 assert!(
8275 id_err.contains("id()"),
8276 "id() error must name itself: {id_err}"
8277 );
8278 }
8279
8280 #[test]
8282 fn where_n_id_eq_literal_uses_scan_key() {
8283 let mut fx = Fx::new();
8284 fx.add("Person", "alice", vec![]);
8285 let fires_before = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
8286 let rs = run(
8287 &fx.view(),
8288 "MATCH (n:Person) WHERE n.id = 'alice' RETURN n",
8289 &BTreeMap::new(),
8290 )
8291 .unwrap();
8292 let fires_after = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
8293 assert!(
8294 fires_after > fires_before,
8295 "SCAN_KEY_FIRES must increment; before={fires_before} after={fires_after}"
8296 );
8297 assert_eq!(rows_of(&rs), vec![vec![Some(s("alice"))]]);
8298 }
8299
8300 #[test]
8302 fn where_n_key_eq_param_uses_scan_key() {
8303 let mut fx = Fx::new();
8304 fx.add("Person", "alice", vec![]);
8305 let mut params = BTreeMap::new();
8306 params.insert("k".to_string(), s("alice"));
8307 let fires_before = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
8308 let rs = run(
8309 &fx.view(),
8310 "MATCH (n:Person) WHERE n.key = $k RETURN n",
8311 ¶ms,
8312 )
8313 .unwrap();
8314 let fires_after = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
8315 assert!(
8316 fires_after > fires_before,
8317 "SCAN_KEY_FIRES must increment; before={fires_before} after={fires_after}"
8318 );
8319 assert_eq!(rows_of(&rs), vec![vec![Some(s("alice"))]]);
8320 }
8321
8322 #[test]
8327 fn where_key_func_ignores_a_stored_key_property() {
8328 let mut fx = Fx::new();
8329 fx.add("N", "a", vec![("key", s("K"))]);
8330 fx.add("N", "K", vec![]);
8331 let rs = run(
8332 &fx.view(),
8333 "MATCH (n:N) WHERE key(n) = 'K' RETURN key(n) AS k",
8334 &BTreeMap::new(),
8335 )
8336 .unwrap();
8337 let got: Vec<Option<Value>> = col(&rs, "k");
8338 assert_eq!(
8339 got,
8340 vec![Some(s("K"))],
8341 "key(n) is the id-map key: only the node keyed K matches, not the \
8342 node whose stored `key` property is K"
8343 );
8344 }
8345
8346 #[test]
8349 fn where_id_func_ignores_a_stored_id_property() {
8350 let mut fx = Fx::new();
8351 fx.add("N", "k", vec![("id", s("other"))]);
8352 let rs = run(
8353 &fx.view(),
8354 "MATCH (n:N) WHERE id(n) = 'k' RETURN id(n) AS k",
8355 &BTreeMap::new(),
8356 )
8357 .unwrap();
8358 assert_eq!(
8359 col(&rs, "k"),
8360 vec![Some(s("k"))],
8361 "id(n) is the id-map key: a stored `id` property must not suppress \
8362 the match"
8363 );
8364 }
8365
8366 #[test]
8370 fn where_n_id_respects_stored_wins() {
8371 let mut fx = Fx::new();
8372 fx.add("N", "k", vec![("id", s("other"))]);
8373 fx.add("N", "other", vec![]);
8374 let rs = run(
8375 &fx.view(),
8376 "MATCH (n:N) WHERE n.id = 'other' RETURN key(n) AS k",
8377 &BTreeMap::new(),
8378 )
8379 .unwrap();
8380 let mut got: Vec<String> = col(&rs, "k")
8381 .into_iter()
8382 .map(|v| match v {
8383 Some(Value::Str(s)) => s,
8384 other => panic!("expected a string key, got {other:?}"),
8385 })
8386 .collect();
8387 got.sort();
8388 assert_eq!(
8389 got,
8390 vec!["k".to_string(), "other".to_string()],
8391 "stored-wins union: the stored-id node and the key-fallback node"
8392 );
8393 }
8394
8395 #[test]
8398 fn labels_and_label_read_the_node_label() {
8399 let fx = assoc_graph();
8400 let rs = run(
8401 &fx.view(),
8402 "MATCH (n:Company) RETURN labels(n), n.label LIMIT 1",
8403 &BTreeMap::new(),
8404 )
8405 .unwrap();
8406 assert_eq!(
8407 rows_of(&rs),
8408 vec![vec![
8409 Some(Value::List(vec![s("Company")])),
8410 Some(s("Company"))
8411 ]]
8412 );
8413 }
8414
8415 #[test]
8417 fn unknown_function_is_a_named_error() {
8418 let fx = assoc_graph();
8419 let err = run(
8420 &fx.view(),
8421 "MATCH (n:Company) RETURN nodes(n)",
8422 &BTreeMap::new(),
8423 )
8424 .expect_err("unknown function must error");
8425 assert!(err.contains("unknown function `nodes`"), "{err}");
8426 assert!(
8427 err.contains("labels"),
8428 "error must list what is supported: {err}"
8429 );
8430 }
8431
8432 #[test]
8435 fn infix_string_predicates_filter() {
8436 let fx = assoc_graph();
8437 let p = BTreeMap::new();
8438 for (q, want) in [
8439 (
8440 "MATCH (c:Company) WHERE c.name STARTS WITH 'Acme' RETURN c.key",
8441 vec!["c1"],
8442 ),
8443 (
8444 "MATCH (c:Company) WHERE c.name ENDS WITH 'Works' RETURN c.key",
8445 vec!["c1", "c3"],
8446 ),
8447 (
8448 "MATCH (c:Company) WHERE c.name CONTAINS 'Studio' RETURN c.key",
8449 vec!["c2"],
8450 ),
8451 (
8452 "MATCH (c:Company) WHERE NOT c.name CONTAINS 'Works' RETURN c.key",
8453 vec!["c2"],
8454 ),
8455 ] {
8456 let rs = run(&fx.view(), q, &p).unwrap_or_else(|e| panic!("{q}: {e}"));
8457 let got: Vec<String> = (0..rs.len())
8458 .map(|r| match rs.row(r)[0].clone() {
8459 Some(Value::Str(k)) => k,
8460 other => panic!("{q}: {other:?}"),
8461 })
8462 .collect();
8463 assert_eq!(got, want, "{q}");
8464 }
8465 }
8466
8467 #[test]
8470 fn infix_string_predicate_on_missing_property_is_false() {
8471 let fx = assoc_graph();
8472 let rs = run(
8473 &fx.view(),
8474 "MATCH (t:Talent) WHERE t.name STARTS WITH 'x' RETURN t.key",
8475 &BTreeMap::new(),
8476 )
8477 .unwrap();
8478 assert_eq!(rows_of(&rs), Vec::<Vec<Option<Value>>>::new());
8479 }
8480
8481 #[test]
8484 fn starts_without_with_is_a_named_error() {
8485 let err = parse(&lex("MATCH (c:Company) WHERE c.name STARTS 'Acme' RETURN c").unwrap())
8486 .expect_err("must not parse");
8487 assert!(err.contains("expected WITH after STARTS"), "{err}");
8488 }
8489
8490 #[test]
8493 fn list_subscript_reads_one_element() {
8494 let fx = assoc_graph();
8495 let rs = run(
8496 &fx.view(),
8497 "MATCH (t:Talent) WHERE t.key = 't1' \
8498 RETURN t.location[0] AS lat, t.location[1] AS lon, \
8499 t.location[-1] AS last, t.location[7] AS oob, t.status[0] AS notalist",
8500 &BTreeMap::new(),
8501 )
8502 .unwrap();
8503 assert_eq!(
8504 rows_of(&rs),
8505 vec![vec![
8506 Some(f(40.71)),
8507 Some(f(-74.01)),
8508 Some(f(-74.01)),
8509 None,
8510 None
8511 ]]
8512 );
8513 }
8514
8515 #[test]
8519 fn node_key_works_in_every_position() {
8520 let fx = assoc_graph();
8521 let p = BTreeMap::new();
8522 for (q, want) in [
8523 (
8524 "MATCH (c:Company) WHERE c.key = 'c2' RETURN c.key",
8525 vec!["c2"],
8526 ),
8527 (
8528 "MATCH (c:Company) WHERE c.key <> 'c1' RETURN c.key LIMIT 1",
8529 vec!["c2"],
8530 ),
8531 (
8532 "MATCH (c:Company) WITH c ORDER BY c.key DESC RETURN c.key LIMIT 1",
8533 vec!["c3"],
8534 ),
8535 ("MATCH (c:Company {key: 'c3'}) RETURN c.key", vec!["c3"]),
8536 (
8537 "MATCH (c:Company) WHERE c.label = 'Company' RETURN c.key LIMIT 1",
8538 vec!["c1"],
8539 ),
8540 ] {
8541 let rs = run(&fx.view(), q, &p).unwrap_or_else(|e| panic!("{q}: {e}"));
8542 let got: Vec<String> = (0..rs.len())
8543 .map(|r| match rs.row(r)[0].clone() {
8544 Some(Value::Str(k)) => k,
8545 other => panic!("{q}: {other:?}"),
8546 })
8547 .collect();
8548 assert_eq!(got, want, "{q}");
8549 }
8550 }
8551
8552 #[test]
8554 fn list_subscript_filters() {
8555 let fx = assoc_graph();
8556 let rs = run(
8557 &fx.view(),
8558 "MATCH (t:Talent) WHERE t.location[0] > 41.0 RETURN t.key",
8559 &BTreeMap::new(),
8560 )
8561 .unwrap();
8562 assert_eq!(rows_of(&rs), vec![vec![Some(s("t2"))]]);
8563 }
8564
8565 #[test]
8569 fn count_distinct_counts_each_binding_once() {
8570 let fx = assoc_graph();
8571 let q = "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT|:SPECIALTY_MATCH]->(c:Company) \
8572 WITH c, count(t) AS raw, count(DISTINCT t) AS uniq WHERE raw >= 1 \
8573 RETURN c.key, raw, uniq";
8574 let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
8575 assert_eq!(
8576 rows_of(&rs),
8577 vec![
8578 vec![Some(s("c1")), Some(i(4)), Some(i(2))],
8579 vec![Some(s("c2")), Some(i(2)), Some(i(1))],
8580 vec![Some(s("c3")), Some(i(2)), Some(i(1))],
8581 ]
8582 );
8583 }
8584
8585 #[test]
8595 fn count_distinct_on_a_relationship_counts_edges() {
8596 let fx = assoc_graph();
8597 let rs = run(
8598 &fx.view(),
8599 "MATCH (a)-[r]->(b) RETURN count(r) AS raw, count(DISTINCT r) AS uniq",
8600 &BTreeMap::new(),
8601 )
8602 .unwrap();
8603 let row = &rows_of(&rs)[0];
8604 assert_eq!(
8605 row[0], row[1],
8606 "no pair is joined twice by one type: {row:?}"
8607 );
8608 assert_ne!(row[1], Some(i(0)), "a graph full of edges counts them");
8609 }
8610
8611 #[test]
8615 fn count_distinct_on_a_relationship_survives_an_alternation() {
8616 let fx = assoc_graph();
8617 let rs = run(
8618 &fx.view(),
8619 "MATCH (t:Talent)-[r:INDUSTRY_ALIGNMENT|:SPECIALTY_MATCH]->(c:Company) \
8620 RETURN count(r) AS raw, count(DISTINCT r) AS uniq",
8621 &BTreeMap::new(),
8622 )
8623 .unwrap();
8624 let row = &rows_of(&rs)[0];
8625 assert_eq!(row[0], row[1], "every row bound a different edge: {row:?}");
8626 }
8627
8628 #[test]
8630 fn collect_distinct_dedupes() {
8631 let fx = assoc_graph();
8632 let rs = run(
8633 &fx.view(),
8634 "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT|:SPECIALTY_MATCH]->(c:Company) \
8635 WHERE c.key = 'c1' WITH collect(DISTINCT t.status) AS st RETURN st",
8636 &BTreeMap::new(),
8637 )
8638 .unwrap();
8639 assert_eq!(
8640 rows_of(&rs),
8641 vec![vec![Some(Value::List(vec![s("published")]))]]
8642 );
8643 }
8644
8645 #[test]
8647 fn count_distinct_star_is_rejected() {
8648 let err =
8649 parse(&lex("MATCH (n) RETURN count(DISTINCT *)").unwrap()).expect_err("must not parse");
8650 assert!(
8651 err.contains("DISTINCT * is not a valid aggregate argument"),
8652 "{err}"
8653 );
8654 }
8655
8656 #[test]
8658 fn distinct_is_still_usable_as_a_variable_name() {
8659 let q = parse(&lex("MATCH (distinct) RETURN count(distinct)").unwrap()).unwrap();
8660 assert_eq!(
8661 q.returns[0].value,
8662 RetVal::Agg {
8663 func: crate::cypher::ast::AggFunc::Count,
8664 arg: crate::cypher::ast::AggArg::Var("distinct".into()),
8665 }
8666 );
8667 }
8668
8669 #[test]
8673 fn comma_patterns_intersect_on_shared_variables() {
8674 let fx = assoc_graph();
8675 let q = "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company), \
8676 (t)-[:SPECIALTY_MATCH]->(c), \
8677 (t)-[:LOCATION_FIT]->(c) \
8678 WHERE t.status = 'published' AND t.years_of_experience >= 10 \
8679 WITH c, count(DISTINCT t) AS n WHERE n >= 2 \
8680 RETURN c.key, n ORDER BY n DESC";
8681 let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
8682 assert_eq!(rows_of(&rs), vec![vec![Some(s("c1")), Some(i(2))]]);
8683 }
8684
8685 #[test]
8689 fn comma_patterns_exclude_partial_matches() {
8690 let fx = assoc_graph();
8691 let q = "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company), \
8692 (t)-[:SPECIALTY_MATCH]->(c), \
8693 (t)-[:LOCATION_FIT]->(c) \
8694 WHERE t.years_of_experience >= 10 \
8695 WITH c, count(DISTINCT t) AS n RETURN c.key, n";
8696 let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
8697 assert_eq!(rows_of(&rs), vec![vec![Some(s("c1")), Some(i(2))]]);
8698 }
8699
8700 #[test]
8703 fn comma_patterns_equal_separate_match_clauses() {
8704 let commas =
8705 parse(&lex("MATCH (a:A)-[:X]->(b:B), (a)-[:Y]->(b) RETURN a.key").unwrap()).unwrap();
8706 let clauses =
8707 parse(&lex("MATCH (a:A)-[:X]->(b:B) MATCH (a)-[:Y]->(b) RETURN a.key").unwrap())
8708 .unwrap();
8709 assert_eq!(commas.matches, clauses.matches);
8710 }
8711
8712 #[test]
8724 fn aggregate_with_projects_without_a_having_clause() {
8725 let fx = assoc_graph();
8726 let rs = run(
8727 &fx.view(),
8728 "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company) \
8729 WITH c, count(t) AS n RETURN c.name, n * 2 AS dbl",
8730 &BTreeMap::new(),
8731 )
8732 .unwrap();
8733 assert_eq!(rs.columns(), ["c.name", "dbl"]);
8734 assert_eq!(
8735 rows_of(&rs),
8736 vec![
8737 vec![Some(s("Acme Design Works")), Some(i(4))],
8738 vec![Some(s("Beta Studio")), Some(i(2))],
8739 vec![Some(s("Gamma Works")), Some(i(2))],
8740 ]
8741 );
8742 }
8743
8744 #[test]
8747 fn aggregate_with_names_its_projected_columns() {
8748 let fx = assoc_graph();
8749 let rs = run(
8750 &fx.view(),
8751 "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company) \
8752 WITH c, count(t) AS n RETURN key(c), n",
8753 &BTreeMap::new(),
8754 )
8755 .unwrap();
8756 assert_eq!(rs.columns(), ["key(c)", "n"]);
8757 assert_eq!(
8758 rows_of(&rs),
8759 vec![
8760 vec![Some(s("c1")), Some(i(2))],
8761 vec![Some(s("c2")), Some(i(1))],
8762 vec![Some(s("c3")), Some(i(1))],
8763 ]
8764 );
8765 }
8766
8767 #[test]
8770 fn aggregate_with_returning_the_bare_node_keeps_the_key() {
8771 let fx = assoc_graph();
8772 let rs = run(
8773 &fx.view(),
8774 "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company) \
8775 WITH c, count(t) AS n RETURN c",
8776 &BTreeMap::new(),
8777 )
8778 .unwrap();
8779 assert_eq!(rs.columns(), ["c"]);
8780 assert_eq!(
8781 rows_of(&rs),
8782 vec![
8783 vec![Some(s("c1"))],
8784 vec![Some(s("c2"))],
8785 vec![Some(s("c3"))]
8786 ]
8787 );
8788 }
8789
8790 #[test]
8793 fn a_plain_aggregate_is_unchanged() {
8794 let fx = assoc_graph();
8795 let rs = run(
8796 &fx.view(),
8797 "MATCH (c:Company) RETURN c.name, count(*)",
8798 &BTreeMap::new(),
8799 )
8800 .unwrap();
8801 assert_eq!(rs.columns(), ["c.name", "COUNT(*)"]);
8802 assert_eq!(rs.len(), 3);
8803 }
8804
8805 #[test]
8809 fn two_subscripts_of_one_list_are_two_named_columns() {
8810 let fx = assoc_graph();
8811 let rs = run(
8812 &fx.view(),
8813 "MATCH (t:Talent) RETURN t.location[0], t.location[1]",
8814 &BTreeMap::new(),
8815 )
8816 .unwrap();
8817 assert_eq!(rs.columns(), ["t.location[0]", "t.location[1]"]);
8818 assert_eq!(
8819 rows_of(&rs),
8820 vec![
8821 vec![Some(f(40.71)), Some(f(-74.01))],
8822 vec![Some(f(41.88)), Some(f(-87.63))],
8823 vec![None, None],
8825 ]
8826 );
8827 }
8828
8829 #[test]
8833 fn a_subscript_carries_through_a_with_stage() {
8834 let fx = assoc_graph();
8835 let rs = run(
8836 &fx.view(),
8837 "MATCH (t:Talent) WHERE t.location[0] > 41.0 \
8838 WITH t, t.location[0] AS lat, t.location[1] \
8839 RETURN key(t), lat, t.location[1]",
8840 &BTreeMap::new(),
8841 )
8842 .unwrap();
8843 assert_eq!(rs.columns(), ["key(t)", "lat", "t.location[1]"]);
8844 assert_eq!(
8845 rows_of(&rs),
8846 vec![vec![Some(s("t2")), Some(f(41.88)), Some(f(-87.63))]]
8847 );
8848 }
8849
8850 #[test]
8853 fn subscript_column_names_cover_nested_and_computed_forms() {
8854 use crate::cypher::ast::operand_label;
8855 let prop = || Operand::Prop {
8856 var: "t".into(),
8857 field: "location".into(),
8858 };
8859 let at = |idx: Operand| Operand::Index {
8860 base: Box::new(prop()),
8861 index: Box::new(idx),
8862 };
8863 assert_eq!(operand_label(&at(Operand::Lit(i(0)))), "t.location[0]");
8864 assert_eq!(operand_label(&at(Operand::Lit(i(-1)))), "t.location[-1]");
8865 assert_eq!(
8866 operand_label(&at(Operand::Param("k".into()))),
8867 "t.location[$k]"
8868 );
8869 assert_eq!(
8870 operand_label(&at(Operand::Var("j".into()))),
8871 "t.location[j]"
8872 );
8873 assert_eq!(
8874 operand_label(&Operand::Index {
8875 base: Box::new(at(Operand::Lit(i(0)))),
8876 index: Box::new(Operand::Lit(i(1))),
8877 }),
8878 "t.location[0][1]"
8879 );
8880 }
8881
8882 #[test]
8891 fn a_with_alias_is_visible_to_its_where() {
8892 let fx = assoc_graph();
8893 let rs = run(
8894 &fx.view(),
8895 "MATCH (t:Talent) WITH t, t.years_of_experience AS x WHERE x > 11 \
8896 RETURN key(t), x",
8897 &BTreeMap::new(),
8898 )
8899 .unwrap();
8900 assert_eq!(rs.columns(), ["key(t)", "x"]);
8901 assert_eq!(rows_of(&rs), vec![vec![Some(s("t1")), Some(i(12))]]);
8902 }
8903
8904 #[test]
8906 fn a_with_alias_is_visible_to_where_and_order_by_together() {
8907 let fx = assoc_graph();
8908 let rs = run(
8909 &fx.view(),
8910 "MATCH (t:Talent) WITH t, t.years_of_experience AS x WHERE x > 3 \
8911 ORDER BY x DESC RETURN key(t), x",
8912 &BTreeMap::new(),
8913 )
8914 .unwrap();
8915 assert_eq!(rs.columns(), ["key(t)", "x"]);
8916 assert_eq!(
8917 rows_of(&rs),
8918 vec![
8919 vec![Some(s("t1")), Some(i(12))],
8920 vec![Some(s("t2")), Some(i(11))],
8921 ]
8922 );
8923 }
8924
8925 #[test]
8929 fn a_with_carrying_only_a_variable_still_filters_on_it() {
8930 let fx = assoc_graph();
8931 let rs = run(
8932 &fx.view(),
8933 "MATCH (t:Talent) WHERE t.status = 'published' \
8934 WITH t WHERE t.years_of_experience > 11 RETURN key(t)",
8935 &BTreeMap::new(),
8936 )
8937 .unwrap();
8938 assert_eq!(rs.columns(), ["key(t)"]);
8939 assert_eq!(rows_of(&rs), vec![vec![Some(s("t1"))]]);
8940 }
8941
8942 #[test]
8945 fn a_with_alias_projects_under_its_new_name() {
8946 let fx = assoc_graph();
8947 let rs = run(
8948 &fx.view(),
8949 "MATCH (c:Company) WITH c, c.name AS nm RETURN nm",
8950 &BTreeMap::new(),
8951 )
8952 .unwrap();
8953 assert_eq!(rs.columns(), ["nm"]);
8954 assert_eq!(
8955 rows_of(&rs),
8956 vec![
8957 vec![Some(s("Acme Design Works"))],
8958 vec![Some(s("Beta Studio"))],
8959 vec![Some(s("Gamma Works"))],
8960 ]
8961 );
8962 }
8963
8964 #[test]
8968 fn every_with_alias_shape_is_in_scope_for_the_where() {
8969 let fx = assoc_graph();
8970 let p = BTreeMap::new();
8971 for (q, want) in [
8972 (
8973 "MATCH (t:Talent) WITH t, t.years_of_experience + 1 AS x WHERE x > 12 \
8974 RETURN key(t)",
8975 vec!["t1"],
8976 ),
8977 (
8978 "MATCH (t:Talent) WITH t, t.location[0] AS lat WHERE lat > 41.0 RETURN key(t)",
8979 vec!["t2"],
8980 ),
8981 (
8982 "MATCH (t:Talent) WITH t AS u WHERE u.years_of_experience > 11 RETURN key(u)",
8983 vec!["t1"],
8984 ),
8985 (
8986 "MATCH (t:Talent) WITH t.key AS k WHERE k = 't2' RETURN k",
8987 vec!["t2"],
8988 ),
8989 ] {
8990 let rs = run(&fx.view(), q, &p).unwrap_or_else(|e| panic!("{q}: {e}"));
8991 let got: Vec<String> = (0..rs.len())
8992 .map(|r| match rs.row(r)[0].clone() {
8993 Some(Value::Str(k)) => k,
8994 other => panic!("{q}: {other:?}"),
8995 })
8996 .collect();
8997 assert_eq!(got, want, "{q}");
8998 }
8999 }
9000
9001 #[test]
9004 fn an_unknown_name_in_a_with_where_is_still_an_error() {
9005 let err = plan(
9006 &parse(
9007 &lex(
9008 "MATCH (t:Talent) WITH t, t.years_of_experience AS x WHERE nope > 1 \
9009 RETURN key(t)",
9010 )
9011 .unwrap(),
9012 )
9013 .unwrap(),
9014 )
9015 .expect_err("must not plan");
9016 assert_eq!(err, "unbound variable `nope` in WHERE");
9017 }
9018
9019 #[test]
9022 fn comma_patterns_without_shared_vars_are_a_product() {
9023 let mut fx = Fx::new();
9024 fx.add("A", "a1", vec![]);
9025 fx.add("A", "a2", vec![]);
9026 fx.add("B", "b1", vec![]);
9027 let rs = run(
9028 &fx.view(),
9029 "MATCH (a:A), (b:B) RETURN a.key, b.key",
9030 &BTreeMap::new(),
9031 )
9032 .unwrap();
9033 assert_eq!(
9034 rows_of(&rs),
9035 vec![
9036 vec![Some(s("a1")), Some(s("b1"))],
9037 vec![Some(s("a2")), Some(s("b1"))],
9038 ]
9039 );
9040 }
9041}