1use crate::cypher::ast::{
4 AggArg, AggFunc, Expr, LimitSkip, Operand, OrderItem, OrderTarget, RetItem, RetVal, UnwindExpr,
5};
6use crate::cypher::plan::PlanOp;
7use crate::cypher::RelDir;
8use crate::filter::eval_cmp;
9use crate::result::ResultSet;
10use crate::traverse::{expand, Dir, EdgeRef};
11use crate::value_ops::{cmp_optional, values_equal};
12use crate::view::GraphView;
13use core_storage::{Value, ValueKey};
14use std::collections::{BTreeMap, BTreeSet, HashMap};
15
16#[cfg(test)]
20static FUSED_SCAN_FIRES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
21
22#[cfg(test)]
26static SCAN_KEY_FIRES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
27
28#[cfg(test)]
31pub static INDEX_SCAN_FIRES: std::sync::atomic::AtomicUsize =
32 std::sync::atomic::AtomicUsize::new(0);
33
34#[cfg(test)]
38pub static INDEX_INTERSECT_FIRES: std::sync::atomic::AtomicUsize =
39 std::sync::atomic::AtomicUsize::new(0);
40
41pub struct Params<'a>(pub &'a BTreeMap<String, Value>);
44
45#[derive(Clone, Debug)]
46enum Cell {
47 Node(u32),
48 Rel(EdgeRef),
49 Path(u8),
52 Scalar(Value),
54}
55
56type Row = Vec<Option<Cell>>;
59
60struct VarTable {
61 names: Vec<String>,
62}
63
64impl VarTable {
65 fn intern(&mut self, name: &str) -> usize {
66 if let Some(i) = self.names.iter().position(|n| n == name) {
67 return i;
68 }
69 self.names.push(name.to_string());
70 self.names.len() - 1
71 }
72
73 fn slot(&self, name: &str) -> Option<usize> {
74 self.names.iter().position(|n| n == name)
75 }
76}
77
78struct Projected {
79 columns: Vec<String>,
80 rows: Vec<Vec<Option<Value>>>,
81}
82
83const MAX_INTERMEDIATE_ROWS: usize = 1_000_000;
86
87const MAX_GROUPS: usize = 1_000_000;
89
90type GroupKey = Vec<Option<ValueKey>>;
93type GroupEntry = (Vec<Option<Value>>, Vec<AggAcc>);
98
99#[cfg(test)]
100thread_local! {
101 static TEST_MAX_INTERMEDIATE_ROWS: std::cell::Cell<Option<usize>> =
102 const { std::cell::Cell::new(None) };
103 static TEST_EXPAND_PRODUCED: std::cell::Cell<Option<usize>> =
106 const { std::cell::Cell::new(None) };
107 static TEST_MAX_GROUPS: std::cell::Cell<Option<usize>> =
109 const { std::cell::Cell::new(None) };
110}
111
112fn max_intermediate_rows() -> usize {
113 #[cfg(test)]
114 {
115 TEST_MAX_INTERMEDIATE_ROWS
116 .with(|c| c.get())
117 .unwrap_or(MAX_INTERMEDIATE_ROWS)
118 }
119 #[cfg(not(test))]
120 {
121 MAX_INTERMEDIATE_ROWS
122 }
123}
124
125fn max_groups() -> usize {
126 #[cfg(test)]
127 {
128 TEST_MAX_GROUPS.with(|c| c.get()).unwrap_or(MAX_GROUPS)
129 }
130 #[cfg(not(test))]
131 {
132 MAX_GROUPS
133 }
134}
135
136#[cfg(test)]
139pub(crate) fn with_max_groups<R>(cap: usize, f: impl FnOnce() -> R) -> R {
140 TEST_MAX_GROUPS.with(|c| {
141 let prev = c.replace(Some(cap));
142 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
143 c.set(prev);
144 match result {
145 Ok(v) => v,
146 Err(p) => std::panic::resume_unwind(p),
147 }
148 })
149}
150
151#[cfg(test)]
155pub(crate) fn with_max_intermediate_rows<R>(cap: usize, f: impl FnOnce() -> R) -> R {
156 TEST_MAX_INTERMEDIATE_ROWS.with(|c| {
157 let prev = c.replace(Some(cap));
158 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
159 c.set(prev);
160 match result {
161 Ok(v) => v,
162 Err(p) => std::panic::resume_unwind(p),
163 }
164 })
165}
166
167#[cfg(test)]
169fn record_expand_row() {
170 TEST_EXPAND_PRODUCED.with(|c| {
171 if let Some(prev) = c.get() {
172 c.set(Some(prev + 1));
173 }
174 });
175}
176
177#[cfg(test)]
183pub(crate) fn with_expand_counter<R>(f: impl FnOnce() -> R) -> (R, usize) {
184 TEST_EXPAND_PRODUCED.with(|c| c.set(Some(0)));
185 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
186 let count = TEST_EXPAND_PRODUCED.with(|c| c.get().unwrap_or(0));
187 TEST_EXPAND_PRODUCED.with(|c| c.set(None));
188 match result {
189 Ok(v) => (v, count),
190 Err(p) => std::panic::resume_unwind(p),
191 }
192}
193
194fn row_cap_err(cap: usize) -> String {
195 format!(
196 "intermediate result exceeds {cap} rows; add a LIMIT or constrain patterns with shared variables"
197 )
198}
199
200fn group_cap_err() -> String {
201 format!(
202 "group count exceeds {} distinct keys; add a WHERE clause or constrain the grouping key",
203 max_groups()
204 )
205}
206
207fn group_key_normalize(v: &Value) -> Option<ValueKey> {
218 match v {
219 Value::Int(n) => Some(ValueKey::FloatBits((*n as f64).to_bits())),
220 Value::Float(f) => Some(ValueKey::FloatBits(f.to_bits())),
221 _ => ValueKey::from_value(v),
222 }
223}
224
225pub fn execute(view: &GraphView, plan: &[PlanOp], params: &Params) -> Result<ResultSet, String> {
237 use crate::cypher::plan::row_bound;
238 execute_inner(view, plan, params, row_bound(plan))
239}
240
241pub fn execute_union(
246 view: &GraphView,
247 union: &crate::cypher::parser::UnionQuery,
248 params: &Params,
249) -> Result<ResultSet, String> {
250 let mut acc: Option<ResultSet> = None;
251 for (i, part) in union.parts.iter().enumerate() {
252 let ops = crate::cypher::plan::plan(part)?;
253 let rs = execute(view, &ops, params)?;
254 acc = Some(match acc {
255 None => rs,
256 Some(prev) => {
257 if prev.columns() != rs.columns() {
258 return Err(format!(
259 "UNION requires matching column names across all parts; \
260 got {:?} then {:?}",
261 prev.columns(),
262 rs.columns()
263 ));
264 }
265 let all = union.all_flags.get(i - 1).copied().unwrap_or(false);
267 combine_result_sets(prev, rs, all)
268 }
269 });
270 }
271 Ok(acc.expect("UNION query has at least one part"))
273}
274
275fn combine_result_sets(mut acc: ResultSet, other: ResultSet, all: bool) -> ResultSet {
276 for i in 0..other.len() {
277 acc.push_row(other.row(i).to_vec());
278 }
279 if !all {
280 let mut seen: Vec<Vec<Option<Value>>> = Vec::new();
282 for i in 0..acc.len() {
283 let r = acc.row(i).to_vec();
284 if !seen.contains(&r) {
285 seen.push(r);
286 }
287 }
288 let mut deduped = ResultSet::new(acc.columns().to_vec());
289 for r in seen {
290 deduped.push_row(r);
291 }
292 return deduped;
293 }
294 acc
295}
296
297#[cfg(test)]
302pub(crate) fn execute_unbounded(
303 view: &GraphView,
304 plan: &[PlanOp],
305 params: &Params,
306) -> Result<ResultSet, String> {
307 execute_inner(view, plan, params, None)
308}
309
310fn execute_inner(
311 view: &GraphView,
312 plan: &[PlanOp],
313 params: &Params,
314 row_bound: Option<usize>,
315) -> Result<ResultSet, String> {
316 check_params(plan, params)?;
317
318 let has_var_expand = plan
324 .iter()
325 .any(|op| matches!(op, PlanOp::VarExpand { .. } | PlanOp::ShortestPath { .. }));
326
327 let is_pipeline = plan.iter().any(|op| {
333 matches!(
334 op,
335 PlanOp::With { .. } | PlanOp::Unwind { .. } | PlanOp::LeftOuterApply { .. }
336 )
337 }) || {
338 let mut saw_gagg = false;
339 plan.iter().any(|op| {
340 if matches!(op, PlanOp::GroupAggregate { .. }) {
341 saw_gagg = true;
342 false
343 } else {
344 saw_gagg && matches!(op, PlanOp::Filter { .. })
345 }
346 })
347 };
348
349 if !has_var_expand
353 && !is_pipeline
354 && plan
355 .iter()
356 .any(|op| matches!(op, PlanOp::GroupAggregate { .. }))
357 {
358 return execute_group_aggregate(view, plan, params);
359 }
360
361 if !has_var_expand
365 && !is_pipeline
366 && plan.iter().any(|op| matches!(op, PlanOp::Aggregate { .. }))
367 {
368 return execute_aggregate(view, plan, params);
369 }
370
371 if let Some(bound) = row_bound {
375 return execute_pull(view, plan, params, bound);
376 }
377
378 let vars = collect_vars(plan);
380 let mut rows: Vec<Row> = vec![vec![None; vars.names.len()]];
381 let mut projected: Option<Projected> = None;
382 let mut pipeline_group_columns: Option<Vec<String>> = None;
385
386 for op in plan {
387 match op {
388 PlanOp::ScanLabel { var, label } => {
389 rows = scan_label(view, &vars, &rows, var, label.as_deref())?;
390 }
391 PlanOp::ScanKey { var, key, label } => {
392 rows = scan_key(view, &vars, &rows, var, key, label.as_deref(), params)?;
393 }
394 PlanOp::IndexScan {
395 var,
396 label,
397 field,
398 value,
399 } => {
400 rows = scan_index(
401 view,
402 &vars,
403 &rows,
404 var,
405 label.as_deref(),
406 field,
407 value,
408 params,
409 )?;
410 }
411 PlanOp::IndexIntersect {
412 var,
413 label,
414 equalities,
415 } => {
416 rows = scan_intersect(
417 view,
418 &vars,
419 &rows,
420 var,
421 label.as_deref(),
422 equalities,
423 params,
424 )?;
425 }
426 PlanOp::LookupProps { var, props } => {
427 rows = retain_node(view, &vars, &rows, var, None, props, params)?;
428 }
429 PlanOp::JoinBound { var, label, props } => {
430 rows = retain_node(view, &vars, &rows, var, label.as_deref(), props, params)?;
431 }
432 PlanOp::Expand { .. } => {
433 rows = exec_expand(view, &vars, &rows, op, params)?;
434 }
435 PlanOp::VarExpand {
436 from,
437 rel_var,
438 etypes,
439 dir,
440 to,
441 min,
442 max,
443 } => {
444 rows = exec_var_expand(
445 view, &vars, &rows, from, rel_var, etypes, *dir, to, *min, *max,
446 )?;
447 }
448 PlanOp::ShortestPath {
449 from,
450 rel_var,
451 etypes,
452 dir,
453 to,
454 max_hops,
455 } => {
456 rows = exec_shortest_path(
457 view, &vars, &rows, from, rel_var, etypes, *dir, to, *max_hops,
458 )?;
459 }
460 PlanOp::Filter { expr } => {
461 rows = exec_filter(view, &vars, &rows, expr, params)?;
462 }
463 PlanOp::Project { items } => {
464 projected = Some(exec_project(view, &vars, &rows, items, params)?);
465 }
466 PlanOp::Distinct => {
467 if let Some(table) = projected.as_mut() {
468 exec_distinct(table)?;
469 } else {
470 return Err("DISTINCT requires a Project".into());
471 }
472 }
473 PlanOp::OrderBy { items } => {
474 if let Some(table) = projected.as_mut() {
475 exec_order_by(table, items)?;
476 } else {
477 exec_order_by_rows(&vars, &mut rows, items, view);
479 }
480 }
481 PlanOp::Skip(ls) => {
482 let n = resolve_ls(ls, params)?;
483 if let Some(table) = projected.as_mut() {
484 apply_skip(&mut table.rows, n);
485 } else {
486 apply_skip(&mut rows, n);
487 }
488 }
489 PlanOp::Limit(ls) => {
490 let n = resolve_ls(ls, params)?;
491 if let Some(table) = projected.as_mut() {
492 apply_limit(&mut table.rows, n);
493 } else {
494 apply_limit(&mut rows, n);
495 }
496 }
497 PlanOp::GroupAggregate { keys, aggs } => {
502 let mut grp_groups: HashMap<GroupKey, GroupEntry> = HashMap::new();
503 let mut grp_key_order: Vec<GroupKey> = Vec::new();
504 for row in &rows {
505 let mut gk: GroupKey = Vec::with_capacity(keys.len());
506 let mut display_vals: Vec<Option<Value>> = Vec::with_capacity(keys.len());
507 for (_, item) in keys {
508 let val = project_item(view, &vars, row, item, params)?;
509 gk.push(val.as_ref().and_then(group_key_normalize));
510 display_vals.push(val);
511 }
512 if !grp_groups.contains_key(&gk) {
513 if grp_groups.len() >= max_groups() {
514 return Err(group_cap_err());
515 }
516 grp_key_order.push(gk.clone());
517 grp_groups.insert(
518 gk.clone(),
519 (
520 display_vals,
521 aggs.iter().map(|(f, _, _)| AggAcc::new(f)).collect(),
522 ),
523 );
524 }
525 let (_, accs) = grp_groups.get_mut(&gk).unwrap();
526 for (acc, (func, arg, _)) in accs.iter_mut().zip(aggs.iter()) {
527 update_acc(view, &vars, row, func, arg, acc)?;
528 }
529 }
530 if keys.is_empty() && grp_key_order.is_empty() {
533 let empty_key: GroupKey = vec![];
534 grp_key_order.push(empty_key.clone());
535 grp_groups.insert(
536 empty_key,
537 (
538 vec![],
539 aggs.iter().map(|(f, _, _)| AggAcc::new(f)).collect(),
540 ),
541 );
542 }
543 if is_pipeline {
544 rows = group_result_to_rows(keys, aggs, grp_key_order, &mut grp_groups, &vars);
547 projected = None;
548 let mut cols: Vec<String> = keys.iter().map(|(c, _)| c.clone()).collect();
551 cols.extend(aggs.iter().map(|(_, _, c)| c.clone()));
552 pipeline_group_columns = Some(cols);
553 } else {
554 projected = Some(build_group_projected(
555 keys,
556 aggs,
557 grp_key_order,
558 &mut grp_groups,
559 ));
560 pipeline_group_columns = None;
561 }
562 }
563 PlanOp::With {
566 items,
567 where_expr,
568 order_by,
569 skip,
570 limit,
571 } => {
572 let row_len = vars.names.len();
576 let mut new_rows: Vec<Row> = Vec::with_capacity(rows.len());
577 for row in &rows {
578 let mut new_row: Row = vec![None; row_len];
579 for item in items {
580 let col = column_name(item);
581 let Some(dst_slot) = vars.slot(&col) else {
582 continue;
583 };
584 match &item.value {
585 RetVal::Var(v) => {
586 if let Some(src_slot) = vars.slot(v) {
588 new_row[dst_slot] = row.get(src_slot).cloned().flatten();
589 }
590 }
591 RetVal::Prop { var, field } => {
592 let val = resolve_prop(view, &vars, row, var, field)?;
593 new_row[dst_slot] = val.map(Cell::Scalar);
594 }
595 RetVal::Agg { .. } => {} RetVal::FuncCall { name, args } => {
597 let val = eval_func(name, args, view, &vars, row, params)?;
598 new_row[dst_slot] = val.map(Cell::Scalar);
599 }
600 RetVal::ScalarExpr(op) => {
601 let val = resolve_operand(view, &vars, row, op, params)?;
602 new_row[dst_slot] = val.map(Cell::Scalar);
603 }
604 }
605 }
606 new_rows.push(new_row);
607 }
608 rows = new_rows;
609 if let Some(expr) = where_expr {
611 rows = exec_filter(view, &vars, &rows, expr, params)?;
612 }
613 if !order_by.is_empty() {
615 exec_order_by_rows(&vars, &mut rows, order_by, view);
616 }
617 if let Some(ls) = skip {
618 let n = resolve_ls(ls, params)?;
619 apply_skip(&mut rows, n);
620 }
621 if let Some(ls) = limit {
622 let n = resolve_ls(ls, params)?;
623 apply_limit(&mut rows, n);
624 }
625 }
626 PlanOp::Unwind { expr, alias } => {
628 let alias_slot = vars
629 .slot(alias)
630 .ok_or_else(|| format!("UNWIND alias `{alias}` not in VarTable"))?;
631 let cap = max_intermediate_rows();
632 let mut new_rows: Vec<Row> = Vec::new();
633 for row in &rows {
634 let list_val: Option<Value> = match expr {
635 UnwindExpr::Lit(vals) => Some(Value::List(vals.clone())),
636 UnwindExpr::Prop { var, field } => {
637 resolve_prop(view, &vars, row, var, field)?
638 }
639 UnwindExpr::Var(name) => {
640 let slot = vars
641 .slot(name)
642 .ok_or_else(|| format!("UNWIND variable `{name}` is not bound"))?;
643 match row.get(slot).and_then(|c| c.as_ref()) {
644 Some(Cell::Scalar(v)) => Some(v.clone()),
645 Some(Cell::Node(_) | Cell::Rel(_) | Cell::Path(_)) => {
646 return Err(format!(
647 "UNWIND requires a list; `{name}` is bound to a graph element"
648 ));
649 }
650 None => None,
651 }
652 }
653 };
654 match list_val {
655 None => {} Some(Value::List(items_list)) => {
657 for item_val in items_list {
659 if new_rows.len() >= cap {
660 return Err(row_cap_err(cap));
661 }
662 let mut new_row = row.clone();
663 new_row[alias_slot] = Some(Cell::Scalar(item_val));
664 new_rows.push(new_row);
665 }
666 }
667 Some(other) => {
668 let type_name = match &other {
669 Value::Int(_) => "Int",
670 Value::Float(_) => "Float",
671 Value::Str(_) => "Str",
672 Value::Bool(_) => "Bool",
673 Value::List(_) => unreachable!(),
674 Value::Map(_) => "Map",
675 };
676 return Err(format!(
677 "UNWIND requires a list; got {type_name} value for `{alias}`"
678 ));
679 }
680 }
681 }
682 rows = new_rows;
683 }
684 PlanOp::Aggregate { func, arg, column } => {
689 let ctx = AggStreamCtx {
690 view,
691 vars: &vars,
692 params,
693 func,
694 arg,
695 };
696 let mut acc = AggAcc::new(func);
697 for row in &rows {
698 agg_stream(&ctx, &[], row, &mut acc)?;
700 }
701 let value = acc.finish();
702 let mut rs = ResultSet::new(vec![column.clone()]);
703 rs.push_row(vec![value]);
704 return Ok(rs);
705 }
706 PlanOp::LeftOuterApply {
712 inner,
713 optional_vars,
714 } => {
715 let cap = max_intermediate_rows();
716 let mut new_rows: Vec<Row> = Vec::new();
717 for outer_row in &rows {
718 let inner_seed: Vec<Row> = vec![outer_row.clone()];
720 let inner_result =
723 exec_left_outer_inner(view, &vars, inner_seed, inner, params)?;
724 if inner_result.is_empty() {
725 let mut null_row = outer_row.clone();
727 for v in optional_vars {
728 if let Some(slot) = vars.slot(v) {
729 null_row[slot] = None;
730 }
731 }
732 if new_rows.len() >= cap {
733 return Err(row_cap_err(cap));
734 }
735 new_rows.push(null_row);
736 } else {
737 for r in inner_result {
738 if new_rows.len() >= cap {
739 return Err(row_cap_err(cap));
740 }
741 new_rows.push(r);
742 }
743 }
744 }
745 rows = new_rows;
746 }
747 }
748 }
749
750 Ok(match projected {
751 Some(table) => finish(table),
752 None => {
753 if let Some(cols) = pipeline_group_columns {
756 let mut rs = ResultSet::new(cols.clone());
757 for row in rows {
758 let vals: Vec<Option<Value>> = cols
759 .iter()
760 .map(|col| {
761 vars.slot(col)
762 .and_then(|s| row.get(s))
763 .and_then(|c| c.as_ref())
764 .and_then(|c| match c {
765 Cell::Scalar(v) => Some(v.clone()),
766 Cell::Node(id) => {
767 view.ids.key_of(*id).map(|k| Value::Str(k.to_owned()))
768 }
769 Cell::Path(h) => Some(Value::Int(*h as i64)),
770 Cell::Rel(_) => None,
771 })
772 })
773 .collect();
774 rs.push_row(vals);
775 }
776 rs
777 } else {
778 ResultSet::new(vec![])
779 }
780 }
781 })
782}
783
784fn exec_left_outer_inner(
793 view: &GraphView,
794 vars: &VarTable,
795 mut rows: Vec<Row>,
796 inner: &[PlanOp],
797 params: &Params,
798) -> Result<Vec<Row>, String> {
799 for op in inner {
800 match op {
801 PlanOp::ScanLabel { var, label } => {
802 rows = scan_label(view, vars, &rows, var, label.as_deref())?;
803 }
804 PlanOp::ScanKey { var, key, label } => {
805 rows = scan_key(view, vars, &rows, var, key, label.as_deref(), params)?;
806 }
807 PlanOp::LookupProps { var, props } => {
808 rows = retain_node(view, vars, &rows, var, None, props, params)?;
809 }
810 PlanOp::JoinBound { var, label, props } => {
811 rows = retain_node(view, vars, &rows, var, label.as_deref(), props, params)?;
812 }
813 PlanOp::Expand { .. } => {
814 rows = exec_expand(view, vars, &rows, op, params)?;
815 }
816 PlanOp::Filter { expr } => {
817 rows = exec_filter(view, vars, &rows, expr, params)?;
818 }
819 other => {
820 return Err(format!(
821 "unsupported op inside OPTIONAL MATCH inner plan: {other:?}"
822 ));
823 }
824 }
825 }
826 Ok(rows)
827}
828
829fn finish(table: Projected) -> ResultSet {
830 let mut rs = ResultSet::new(table.columns);
831 for row in table.rows {
832 rs.push_row(row);
833 }
834 rs
835}
836
837fn check_params(plan: &[PlanOp], params: &Params) -> Result<(), String> {
838 let mut names = Vec::new();
839 let mut seen = BTreeSet::new();
840 collect_params_from_ops(plan, &mut names, &mut seen)?;
841 for name in names {
842 if !params.0.contains_key(&name) {
843 return Err(format!("missing parameter `{name}`"));
844 }
845 }
846 Ok(())
847}
848
849fn collect_params_from_ops(
850 plan: &[PlanOp],
851 names: &mut Vec<String>,
852 seen: &mut BTreeSet<String>,
853) -> Result<(), String> {
854 for op in plan {
855 match op {
856 PlanOp::ScanKey { key, .. } => collect_operand(key, names, seen),
857 PlanOp::LookupProps { props, .. }
858 | PlanOp::JoinBound { props, .. }
859 | PlanOp::Expand {
860 to_props: props, ..
861 } => {
862 for (_, operand) in props {
863 collect_operand(operand, names, seen);
864 }
865 }
866 PlanOp::Filter { expr } => collect_expr(expr, names, seen, 0)?,
867 PlanOp::LeftOuterApply { inner, .. } => {
868 collect_params_from_ops(inner, names, seen)?;
869 }
870 PlanOp::Project { items } => {
873 for item in items {
874 collect_ret_item_params(item, names, seen);
875 }
876 }
877 PlanOp::With {
878 items, where_expr, ..
879 } => {
880 for item in items {
881 collect_ret_item_params(item, names, seen);
882 }
883 if let Some(expr) = where_expr {
884 let _ = collect_expr(expr, names, seen, 0);
885 }
886 }
887 PlanOp::GroupAggregate { keys, aggs } => {
888 for (_, item) in keys {
889 collect_ret_item_params(item, names, seen);
890 }
891 for (_, arg, _) in aggs {
892 match arg {
893 AggArg::Star => {}
894 AggArg::Var(_) => {}
895 AggArg::Prop { .. } => {}
896 }
897 }
898 }
899 PlanOp::Skip(LimitSkip::Param(n)) | PlanOp::Limit(LimitSkip::Param(n))
902 if seen.insert(n.clone()) =>
903 {
904 names.push(n.clone());
905 }
906 _ => {}
907 }
908 }
909 Ok(())
910}
911
912fn collect_ret_item_params(item: &RetItem, names: &mut Vec<String>, seen: &mut BTreeSet<String>) {
914 match &item.value {
915 RetVal::FuncCall { args, .. } => {
916 for arg in args {
917 collect_operand(arg, names, seen);
918 }
919 }
920 RetVal::ScalarExpr(op) => {
921 collect_operand(op, names, seen);
922 }
923 RetVal::Prop { .. } | RetVal::Var(_) | RetVal::Agg { .. } => {}
924 }
925}
926
927fn collect_operand(op: &Operand, names: &mut Vec<String>, seen: &mut BTreeSet<String>) {
928 match op {
929 Operand::Param(n) => {
930 if seen.insert(n.clone()) {
931 names.push(n.clone());
932 }
933 }
934 Operand::FuncCall { args, .. } => {
935 for arg in args {
936 collect_operand(arg, names, seen);
937 }
938 }
939 Operand::BinArith { left, right, .. } => {
940 collect_operand(left, names, seen);
941 collect_operand(right, names, seen);
942 }
943 _ => {}
944 }
945}
946
947fn collect_expr(
948 expr: &Expr,
949 names: &mut Vec<String>,
950 seen: &mut BTreeSet<String>,
951 depth: u32,
952) -> Result<(), String> {
953 if depth > 256 {
954 return Err("expression nesting too deep".into());
955 }
956 match expr {
957 Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => {
958 collect_expr(lhs, names, seen, depth + 1)?;
959 collect_expr(rhs, names, seen, depth + 1)
960 }
961 Expr::Not(inner) => collect_expr(inner, names, seen, depth + 1),
962 Expr::Cmp { lhs, rhs, .. } => {
963 collect_operand(lhs, names, seen);
964 collect_operand(rhs, names, seen);
965 Ok(())
966 }
967 Expr::Truthy(op) => {
968 collect_operand(op, names, seen);
969 Ok(())
970 }
971 Expr::IsNull(op) | Expr::IsNotNull(op) => {
972 collect_operand(op, names, seen);
973 Ok(())
974 }
975 Expr::In { expr, list } => {
976 collect_operand(expr, names, seen);
977 for item in list {
978 collect_operand(item, names, seen);
979 }
980 Ok(())
981 }
982 }
983}
984
985fn collect_vars(plan: &[PlanOp]) -> VarTable {
986 let mut vars = VarTable { names: Vec::new() };
987 for op in plan {
988 match op {
989 PlanOp::ScanLabel { var, .. } => {
990 vars.intern(var);
991 }
992 PlanOp::ScanKey { var, key, .. } => {
993 vars.intern(var);
994 intern_operand(&mut vars, key);
995 }
996 PlanOp::IndexScan { var, value, .. } => {
997 vars.intern(var);
998 intern_operand(&mut vars, value);
999 }
1000 PlanOp::IndexIntersect {
1001 var, equalities, ..
1002 } => {
1003 vars.intern(var);
1004 for (_, operand) in equalities {
1005 intern_operand(&mut vars, operand);
1006 }
1007 }
1008 PlanOp::LookupProps { var, props } | PlanOp::JoinBound { var, props, .. } => {
1009 vars.intern(var);
1010 for (_, operand) in props {
1011 intern_operand(&mut vars, operand);
1012 }
1013 }
1014 PlanOp::Expand {
1015 from,
1016 rel_var,
1017 to,
1018 to_props,
1019 ..
1020 } => {
1021 vars.intern(from);
1022 vars.intern(to);
1023 if let Some(r) = rel_var {
1024 vars.intern(r);
1025 }
1026 for (_, operand) in to_props {
1027 intern_operand(&mut vars, operand);
1028 }
1029 }
1030 PlanOp::VarExpand {
1031 from, rel_var, to, ..
1032 } => {
1033 vars.intern(from);
1034 vars.intern(to);
1035 if let Some(r) = rel_var {
1036 vars.intern(r);
1037 }
1038 }
1039 PlanOp::ShortestPath {
1040 from, rel_var, to, ..
1041 } => {
1042 vars.intern(from);
1043 vars.intern(to);
1044 if let Some(r) = rel_var {
1045 vars.intern(r);
1046 }
1047 }
1048 PlanOp::Filter { expr } => intern_expr(&mut vars, expr),
1049 PlanOp::Project { items } => {
1050 for item in items {
1051 match &item.value {
1052 RetVal::Var(name) | RetVal::Prop { var: name, .. } => {
1053 vars.intern(name);
1054 }
1055 RetVal::Agg { .. } => {} RetVal::FuncCall { args, .. } => {
1057 for arg in args {
1058 intern_operand(&mut vars, arg);
1059 }
1060 }
1061 RetVal::ScalarExpr(op) => {
1062 intern_operand(&mut vars, op);
1063 }
1064 }
1065 }
1066 }
1067 PlanOp::Aggregate { arg, .. } => match arg {
1068 AggArg::Star => {}
1069 AggArg::Var(v) => {
1070 vars.intern(v);
1071 }
1072 AggArg::Prop { var, .. } => {
1073 vars.intern(var);
1074 }
1075 },
1076 PlanOp::GroupAggregate { keys, aggs } => {
1077 for (col, item) in keys {
1079 match &item.value {
1080 RetVal::Var(name) | RetVal::Prop { var: name, .. } => {
1081 vars.intern(name);
1082 }
1083 RetVal::Agg { .. } => {}
1084 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 vars.intern(col);
1095 }
1096 for (_, arg, col) in aggs {
1097 match arg {
1098 AggArg::Star => {}
1099 AggArg::Var(v) => {
1100 vars.intern(v);
1101 }
1102 AggArg::Prop { var, .. } => {
1103 vars.intern(var);
1104 }
1105 }
1106 vars.intern(col);
1108 }
1109 }
1110 PlanOp::With {
1111 items,
1112 where_expr,
1113 order_by,
1114 ..
1115 } => {
1116 for item in items {
1117 match &item.value {
1118 RetVal::Var(name) | RetVal::Prop { var: name, .. } => {
1119 vars.intern(name);
1120 }
1121 RetVal::Agg { .. } => {}
1122 RetVal::FuncCall { args, .. } => {
1123 for arg in args {
1124 intern_operand(&mut vars, arg);
1125 }
1126 }
1127 RetVal::ScalarExpr(op) => {
1128 intern_operand(&mut vars, op);
1129 }
1130 }
1131 if let Some(alias) = &item.alias {
1133 vars.intern(alias);
1134 } else {
1135 match &item.value {
1136 RetVal::Var(name) => {
1137 vars.intern(name);
1138 }
1139 RetVal::Prop { var, field } => {
1140 let col = format!("{var}.{field}");
1141 vars.intern(&col);
1142 }
1143 RetVal::Agg { .. } => {}
1144 RetVal::ScalarExpr(_) => {
1145 vars.intern("<expr>");
1146 }
1147 RetVal::FuncCall { name, args } => {
1148 let arg_strs: Vec<String> = args
1150 .iter()
1151 .map(|a| match a {
1152 Operand::Var(v) => v.clone(),
1153 Operand::Prop { var, field } => format!("{var}.{field}"),
1154 Operand::Lit(_) => "<lit>".to_string(),
1155 Operand::Param(p) => format!("${p}"),
1156 Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
1157 Operand::BinArith { .. } => "<arith>".to_string(),
1158 Operand::Case { .. } => "<case>".to_string(),
1159 })
1160 .collect();
1161 let col = format!("{name}({})", arg_strs.join(", "));
1162 vars.intern(&col);
1163 }
1164 }
1165 }
1166 }
1167 if let Some(expr) = where_expr {
1168 intern_expr(&mut vars, expr);
1169 }
1170 for oi in order_by {
1171 match &oi.target {
1172 OrderTarget::Alias(name) | OrderTarget::Var(name) => {
1173 vars.intern(name);
1174 }
1175 OrderTarget::Prop { var, .. } => {
1176 vars.intern(var);
1177 }
1178 }
1179 }
1180 }
1181 PlanOp::Unwind { expr, alias } => {
1182 vars.intern(alias);
1183 match expr {
1184 UnwindExpr::Prop { var, .. } => {
1185 vars.intern(var);
1186 }
1187 UnwindExpr::Var(name) => {
1188 vars.intern(name);
1189 }
1190 UnwindExpr::Lit(_) => {}
1191 }
1192 }
1193 PlanOp::LeftOuterApply {
1194 inner,
1195 optional_vars,
1196 } => {
1197 for op in inner {
1199 match op {
1203 PlanOp::ScanLabel { var, .. } => {
1204 vars.intern(var);
1205 }
1206 PlanOp::ScanKey { var, key, .. } => {
1207 vars.intern(var);
1208 intern_operand(&mut vars, key);
1209 }
1210 PlanOp::IndexScan { var, value, .. } => {
1211 vars.intern(var);
1212 intern_operand(&mut vars, value);
1213 }
1214 PlanOp::IndexIntersect {
1215 var, equalities, ..
1216 } => {
1217 vars.intern(var);
1218 for (_, operand) in equalities {
1219 intern_operand(&mut vars, operand);
1220 }
1221 }
1222 PlanOp::Expand {
1223 from, rel_var, to, ..
1224 } => {
1225 vars.intern(from);
1226 vars.intern(to);
1227 if let Some(r) = rel_var {
1228 vars.intern(r);
1229 }
1230 }
1231 PlanOp::JoinBound { var, .. } | PlanOp::LookupProps { var, .. } => {
1232 vars.intern(var);
1233 }
1234 PlanOp::Filter { expr } => intern_expr(&mut vars, expr),
1235 _ => {}
1236 }
1237 }
1238 for v in optional_vars {
1239 vars.intern(v);
1240 }
1241 }
1242 _ => {}
1243 }
1244 }
1245 vars
1246}
1247
1248fn intern_operand(vars: &mut VarTable, operand: &Operand) {
1249 match operand {
1250 Operand::Prop { var, .. } | Operand::Var(var) => {
1251 vars.intern(var);
1252 }
1253 Operand::Lit(_) | Operand::Param(_) => {}
1254 Operand::BinArith { left, right, .. } => {
1255 intern_operand(vars, left);
1256 intern_operand(vars, right);
1257 }
1258 Operand::FuncCall { args, .. } => {
1259 for arg in args {
1260 intern_operand(vars, arg);
1261 }
1262 }
1263 Operand::Case { branches, default } => {
1264 for (cond, value) in branches {
1265 intern_expr(vars, cond);
1266 intern_operand(vars, value);
1267 }
1268 if let Some(d) = default {
1269 intern_operand(vars, d);
1270 }
1271 }
1272 }
1273}
1274
1275fn intern_expr(vars: &mut VarTable, expr: &Expr) {
1276 match expr {
1277 Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => {
1278 intern_expr(vars, lhs);
1279 intern_expr(vars, rhs);
1280 }
1281 Expr::Not(inner) => intern_expr(vars, inner),
1282 Expr::Cmp { lhs, rhs, .. } => {
1283 intern_operand(vars, lhs);
1284 intern_operand(vars, rhs);
1285 }
1286 Expr::Truthy(op) => intern_operand(vars, op),
1287 Expr::IsNull(op) | Expr::IsNotNull(op) => intern_operand(vars, op),
1288 Expr::In { expr, list } => {
1289 intern_operand(vars, expr);
1290 for item in list {
1291 intern_operand(vars, item);
1292 }
1293 }
1294 }
1295}
1296
1297fn scan_ids(view: &GraphView, label: Option<&str>) -> Vec<u32> {
1298 let ids = match label {
1299 Some(label) => view.nodes_with_label(label),
1300 None => (0..view.ids.len() as u32)
1302 .filter(|&id| view.label_of(id).is_some())
1303 .collect(),
1304 };
1305 if view.mask.is_some() {
1306 ids.into_iter().filter(|&id| view.visible(id)).collect()
1307 } else {
1308 ids
1309 }
1310}
1311
1312fn resolve_scan_key_id(
1315 view: &GraphView,
1316 vars: &VarTable,
1317 row: &Row,
1318 key: &Operand,
1319 label: Option<&str>,
1320 params: &Params,
1321) -> Result<Option<u32>, String> {
1322 #[cfg(test)]
1323 SCAN_KEY_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1324
1325 let Some(val) = resolve_operand(view, vars, row, key, params)? else {
1326 return Ok(None);
1327 };
1328 let Value::Str(s) = val else {
1329 return Ok(None);
1330 };
1331 let Some(id) = view.node_id(&s) else {
1332 return Ok(None);
1333 };
1334 if !view.visible(id) {
1335 return Ok(None);
1336 }
1337 if let Some(want) = label {
1338 match view.label_of(id) {
1339 Some(got) if got == want => {}
1340 _ => return Ok(None),
1341 }
1342 }
1343 Ok(Some(id))
1344}
1345
1346fn scan_key(
1347 view: &GraphView,
1348 vars: &VarTable,
1349 rows: &[Row],
1350 var: &str,
1351 key: &Operand,
1352 label: Option<&str>,
1353 params: &Params,
1354) -> Result<Vec<Row>, String> {
1355 let slot = vars
1356 .slot(var)
1357 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1358 let cap = max_intermediate_rows();
1359 let mut out = Vec::new();
1360 for row in rows {
1361 let Some(id) = resolve_scan_key_id(view, vars, row, key, label, params)? else {
1362 continue;
1363 };
1364 if out.len() >= cap {
1365 return Err(row_cap_err(cap));
1366 }
1367 let mut next = row.clone();
1368 next[slot] = Some(Cell::Node(id));
1369 out.push(next);
1370 }
1371 Ok(out)
1372}
1373
1374fn scan_label(
1375 view: &GraphView,
1376 vars: &VarTable,
1377 rows: &[Row],
1378 var: &str,
1379 label: Option<&str>,
1380) -> Result<Vec<Row>, String> {
1381 let ids = scan_ids(view, label);
1382 let slot = vars
1383 .slot(var)
1384 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1385 let cap = max_intermediate_rows();
1386 let mut out = Vec::with_capacity(rows.len().saturating_mul(ids.len()).min(cap));
1387 for row in rows {
1388 for &id in &ids {
1389 if out.len() >= cap {
1390 return Err(row_cap_err(cap));
1391 }
1392 let mut next = row.clone();
1393 next[slot] = Some(Cell::Node(id));
1394 out.push(next);
1395 }
1396 }
1397 Ok(out)
1398}
1399
1400#[allow(clippy::too_many_arguments)]
1408fn index_scan_ids(
1409 view: &GraphView,
1410 vars: &VarTable,
1411 row: &Row,
1412 label: Option<&str>,
1413 field: &str,
1414 value: &Operand,
1415 params: &Params,
1416) -> Result<Vec<u32>, String> {
1417 let resolved = resolve_operand(view, vars, row, value, params)?;
1418 if let (Some(label_str), Some(val)) = (label, resolved.as_ref()) {
1419 if let Some(ids) = view.nodes_with_prop(label_str, field, val) {
1420 #[cfg(test)]
1421 INDEX_SCAN_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1422 return Ok(ids);
1423 }
1424 }
1425 let props = [(field.to_string(), value.clone())];
1427 let mut out = Vec::new();
1428 for id in scan_ids(view, label) {
1429 if node_matches(view, vars, row, id, None, &props, params)? {
1430 out.push(id);
1431 }
1432 }
1433 Ok(out)
1434}
1435
1436#[allow(clippy::too_many_arguments)]
1439fn scan_index(
1440 view: &GraphView,
1441 vars: &VarTable,
1442 rows: &[Row],
1443 var: &str,
1444 label: Option<&str>,
1445 field: &str,
1446 value: &Operand,
1447 params: &Params,
1448) -> Result<Vec<Row>, String> {
1449 let Some(first) = rows.first() else {
1450 return Ok(Vec::new());
1451 };
1452 let ids = index_scan_ids(view, vars, first, label, field, value, params)?;
1453 let slot = vars
1454 .slot(var)
1455 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1456 let cap = max_intermediate_rows();
1457 let mut out = Vec::new();
1458 for row in rows {
1459 for &id in &ids {
1460 if out.len() >= cap {
1461 return Err(row_cap_err(cap));
1462 }
1463 let mut next = row.clone();
1464 next[slot] = Some(Cell::Node(id));
1465 out.push(next);
1466 }
1467 }
1468 Ok(out)
1469}
1470
1471#[allow(clippy::too_many_arguments)]
1480fn index_intersect_ids(
1481 view: &GraphView,
1482 vars: &VarTable,
1483 row: &Row,
1484 label: Option<&str>,
1485 equalities: &[(String, Operand)],
1486 params: &Params,
1487) -> Result<Vec<u32>, String> {
1488 let mut resolved: Vec<(String, Option<Value>)> = Vec::with_capacity(equalities.len());
1490 for (field, operand) in equalities {
1491 let val = resolve_operand(view, vars, row, operand, params)?;
1492 resolved.push((field.clone(), val));
1493 }
1494
1495 let mut indexed_lists: Vec<Vec<u32>> = Vec::new();
1497 let mut unindexed_props: Vec<(String, Operand)> = Vec::new();
1498
1499 for ((field, val_opt), (_, operand)) in resolved.iter().zip(equalities.iter()) {
1500 if let (Some(label_str), Some(val)) = (label, val_opt.as_ref()) {
1501 if let Some(ids) = view.nodes_with_prop(label_str, field, val) {
1502 indexed_lists.push(ids);
1503 continue;
1504 }
1505 }
1506 unindexed_props.push((field.clone(), operand.clone()));
1507 }
1508
1509 if indexed_lists.is_empty() {
1510 let mut out = Vec::new();
1512 for id in scan_ids(view, label) {
1513 if node_matches(view, vars, row, id, None, equalities, params)? {
1514 out.push(id);
1515 }
1516 }
1517 return Ok(out);
1518 }
1519
1520 #[cfg(test)]
1521 INDEX_INTERSECT_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1522
1523 indexed_lists.sort_unstable_by_key(|v| v.len());
1525 let mut result = indexed_lists.remove(0);
1526 for other in indexed_lists {
1527 let mut merged = Vec::new();
1528 let (mut i, mut j) = (0, 0);
1529 while i < result.len() && j < other.len() {
1530 match result[i].cmp(&other[j]) {
1531 std::cmp::Ordering::Equal => {
1532 merged.push(result[i]);
1533 i += 1;
1534 j += 1;
1535 }
1536 std::cmp::Ordering::Less => i += 1,
1537 std::cmp::Ordering::Greater => j += 1,
1538 }
1539 }
1540 result = merged;
1541 }
1542
1543 if !unindexed_props.is_empty() {
1547 let mut filtered = Vec::with_capacity(result.len());
1548 for id in result {
1549 if node_matches(view, vars, row, id, None, &unindexed_props, params)? {
1550 filtered.push(id);
1551 }
1552 }
1553 result = filtered;
1554 }
1555
1556 Ok(result)
1557}
1558
1559#[allow(clippy::too_many_arguments)]
1562fn scan_intersect(
1563 view: &GraphView,
1564 vars: &VarTable,
1565 rows: &[Row],
1566 var: &str,
1567 label: Option<&str>,
1568 equalities: &[(String, Operand)],
1569 params: &Params,
1570) -> Result<Vec<Row>, String> {
1571 let Some(first) = rows.first() else {
1572 return Ok(Vec::new());
1573 };
1574 let ids = index_intersect_ids(view, vars, first, label, equalities, params)?;
1575 let slot = vars
1576 .slot(var)
1577 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1578 let cap = max_intermediate_rows();
1579 let mut out = Vec::new();
1580 for row in rows {
1581 for &id in &ids {
1582 if out.len() >= cap {
1583 return Err(row_cap_err(cap));
1584 }
1585 let mut next = row.clone();
1586 next[slot] = Some(Cell::Node(id));
1587 out.push(next);
1588 }
1589 }
1590 Ok(out)
1591}
1592
1593fn require_cell<'a>(row: &'a Row, vars: &VarTable, var: &str) -> Result<&'a Cell, String> {
1594 let slot = vars
1595 .slot(var)
1596 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1597 row.get(slot)
1598 .and_then(|c| c.as_ref())
1599 .ok_or_else(|| format!("unbound variable `{var}`"))
1600}
1601
1602fn require_node(row: &Row, vars: &VarTable, var: &str) -> Result<u32, String> {
1603 match require_cell(row, vars, var)? {
1604 Cell::Node(id) => Ok(*id),
1605 Cell::Rel(_) => Err(format!("variable `{var}` is not a node")),
1606 Cell::Path(_) => Err(format!("variable `{var}` is a path, not a node")),
1607 Cell::Scalar(_) => Err(format!("variable `{var}` is a scalar value, not a node")),
1608 }
1609}
1610
1611const SCALAR_FUNCS: &[&str] = &[
1613 "toLower",
1614 "toUpper",
1615 "size",
1616 "coalesce",
1617 "type",
1618 "abs",
1619 "round",
1620 "textMatches",
1621 "contains",
1622 "startsWith",
1623 "endsWith",
1624 "toInteger",
1625 "toFloat",
1626 "toString",
1627];
1628
1629fn eval_string_predicate(
1632 name: &str,
1633 args: &[Operand],
1634 view: &GraphView,
1635 vars: &VarTable,
1636 row: &Row,
1637 params: &Params,
1638 f: impl Fn(&str, &str) -> bool,
1639) -> Result<Option<Value>, String> {
1640 if args.len() != 2 {
1641 return Err(format!(
1642 "{name}() requires exactly 2 arguments, got {}",
1643 args.len()
1644 ));
1645 }
1646 let a = resolve_operand(view, vars, row, &args[0], params)?;
1647 let b = resolve_operand(view, vars, row, &args[1], params)?;
1648 match (a, b) {
1649 (Some(Value::Str(s)), Some(Value::Str(sub))) => Ok(Some(Value::Bool(f(&s, &sub)))),
1650 (None, _) | (_, None) => Ok(None),
1651 _ => Ok(None),
1652 }
1653}
1654
1655fn eval_func(
1659 name: &str,
1660 args: &[Operand],
1661 view: &GraphView,
1662 vars: &VarTable,
1663 row: &Row,
1664 params: &Params,
1665) -> Result<Option<Value>, String> {
1666 let norm = name.to_ascii_lowercase();
1667 match norm.as_str() {
1668 "tolower" => {
1669 if args.len() != 1 {
1670 return Err(format!(
1671 "toLower() requires exactly 1 argument, got {}",
1672 args.len()
1673 ));
1674 }
1675 let v = resolve_operand(view, vars, row, &args[0], params)?;
1676 Ok(v.map(|val| match val {
1677 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
1678 other => other, }))
1680 }
1681 "toupper" => {
1682 if args.len() != 1 {
1683 return Err(format!(
1684 "toUpper() requires exactly 1 argument, got {}",
1685 args.len()
1686 ));
1687 }
1688 let v = resolve_operand(view, vars, row, &args[0], params)?;
1689 Ok(v.map(|val| match val {
1690 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
1691 other => other,
1692 }))
1693 }
1694 "size" => {
1695 if args.len() != 1 {
1696 return Err(format!(
1697 "size() requires exactly 1 argument, got {}",
1698 args.len()
1699 ));
1700 }
1701 let v = resolve_operand(view, vars, row, &args[0], params)?;
1702 match v {
1703 None => Ok(None), Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
1705 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
1706 Some(_) => Ok(None), }
1708 }
1709 "coalesce" => {
1710 for arg in args {
1712 if let Some(v) = resolve_operand(view, vars, row, arg, params)? {
1713 return Ok(Some(v));
1714 }
1715 }
1716 Ok(None)
1717 }
1718 "type" => {
1719 if args.len() != 1 {
1720 return Err(format!(
1721 "type() requires exactly 1 argument, got {}",
1722 args.len()
1723 ));
1724 }
1725 let Operand::Var(var_name) = &args[0] else {
1727 return Err(
1728 "type() argument must be a relationship variable (e.g. type(r))".to_string(),
1729 );
1730 };
1731 let slot = vars
1732 .slot(var_name)
1733 .ok_or_else(|| format!("unbound variable `{var_name}` in type()"))?;
1734 match row.get(slot).and_then(|c| c.as_ref()) {
1735 Some(Cell::Rel(e)) => {
1736 let etype = view.syms.resolve(e.etype).unwrap_or("").to_owned();
1738 Ok(Some(Value::Str(etype)))
1739 }
1740 Some(Cell::Node(_)) => Err(format!(
1741 "type() argument `{var_name}` is a node, not a relationship"
1742 )),
1743 Some(Cell::Scalar(_) | Cell::Path(_)) => Err(format!(
1744 "type() argument `{var_name}` is not a relationship"
1745 )),
1746 None => Ok(None), }
1748 }
1749 "abs" => {
1750 if args.len() != 1 {
1751 return Err(format!(
1752 "abs() requires exactly 1 argument, got {}",
1753 args.len()
1754 ));
1755 }
1756 let v = resolve_operand(view, vars, row, &args[0], params)?;
1757 match v {
1758 None => Ok(None),
1759 Some(Value::Int(n)) => Ok(Some(Value::Int(n.abs()))),
1760 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
1761 Some(_) => Ok(None), }
1763 }
1764 "round" => {
1765 if args.len() != 1 {
1766 return Err(format!(
1767 "round() requires exactly 1 argument, got {}",
1768 args.len()
1769 ));
1770 }
1771 let v = resolve_operand(view, vars, row, &args[0], params)?;
1772 match v {
1773 None => Ok(None),
1774 Some(Value::Int(n)) => Ok(Some(Value::Int(n))), Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
1776 Some(_) => Ok(None), }
1778 }
1779 "textmatches" => {
1780 if args.len() != 2 {
1792 return Err(format!(
1793 "textMatches() requires exactly 2 arguments (field_value, query), got {}",
1794 args.len()
1795 ));
1796 }
1797 let field_val = resolve_operand(view, vars, row, &args[0], params)?;
1798 let query_val = resolve_operand(view, vars, row, &args[1], params)?;
1799 match (field_val, query_val) {
1800 (None, _) | (_, None) => Ok(None),
1801 (Some(Value::Str(s)), Some(Value::Str(q))) => {
1802 Ok(Some(Value::Bool(core_storage::fulltext::eval_query_str(
1804 &s, &q,
1805 ))))
1806 }
1807 (Some(Value::List(items)), Some(Value::Str(q))) => Ok(Some(Value::Bool(
1808 core_storage::fulltext::eval_query_str_list(&items, &q),
1809 ))),
1810 _ => Ok(Some(Value::Bool(false))), }
1812 }
1813 "contains" => eval_string_predicate("contains", args, view, vars, row, params, |s, sub| {
1814 s.contains(sub)
1815 }),
1816 "startswith" => {
1817 eval_string_predicate("startsWith", args, view, vars, row, params, |s, p| {
1818 s.starts_with(p)
1819 })
1820 }
1821 "endswith" => eval_string_predicate("endsWith", args, view, vars, row, params, |s, p| {
1822 s.ends_with(p)
1823 }),
1824 "tointeger" => {
1825 if args.len() != 1 {
1826 return Err(format!(
1827 "toInteger() requires exactly 1 argument, got {}",
1828 args.len()
1829 ));
1830 }
1831 let v = resolve_operand(view, vars, row, &args[0], params)?;
1832 Ok(match v {
1833 None => None,
1834 Some(Value::Int(n)) => Some(Value::Int(n)),
1835 Some(Value::Float(f)) => Some(Value::Int(f.trunc() as i64)),
1836 Some(Value::Str(s)) => s
1839 .trim()
1840 .parse::<i64>()
1841 .ok()
1842 .or_else(|| s.trim().parse::<f64>().ok().map(|f| f.trunc() as i64))
1843 .map(Value::Int),
1844 Some(_) => None,
1845 })
1846 }
1847 "tofloat" => {
1848 if args.len() != 1 {
1849 return Err(format!(
1850 "toFloat() requires exactly 1 argument, got {}",
1851 args.len()
1852 ));
1853 }
1854 let v = resolve_operand(view, vars, row, &args[0], params)?;
1855 Ok(match v {
1856 None => None,
1857 Some(Value::Float(f)) => Some(Value::Float(f)),
1858 Some(Value::Int(n)) => Some(Value::Float(n as f64)),
1859 Some(Value::Str(s)) => s.trim().parse::<f64>().ok().map(Value::Float),
1860 Some(_) => None,
1861 })
1862 }
1863 "tostring" => {
1864 if args.len() != 1 {
1865 return Err(format!(
1866 "toString() requires exactly 1 argument, got {}",
1867 args.len()
1868 ));
1869 }
1870 let v = resolve_operand(view, vars, row, &args[0], params)?;
1871 Ok(match v {
1872 None => None,
1873 Some(Value::Str(s)) => Some(Value::Str(s)),
1874 Some(Value::Int(n)) => Some(Value::Str(n.to_string())),
1875 Some(Value::Float(f)) => Some(Value::Str(f.to_string())),
1876 Some(Value::Bool(b)) => Some(Value::Str(b.to_string())),
1877 Some(_) => None, })
1879 }
1880 _ => Err(format!(
1881 "unknown function `{name}`; supported: {}",
1882 SCALAR_FUNCS.join(", ")
1883 )),
1884 }
1885}
1886
1887fn resolve_operand(
1888 view: &GraphView,
1889 vars: &VarTable,
1890 row: &Row,
1891 operand: &Operand,
1892 params: &Params,
1893) -> Result<Option<Value>, String> {
1894 match operand {
1895 Operand::Lit(v) => Ok(Some(v.clone())),
1896 Operand::Param(name) => match params.0.get(name) {
1897 Some(v) => Ok(Some(v.clone())),
1898 None => Err(format!("missing parameter `{name}`")),
1899 },
1900 Operand::Prop { var, field } => resolve_prop(view, vars, row, var, field),
1901 Operand::Var(name) => {
1902 match vars
1904 .slot(name)
1905 .and_then(|s| row.get(s))
1906 .and_then(|c| c.as_ref())
1907 {
1908 Some(Cell::Scalar(v)) => Ok(Some(v.clone())),
1909 Some(Cell::Node(id)) => match view.ids.key_of(*id) {
1910 Some(key) => Ok(Some(Value::Str(key.to_owned()))),
1911 None => Ok(None),
1912 },
1913 Some(Cell::Path(hops)) => Ok(Some(Value::Int(*hops as i64))),
1914 Some(Cell::Rel(_)) => Err(format!("variable `{name}` is a relationship")),
1915 None => Ok(None),
1916 }
1917 }
1918 Operand::FuncCall { name, args } => eval_func(name, args, view, vars, row, params),
1919 Operand::Case { branches, default } => {
1920 for (cond, value) in branches {
1921 if eval_expr(view, vars, row, cond, params, 0)? {
1922 return resolve_operand(view, vars, row, value, params);
1923 }
1924 }
1925 match default {
1926 Some(d) => resolve_operand(view, vars, row, d, params),
1927 None => Ok(None),
1928 }
1929 }
1930 Operand::BinArith { op, left, right } => {
1931 use super::ast::ArithOp;
1932 let lv = resolve_operand(view, vars, row, left, params)?;
1933 let rv = resolve_operand(view, vars, row, right, params)?;
1934 match (lv, rv) {
1935 (None, _) | (_, None) => Ok(None), (Some(Value::Int(a)), Some(Value::Int(b))) => {
1937 let result = match op {
1938 ArithOp::Sub => a.saturating_sub(b),
1939 ArithOp::Mul => a.saturating_mul(b),
1940 ArithOp::Add => a.saturating_add(b),
1941 ArithOp::Div => {
1942 if b == 0 {
1943 return Err("division by zero".into());
1944 }
1945 a.checked_div(b).unwrap_or(i64::MAX)
1946 }
1947 };
1948 Ok(Some(Value::Int(result)))
1949 }
1950 (Some(lv), Some(rv)) => {
1951 let a = match &lv {
1952 Value::Float(f) => *f,
1953 Value::Int(i) => *i as f64,
1954 _ => return Err(format!("arithmetic operand must be numeric, got {lv:?}")),
1955 };
1956 let b = match &rv {
1957 Value::Float(f) => *f,
1958 Value::Int(i) => *i as f64,
1959 _ => return Err(format!("arithmetic operand must be numeric, got {rv:?}")),
1960 };
1961 let result = match op {
1962 ArithOp::Sub => a - b,
1963 ArithOp::Mul => a * b,
1964 ArithOp::Add => a + b,
1965 ArithOp::Div => {
1966 if b == 0.0 {
1967 return Err("division by zero".into());
1968 }
1969 a / b
1970 }
1971 };
1972 Ok(Some(Value::Float(result)))
1973 }
1974 }
1975 }
1976 }
1977}
1978
1979fn resolve_prop(
1980 view: &GraphView,
1981 vars: &VarTable,
1982 row: &Row,
1983 var: &str,
1984 field: &str,
1985) -> Result<Option<Value>, String> {
1986 let slot = vars
1989 .slot(var)
1990 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1991 let cell = match row.get(slot).and_then(|c| c.as_ref()) {
1992 Some(c) => c,
1993 None => return Ok(None),
1995 };
1996 match cell {
1997 Cell::Node(id) => Ok(view.prop(*id, field).map(|vr| vr.into_value())),
1998 Cell::Rel(e) => Ok(view.edge_props.get(e.etype, e.src, e.dst, field)),
1999 Cell::Path(hops) => {
2001 if field == "length" {
2002 Ok(Some(Value::Int(*hops as i64)))
2003 } else {
2004 Ok(None)
2005 }
2006 }
2007 Cell::Scalar(_) => Ok(None),
2009 }
2010}
2011
2012fn node_matches(
2013 view: &GraphView,
2014 vars: &VarTable,
2015 row: &Row,
2016 id: u32,
2017 label: Option<&str>,
2018 props: &[(String, Operand)],
2019 params: &Params,
2020) -> Result<bool, String> {
2021 if let Some(want) = label {
2022 match view.label_of(id) {
2023 Some(got) if got == want => {}
2024 _ => return Ok(false),
2025 }
2026 }
2027 for (field, operand) in props {
2028 let Some(expected) = resolve_operand(view, vars, row, operand, params)? else {
2029 return Ok(false);
2030 };
2031 match view.prop(id, field) {
2032 Some(got) if values_equal(got.as_value(), &expected) => {}
2033 _ => return Ok(false),
2034 }
2035 }
2036 Ok(true)
2037}
2038
2039fn retain_node(
2040 view: &GraphView,
2041 vars: &VarTable,
2042 rows: &[Row],
2043 var: &str,
2044 label: Option<&str>,
2045 props: &[(String, Operand)],
2046 params: &Params,
2047) -> Result<Vec<Row>, String> {
2048 let mut out = Vec::with_capacity(rows.len());
2049 for row in rows {
2050 let id = require_node(row, vars, var)?;
2051 if node_matches(view, vars, row, id, label, props, params)? {
2052 out.push(row.clone());
2053 }
2054 }
2055 Ok(out)
2056}
2057
2058fn map_dir(dir: RelDir) -> Dir {
2059 match dir {
2060 RelDir::Right => Dir::Out,
2061 RelDir::Left => Dir::In,
2062 RelDir::Undirected => Dir::Both,
2063 }
2064}
2065
2066fn neighbor(from: u32, e: &EdgeRef, dir: RelDir) -> u32 {
2067 match dir {
2068 RelDir::Right => e.dst,
2069 RelDir::Left => e.src,
2070 RelDir::Undirected => {
2071 if e.src == from {
2072 e.dst
2073 } else {
2074 e.src
2075 }
2076 }
2077 }
2078}
2079
2080fn row_has_edge(row: &Row, e: &EdgeRef) -> bool {
2081 row.iter()
2082 .any(|c| matches!(c, Some(Cell::Rel(existing)) if existing == e))
2083}
2084
2085fn resolve_etypes(view: &GraphView, etypes: &[String]) -> Option<Vec<u32>> {
2086 if etypes.is_empty() {
2087 None } else {
2089 Some(etypes.iter().filter_map(|n| view.syms.get(n)).collect())
2092 }
2093}
2094
2095fn exec_expand(
2096 view: &GraphView,
2097 vars: &VarTable,
2098 rows: &[Row],
2099 op: &PlanOp,
2100 params: &Params,
2101) -> Result<Vec<Row>, String> {
2102 let PlanOp::Expand {
2103 from,
2104 rel_var,
2105 etypes,
2106 dir,
2107 to,
2108 to_label,
2109 to_props,
2110 } = op
2111 else {
2112 return Err("internal: expected Expand".into());
2113 };
2114 let etypes = resolve_etypes(view, etypes);
2115 let exp_dir = map_dir(*dir);
2116 let to_slot = vars
2117 .slot(to)
2118 .ok_or_else(|| format!("unbound variable `{to}`"))?;
2119 let rel_slot = rel_var.as_ref().and_then(|rv| vars.slot(rv));
2120 let cap = max_intermediate_rows();
2121 let mut out = Vec::with_capacity(rows.len().saturating_mul(2).min(cap));
2122 for row in rows {
2123 let from_id = require_node(row, vars, from)?;
2124 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
2125 Some(Cell::Node(id)) => Some(*id),
2126 Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
2127 return Err(format!("variable `{to}` is not a node"))
2128 }
2129 None => None,
2130 };
2131 for e in expand(view, from_id, etypes.as_deref(), exp_dir) {
2132 if row_has_edge(row, &e) {
2133 continue;
2134 }
2135 let nbr = neighbor(from_id, &e, *dir);
2136 if !view.visible(nbr) {
2137 continue;
2138 }
2139 if let Some(want) = bound_to {
2140 if nbr != want {
2141 continue;
2142 }
2143 }
2144 if !node_matches(view, vars, row, nbr, to_label.as_deref(), to_props, params)? {
2145 continue;
2146 }
2147 if out.len() >= cap {
2148 return Err(row_cap_err(cap));
2149 }
2150 let mut next = row.clone();
2151 if let Some(slot) = rel_slot {
2152 next[slot] = Some(Cell::Rel(e));
2153 }
2154 if bound_to.is_none() {
2155 next[to_slot] = Some(Cell::Node(nbr));
2156 }
2157 out.push(next);
2158 #[cfg(test)]
2159 record_expand_row();
2160 }
2161 }
2162 Ok(out)
2163}
2164
2165#[allow(clippy::too_many_arguments)]
2172fn exec_var_expand(
2173 view: &GraphView,
2174 vars: &VarTable,
2175 rows: &[Row],
2176 from: &str,
2177 rel_var: &Option<String>,
2178 etypes: &[String],
2179 dir: RelDir,
2180 to: &str,
2181 min: u8,
2182 max: u8,
2183) -> Result<Vec<Row>, String> {
2184 let etypes = resolve_etypes(view, etypes);
2185 let exp_dir = map_dir(dir);
2186 let to_slot = vars
2187 .slot(to)
2188 .ok_or_else(|| format!("unbound variable `{to}`"))?;
2189 let rel_slot = rel_var.as_ref().and_then(|rv| vars.slot(rv));
2190 let cap = max_intermediate_rows();
2191 let mut out: Vec<Row> = Vec::new();
2192
2193 for row in rows {
2194 let from_id = require_node(row, vars, from)?;
2195 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
2196 Some(Cell::Node(id)) => Some(*id),
2197 Some(_) => return Err(format!("variable `{to}` is not a node")),
2198 None => None,
2199 };
2200
2201 struct PathState {
2204 node: u32,
2205 edges: Vec<EdgeRef>,
2206 }
2207
2208 let mut frontier: Vec<PathState> = vec![PathState {
2209 node: from_id,
2210 edges: Vec::new(),
2211 }];
2212
2213 let mut frontier_count: usize = 0;
2218
2219 for depth in 1u8..=max {
2220 let mut next_frontier: Vec<PathState> = Vec::new();
2221 for state in &frontier {
2222 for e in expand(view, state.node, etypes.as_deref(), exp_dir) {
2223 if state.edges.contains(&e) {
2225 continue;
2226 }
2227 let nbr = neighbor(state.node, &e, dir);
2228 if !view.visible(nbr) {
2231 continue;
2232 }
2233
2234 if depth >= min {
2237 let dest_matches = match bound_to {
2238 Some(want) => nbr == want,
2239 None => true,
2240 };
2241 if dest_matches {
2242 if out.len() >= cap {
2243 return Err(row_cap_err(cap));
2244 }
2245 let mut next = row.clone();
2246 if let Some(slot) = rel_slot {
2247 next[slot] = Some(Cell::Path(depth));
2248 }
2249 next[to_slot] = Some(Cell::Node(nbr));
2250 out.push(next);
2251 }
2252 }
2253
2254 if depth < max {
2258 frontier_count += 1;
2259 if frontier_count >= cap {
2260 return Err(row_cap_err(cap));
2261 }
2262 let mut new_edges = state.edges.clone();
2263 new_edges.push(e);
2264 next_frontier.push(PathState {
2265 node: nbr,
2266 edges: new_edges,
2267 });
2268 }
2269 }
2270 }
2271 frontier = next_frontier;
2272 if frontier.is_empty() {
2273 break;
2274 }
2275 }
2276 }
2277 Ok(out)
2278}
2279
2280#[allow(clippy::too_many_arguments)]
2286fn exec_shortest_path(
2287 view: &GraphView,
2288 vars: &VarTable,
2289 rows: &[Row],
2290 from: &str,
2291 rel_var: &Option<String>,
2292 etypes: &[String],
2293 dir: RelDir,
2294 to: &str,
2295 max_hops: u8,
2296) -> Result<Vec<Row>, String> {
2297 let etypes = resolve_etypes(view, etypes);
2298 let exp_dir = map_dir(dir);
2299 let rel_slot = rel_var.as_ref().and_then(|rv| vars.slot(rv));
2300 let mut out: Vec<Row> = Vec::new();
2301
2302 for row in rows {
2303 let from_id = require_node(row, vars, from)?;
2304 let to_id = require_node(row, vars, to)?;
2305
2306 let mut visited = std::collections::BTreeSet::new();
2308 visited.insert(from_id);
2309 let mut frontier: Vec<u32> = vec![from_id];
2310
2311 'bfs: for depth in 1u8..=max_hops {
2312 let mut next_frontier: Vec<u32> = Vec::new();
2313 for &node in &frontier {
2314 for e in expand(view, node, etypes.as_deref(), exp_dir) {
2315 let nbr = neighbor(node, &e, dir);
2316 if !view.visible(nbr) {
2320 continue;
2321 }
2322 if nbr == to_id {
2323 let mut next = row.clone();
2325 if let Some(slot) = rel_slot {
2326 next[slot] = Some(Cell::Path(depth));
2327 }
2328 out.push(next);
2329 break 'bfs;
2330 }
2331 if !visited.contains(&nbr) {
2332 visited.insert(nbr);
2333 next_frontier.push(nbr);
2334 }
2335 }
2336 }
2337 frontier = next_frontier;
2338 if frontier.is_empty() {
2339 break;
2340 }
2341 }
2342 }
2343 Ok(out)
2344}
2345
2346fn exec_filter(
2347 view: &GraphView,
2348 vars: &VarTable,
2349 rows: &[Row],
2350 expr: &Expr,
2351 params: &Params,
2352) -> Result<Vec<Row>, String> {
2353 let mut out = Vec::with_capacity(rows.len());
2354 for row in rows {
2355 if eval_expr(view, vars, row, expr, params, 0)? {
2356 out.push(row.clone());
2357 }
2358 }
2359 Ok(out)
2360}
2361
2362struct PullCtx<'a> {
2378 view: &'a GraphView<'a>,
2379 vars: &'a VarTable,
2380 project_items: &'a [RetItem],
2381 params: &'a Params<'a>,
2382 bound: usize,
2383}
2384
2385fn execute_pull(
2386 view: &GraphView,
2387 plan: &[PlanOp],
2388 params: &Params,
2389 bound: usize,
2390) -> Result<ResultSet, String> {
2391 let proj_pos = match plan
2392 .iter()
2393 .position(|op| matches!(op, PlanOp::Project { .. }))
2394 {
2395 Some(p) => p,
2396 None => return Ok(ResultSet::new(vec![])),
2397 };
2398 let producers = &plan[..proj_pos];
2399 let project_items = match &plan[proj_pos] {
2400 PlanOp::Project { items } => items,
2401 _ => unreachable!(),
2402 };
2403 let columns: Vec<String> = project_items.iter().map(column_name).collect();
2404 let vars = collect_vars(plan);
2405 let ctx = PullCtx {
2406 view,
2407 vars: &vars,
2408 project_items,
2409 params,
2410 bound,
2411 };
2412 let mut initial_row: Row = vec![None; vars.names.len()];
2413 let mut result_rows: Vec<Vec<Option<Value>>> = Vec::with_capacity(bound);
2414 pull_rows(&ctx, producers, &mut initial_row, &mut result_rows)?;
2415 let skip_n = plan[proj_pos + 1..]
2418 .iter()
2419 .find_map(|op| match op {
2420 PlanOp::Skip(ls) => Some(resolve_ls(ls, params)),
2421 _ => None,
2422 })
2423 .transpose()?
2424 .unwrap_or(0);
2425 let skip_n = usize::try_from(skip_n).unwrap_or(usize::MAX);
2426 let mut rs = ResultSet::new(columns);
2427 for row in result_rows.into_iter().skip(skip_n) {
2428 rs.push_row(row);
2429 }
2430 Ok(rs)
2431}
2432
2433enum AggAcc {
2447 Count(u64),
2448 Sum { val: f64, has_value: bool },
2449 Avg { sum: f64, n: u64 },
2450 Min(Option<Value>),
2451 Max(Option<Value>),
2452 Collect(Vec<Value>),
2453}
2454
2455impl AggAcc {
2456 fn new(func: &AggFunc) -> Self {
2457 match func {
2458 AggFunc::Count => AggAcc::Count(0),
2459 AggFunc::Sum => AggAcc::Sum {
2460 val: 0.0,
2461 has_value: false,
2462 },
2463 AggFunc::Avg => AggAcc::Avg { sum: 0.0, n: 0 },
2464 AggFunc::Min => AggAcc::Min(None),
2465 AggFunc::Max => AggAcc::Max(None),
2466 AggFunc::Collect => AggAcc::Collect(Vec::new()),
2467 }
2468 }
2469
2470 fn finish(self) -> Option<Value> {
2471 match self {
2472 AggAcc::Count(n) => Some(Value::Int(i64::try_from(n).unwrap_or(i64::MAX))),
2477 AggAcc::Sum { val, has_value } => {
2478 if has_value {
2479 Some(Value::Float(val))
2480 } else {
2481 None
2482 }
2483 }
2484 AggAcc::Avg { sum, n } => {
2485 if n > 0 {
2486 Some(Value::Float(sum / n as f64))
2487 } else {
2488 None
2489 }
2490 }
2491 AggAcc::Min(v) => v,
2492 AggAcc::Max(v) => v,
2493 AggAcc::Collect(items) => Some(Value::List(items)),
2495 }
2496 }
2497}
2498
2499fn numeric_val(v: &Value) -> Option<f64> {
2502 match v {
2503 Value::Int(n) => Some(*n as f64),
2504 Value::Float(f) => Some(*f),
2505 _ => None,
2506 }
2507}
2508
2509struct AggStreamCtx<'a> {
2511 view: &'a GraphView<'a>,
2512 vars: &'a VarTable,
2513 params: &'a Params<'a>,
2514 func: &'a AggFunc,
2515 arg: &'a AggArg,
2516}
2517
2518fn execute_aggregate(
2523 view: &GraphView,
2524 plan: &[PlanOp],
2525 params: &Params,
2526) -> Result<ResultSet, String> {
2527 let agg_pos = match plan
2528 .iter()
2529 .position(|op| matches!(op, PlanOp::Aggregate { .. }))
2530 {
2531 Some(p) => p,
2532 None => return Ok(ResultSet::new(vec![])),
2533 };
2534 let producers = &plan[..agg_pos];
2535 let (func, arg, column) = match &plan[agg_pos] {
2536 PlanOp::Aggregate { func, arg, column } => (func, arg, column),
2537 _ => unreachable!(),
2538 };
2539 let vars = collect_vars(plan);
2540 let ctx = AggStreamCtx {
2541 view,
2542 vars: &vars,
2543 params,
2544 func,
2545 arg,
2546 };
2547 let initial_row: Row = vec![None; vars.names.len()];
2548 let mut acc = AggAcc::new(func);
2549 agg_stream(&ctx, producers, &initial_row, &mut acc)?;
2550
2551 let value = acc.finish();
2552 let mut rs = ResultSet::new(vec![column.clone()]);
2553 rs.push_row(vec![value]);
2554 Ok(rs)
2555}
2556
2557fn update_acc(
2563 view: &GraphView,
2564 vars: &VarTable,
2565 row: &Row,
2566 func: &AggFunc,
2567 arg: &AggArg,
2568 acc: &mut AggAcc,
2569) -> Result<(), String> {
2570 match (func, arg) {
2571 (AggFunc::Count, AggArg::Star) => {
2572 if let AggAcc::Count(n) = acc {
2573 *n += 1;
2574 }
2575 }
2576 (AggFunc::Count, AggArg::Var(v)) => {
2577 let slot = vars.slot(v);
2578 let is_bound = slot
2579 .and_then(|s| row.get(s))
2580 .and_then(|c| c.as_ref())
2581 .is_some();
2582 if is_bound {
2583 if let AggAcc::Count(n) = acc {
2584 *n += 1;
2585 }
2586 }
2587 }
2588 (AggFunc::Count, AggArg::Prop { var, field }) => {
2589 let val = resolve_prop(view, vars, row, var, field)?;
2590 if val.is_some() {
2591 if let AggAcc::Count(n) = acc {
2592 *n += 1;
2593 }
2594 }
2595 }
2596 (AggFunc::Sum, AggArg::Prop { var, field }) => {
2597 if let Some(v) = resolve_prop(view, vars, row, var, field)? {
2598 if let Some(num) = numeric_val(&v) {
2599 if let AggAcc::Sum { val, has_value } = acc {
2600 *val += num;
2601 *has_value = true;
2602 }
2603 }
2604 }
2605 }
2606 (AggFunc::Avg, AggArg::Prop { var, field }) => {
2607 if let Some(v) = resolve_prop(view, vars, row, var, field)? {
2608 if let Some(num) = numeric_val(&v) {
2609 if let AggAcc::Avg { sum, n } = acc {
2610 *sum += num;
2611 *n += 1;
2612 }
2613 }
2614 }
2615 }
2616 (AggFunc::Min, AggArg::Prop { var, field }) => {
2617 if let Some(v) = resolve_prop(view, vars, row, var, field)? {
2618 if numeric_val(&v).is_some() {
2619 if let AggAcc::Min(current) = acc {
2620 *current = Some(match current.take() {
2621 None => v,
2622 Some(prev) => {
2623 if cmp_optional(Some(&prev), Some(&v), false)
2624 == std::cmp::Ordering::Greater
2625 {
2626 v
2627 } else {
2628 prev
2629 }
2630 }
2631 });
2632 }
2633 }
2634 }
2635 }
2636 (AggFunc::Max, AggArg::Prop { var, field }) => {
2637 if let Some(v) = resolve_prop(view, vars, row, var, field)? {
2638 if numeric_val(&v).is_some() {
2639 if let AggAcc::Max(current) = acc {
2640 *current = Some(match current.take() {
2641 None => v,
2642 Some(prev) => {
2643 if cmp_optional(Some(&prev), Some(&v), true)
2644 == std::cmp::Ordering::Greater
2645 {
2646 v
2647 } else {
2648 prev
2649 }
2650 }
2651 });
2652 }
2653 }
2654 }
2655 }
2656 (AggFunc::Collect, AggArg::Var(v)) => {
2657 let val = vars
2660 .slot(v)
2661 .and_then(|s| row.get(s))
2662 .and_then(|c| c.as_ref())
2663 .and_then(|cell| match cell {
2664 Cell::Scalar(x) => Some(x.clone()),
2665 Cell::Node(id) => view.ids.key_of(*id).map(|k| Value::Str(k.to_owned())),
2666 Cell::Path(h) => Some(Value::Int(*h as i64)),
2667 Cell::Rel(_) => None,
2668 });
2669 if let Some(val) = val {
2670 if let AggAcc::Collect(items) = acc {
2671 items.push(val);
2672 }
2673 }
2674 }
2675 (AggFunc::Collect, AggArg::Prop { var, field }) => {
2676 if let Some(val) = resolve_prop(view, vars, row, var, field)? {
2677 if let AggAcc::Collect(items) = acc {
2678 items.push(val);
2679 }
2680 }
2681 }
2682 _ => {}
2685 }
2686 Ok(())
2687}
2688
2689fn agg_stream(
2692 ctx: &AggStreamCtx<'_>,
2693 ops: &[PlanOp],
2694 row: &Row,
2695 acc: &mut AggAcc,
2696) -> Result<(), String> {
2697 let (op, rest) = match ops.split_first() {
2698 Some(pair) => pair,
2699 None => {
2700 update_acc(ctx.view, ctx.vars, row, ctx.func, ctx.arg, acc)?;
2702 return Ok(());
2703 }
2704 };
2705
2706 match op {
2707 PlanOp::ScanLabel { var, label } => {
2708 let ids = scan_ids(ctx.view, label.as_deref());
2709 let slot = ctx
2710 .vars
2711 .slot(var)
2712 .ok_or_else(|| format!("unbound variable `{var}`"))?;
2713 for &id in &ids {
2714 let mut next = row.clone();
2715 next[slot] = Some(Cell::Node(id));
2716 agg_stream(ctx, rest, &next, acc)?;
2717 }
2718 }
2719 PlanOp::ScanKey { var, key, label } => {
2720 let slot = ctx
2721 .vars
2722 .slot(var)
2723 .ok_or_else(|| format!("unbound variable `{var}`"))?;
2724 if let Some(id) =
2725 resolve_scan_key_id(ctx.view, ctx.vars, row, key, label.as_deref(), ctx.params)?
2726 {
2727 let mut next = row.clone();
2728 next[slot] = Some(Cell::Node(id));
2729 agg_stream(ctx, rest, &next, acc)?;
2730 }
2731 }
2732 PlanOp::IndexScan {
2733 var,
2734 label,
2735 field,
2736 value,
2737 } => {
2738 let ids = index_scan_ids(
2739 ctx.view,
2740 ctx.vars,
2741 row,
2742 label.as_deref(),
2743 field,
2744 value,
2745 ctx.params,
2746 )?;
2747 let slot = ctx
2748 .vars
2749 .slot(var)
2750 .ok_or_else(|| format!("unbound variable `{var}`"))?;
2751 for &id in &ids {
2752 let mut next = row.clone();
2753 next[slot] = Some(Cell::Node(id));
2754 agg_stream(ctx, rest, &next, acc)?;
2755 }
2756 }
2757 PlanOp::IndexIntersect {
2758 var,
2759 label,
2760 equalities,
2761 } => {
2762 let ids = index_intersect_ids(
2763 ctx.view,
2764 ctx.vars,
2765 row,
2766 label.as_deref(),
2767 equalities,
2768 ctx.params,
2769 )?;
2770 let slot = ctx
2771 .vars
2772 .slot(var)
2773 .ok_or_else(|| format!("unbound variable `{var}`"))?;
2774 for id in ids {
2775 let mut next = row.clone();
2776 next[slot] = Some(Cell::Node(id));
2777 agg_stream(ctx, rest, &next, acc)?;
2778 }
2779 }
2780 PlanOp::Expand {
2781 from,
2782 rel_var,
2783 etypes,
2784 dir,
2785 to,
2786 to_label,
2787 to_props,
2788 } => {
2789 let etypes = resolve_etypes(ctx.view, etypes);
2790 let exp_dir = map_dir(*dir);
2791 let to_slot = ctx
2792 .vars
2793 .slot(to)
2794 .ok_or_else(|| format!("unbound variable `{to}`"))?;
2795 let rel_slot = rel_var.as_ref().and_then(|rv| ctx.vars.slot(rv));
2796 let from_id = require_node(row, ctx.vars, from)?;
2797 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
2798 Some(Cell::Node(id)) => Some(*id),
2799 Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
2800 return Err(format!("variable `{to}` is not a node"))
2801 }
2802 None => None,
2803 };
2804 for e in expand(ctx.view, from_id, etypes.as_deref(), exp_dir) {
2805 if row_has_edge(row, &e) {
2806 continue;
2807 }
2808 let nbr = neighbor(from_id, &e, *dir);
2809 if !ctx.view.visible(nbr) {
2810 continue;
2811 }
2812 if let Some(want) = bound_to {
2813 if nbr != want {
2814 continue;
2815 }
2816 }
2817 if !node_matches(
2818 ctx.view,
2819 ctx.vars,
2820 row,
2821 nbr,
2822 to_label.as_deref(),
2823 to_props,
2824 ctx.params,
2825 )? {
2826 continue;
2827 }
2828 let mut next = row.clone();
2829 if let Some(slot) = rel_slot {
2830 next[slot] = Some(Cell::Rel(e));
2831 }
2832 if bound_to.is_none() {
2833 next[to_slot] = Some(Cell::Node(nbr));
2834 }
2835 agg_stream(ctx, rest, &next, acc)?;
2836 }
2837 }
2838 PlanOp::Filter { expr } => {
2839 if eval_expr(ctx.view, ctx.vars, row, expr, ctx.params, 0)? {
2840 agg_stream(ctx, rest, row, acc)?;
2841 }
2842 }
2843 PlanOp::LookupProps { var, props } => {
2844 let id = require_node(row, ctx.vars, var)?;
2845 if node_matches(ctx.view, ctx.vars, row, id, None, props, ctx.params)? {
2846 agg_stream(ctx, rest, row, acc)?;
2847 }
2848 }
2849 PlanOp::JoinBound { var, label, props } => {
2850 let id = require_node(row, ctx.vars, var)?;
2851 if node_matches(
2852 ctx.view,
2853 ctx.vars,
2854 row,
2855 id,
2856 label.as_deref(),
2857 props,
2858 ctx.params,
2859 )? {
2860 agg_stream(ctx, rest, row, acc)?;
2861 }
2862 }
2863 PlanOp::VarExpand {
2864 from,
2865 rel_var,
2866 etypes,
2867 dir,
2868 to,
2869 min,
2870 max,
2871 } => {
2872 let new_rows = exec_var_expand(
2873 ctx.view,
2874 ctx.vars,
2875 std::slice::from_ref(row),
2876 from,
2877 rel_var,
2878 etypes,
2879 *dir,
2880 to,
2881 *min,
2882 *max,
2883 )?;
2884 for nr in &new_rows {
2885 agg_stream(ctx, rest, nr, acc)?;
2886 }
2887 }
2888 PlanOp::ShortestPath {
2889 from,
2890 rel_var,
2891 etypes,
2892 dir,
2893 to,
2894 max_hops,
2895 } => {
2896 let new_rows = exec_shortest_path(
2897 ctx.view,
2898 ctx.vars,
2899 std::slice::from_ref(row),
2900 from,
2901 rel_var,
2902 etypes,
2903 *dir,
2904 to,
2905 *max_hops,
2906 )?;
2907 for nr in &new_rows {
2908 agg_stream(ctx, rest, nr, acc)?;
2909 }
2910 }
2911 PlanOp::Project { .. } => {
2913 return Err(
2914 "agg executor: Project in producer slice — plan is structurally malformed"
2915 .to_string(),
2916 );
2917 }
2918 PlanOp::Distinct => {
2919 return Err(
2920 "agg executor: Distinct in producer slice — plan is structurally malformed"
2921 .to_string(),
2922 );
2923 }
2924 PlanOp::OrderBy { .. } => {
2925 return Err(
2926 "agg executor: OrderBy in producer slice — structurally malformed".to_string(),
2927 );
2928 }
2929 PlanOp::Skip(_) => {
2930 return Err(
2931 "agg executor: Skip in producer slice — structurally malformed".to_string(),
2932 );
2933 }
2934 PlanOp::Limit(_) => {
2935 return Err(
2936 "agg executor: Limit in producer slice — structurally malformed".to_string(),
2937 );
2938 }
2939 PlanOp::Aggregate { .. } => {
2940 return Err(
2941 "agg executor: nested Aggregate in producer slice — structurally malformed"
2942 .to_string(),
2943 );
2944 }
2945 PlanOp::GroupAggregate { .. } => {
2946 return Err(
2947 "agg executor: GroupAggregate in producer slice — structurally malformed"
2948 .to_string(),
2949 );
2950 }
2951 PlanOp::With { .. } => {
2952 return Err(
2953 "agg executor: With in producer slice — structurally malformed".to_string(),
2954 );
2955 }
2956 PlanOp::Unwind { .. } => {
2957 return Err(
2958 "agg executor: Unwind in producer slice — structurally malformed".to_string(),
2959 );
2960 }
2961 PlanOp::LeftOuterApply { .. } => {
2962 return Err(
2963 "agg executor: LeftOuterApply in producer slice — structurally malformed"
2964 .to_string(),
2965 );
2966 }
2967 }
2968 Ok(())
2969}
2970
2971struct GroupStreamCtx<'a> {
2979 view: &'a GraphView<'a>,
2980 vars: &'a VarTable,
2981 params: &'a Params<'a>,
2982 keys: &'a [(String, RetItem)],
2983 aggs: &'a [(AggFunc, AggArg, String)],
2984}
2985
2986fn build_group_projected(
2988 keys: &[(String, RetItem)],
2989 aggs: &[(AggFunc, AggArg, String)],
2990 key_order: Vec<GroupKey>,
2991 groups: &mut HashMap<GroupKey, GroupEntry>,
2992) -> Projected {
2993 let columns: Vec<String> = keys
2994 .iter()
2995 .map(|(col, _)| col.clone())
2996 .chain(aggs.iter().map(|(_, _, col)| col.clone()))
2997 .collect();
2998 let mut rows: Vec<Vec<Option<Value>>> = Vec::with_capacity(key_order.len());
2999 for gk in key_order {
3000 let (display_keys, accs) = groups.remove(&gk).unwrap_or_default();
3001 let mut row: Vec<Option<Value>> = Vec::with_capacity(columns.len());
3002 for display_val in display_keys {
3005 row.push(display_val);
3006 }
3007 for acc in accs {
3008 row.push(acc.finish());
3009 }
3010 rows.push(row);
3011 }
3012 Projected { columns, rows }
3013}
3014
3015fn group_result_to_rows(
3022 keys: &[(String, RetItem)],
3023 aggs: &[(AggFunc, AggArg, String)],
3024 key_order: Vec<GroupKey>,
3025 groups: &mut HashMap<GroupKey, GroupEntry>,
3026 vars: &VarTable,
3027) -> Vec<Row> {
3028 let row_len = vars.names.len();
3029 let mut out: Vec<Row> = Vec::with_capacity(key_order.len());
3030 for gk in key_order {
3031 let (display_keys, accs) = groups.remove(&gk).unwrap_or_default();
3032 let mut row: Row = vec![None; row_len];
3033 for ((col, _), val) in keys.iter().zip(display_keys) {
3034 if let Some(slot) = vars.slot(col) {
3035 row[slot] = val.map(Cell::Scalar);
3036 }
3037 }
3038 for ((_, _, col), acc) in aggs.iter().zip(accs) {
3039 if let Some(slot) = vars.slot(col) {
3040 row[slot] = acc.finish().map(Cell::Scalar);
3041 }
3042 }
3043 out.push(row);
3044 }
3045 out
3046}
3047
3048fn exec_order_by_rows(vars: &VarTable, rows: &mut Vec<Row>, items: &[OrderItem], view: &GraphView) {
3055 let mut key_table: Vec<Vec<Option<Value>>> = Vec::with_capacity(rows.len());
3058 for row in rows.iter() {
3059 let mut row_key: Vec<Option<Value>> = Vec::with_capacity(items.len());
3060 for item in items {
3061 let val = match &item.target {
3062 OrderTarget::Alias(name) | OrderTarget::Var(name) => vars
3063 .slot(name)
3064 .and_then(|s| row.get(s))
3065 .and_then(|c| c.as_ref())
3066 .and_then(|c| match c {
3067 Cell::Scalar(v) => Some(v.clone()),
3068 Cell::Node(id) => view.ids.key_of(*id).map(|k| Value::Str(k.to_owned())),
3069 Cell::Path(hops) => Some(Value::Int(*hops as i64)),
3070 Cell::Rel(_) => None,
3071 }),
3072 OrderTarget::Prop { var, field } => vars
3073 .slot(var)
3074 .and_then(|s| row.get(s))
3075 .and_then(|c| c.as_ref())
3076 .and_then(|c| match c {
3077 Cell::Node(id) => view.prop(*id, field).map(|vr| vr.into_value()),
3078 Cell::Rel(e) => view.edge_props.get(e.etype, e.src, e.dst, field),
3079 _ => None,
3080 }),
3081 };
3082 row_key.push(val);
3083 }
3084 key_table.push(row_key);
3085 }
3086 let mut indices: Vec<usize> = (0..rows.len()).collect();
3088 indices.sort_by(|&a, &b| {
3089 for (ki, item) in items.iter().enumerate() {
3090 let c = cmp_optional(
3091 key_table[a].get(ki).and_then(|x| x.as_ref()),
3092 key_table[b].get(ki).and_then(|x| x.as_ref()),
3093 item.descending,
3094 );
3095 if c != std::cmp::Ordering::Equal {
3096 return c;
3097 }
3098 }
3099 std::cmp::Ordering::Equal
3100 });
3101 let sorted: Vec<Row> = indices.into_iter().map(|i| rows[i].clone()).collect();
3102 *rows = sorted;
3103}
3104
3105fn execute_group_aggregate(
3111 view: &GraphView,
3112 plan: &[PlanOp],
3113 params: &Params,
3114) -> Result<ResultSet, String> {
3115 let gagg_pos = plan
3116 .iter()
3117 .position(|op| matches!(op, PlanOp::GroupAggregate { .. }))
3118 .ok_or_else(|| "internal: GroupAggregate op not found in plan".to_string())?;
3119 let producers = &plan[..gagg_pos];
3120 let (keys, aggs) = match &plan[gagg_pos] {
3121 PlanOp::GroupAggregate { keys, aggs } => (keys, aggs),
3122 _ => unreachable!(),
3123 };
3124 let tail = &plan[gagg_pos + 1..];
3125 let vars = collect_vars(plan);
3126 let initial_row: Row = vec![None; vars.names.len()];
3127 let mut groups: HashMap<GroupKey, GroupEntry> = HashMap::new();
3128 let mut key_order: Vec<GroupKey> = Vec::new();
3129 let ctx = GroupStreamCtx {
3130 view,
3131 vars: &vars,
3132 params,
3133 keys,
3134 aggs,
3135 };
3136 group_stream(&ctx, producers, &initial_row, &mut groups, &mut key_order)?;
3137 if keys.is_empty() && key_order.is_empty() {
3141 let empty_key: GroupKey = vec![];
3142 key_order.push(empty_key.clone());
3143 groups.insert(
3144 empty_key,
3145 (
3146 vec![],
3147 aggs.iter().map(|(f, _, _)| AggAcc::new(f)).collect(),
3148 ),
3149 );
3150 }
3151 let mut projected = build_group_projected(keys, aggs, key_order, &mut groups);
3152 for op in tail {
3154 match op {
3155 PlanOp::OrderBy { items } => exec_order_by(&mut projected, items)?,
3156 PlanOp::Skip(ls) => {
3157 let n = resolve_ls(ls, params)?;
3158 apply_skip(&mut projected.rows, n);
3159 }
3160 PlanOp::Limit(ls) => {
3161 let n = resolve_ls(ls, params)?;
3162 apply_limit(&mut projected.rows, n);
3163 }
3164 _ => {} }
3166 }
3167 Ok(finish(projected))
3168}
3169
3170fn group_stream(
3177 ctx: &GroupStreamCtx<'_>,
3178 ops: &[PlanOp],
3179 row: &Row,
3180 groups: &mut HashMap<GroupKey, GroupEntry>,
3181 key_order: &mut Vec<GroupKey>,
3182) -> Result<(), String> {
3183 let (op, rest) = match ops.split_first() {
3184 Some(pair) => pair,
3185 None => {
3186 let mut gk: GroupKey = Vec::with_capacity(ctx.keys.len());
3189 let mut display_vals: Vec<Option<Value>> = Vec::with_capacity(ctx.keys.len());
3190 for (_, item) in ctx.keys {
3191 let val = project_item(ctx.view, ctx.vars, row, item, ctx.params)?;
3192 gk.push(val.as_ref().and_then(group_key_normalize));
3193 display_vals.push(val);
3194 }
3195 if !groups.contains_key(&gk) {
3196 if groups.len() >= max_groups() {
3197 return Err(group_cap_err());
3198 }
3199 key_order.push(gk.clone());
3200 let init: Vec<AggAcc> = ctx.aggs.iter().map(|(f, _, _)| AggAcc::new(f)).collect();
3201 groups.insert(gk.clone(), (display_vals, init));
3202 }
3203 let (_, accs) = groups.get_mut(&gk).unwrap();
3204 for (acc, (func, arg, _)) in accs.iter_mut().zip(ctx.aggs.iter()) {
3205 update_acc(ctx.view, ctx.vars, row, func, arg, acc)?;
3206 }
3207 return Ok(());
3208 }
3209 };
3210
3211 match op {
3212 PlanOp::ScanLabel { var, label } => {
3213 let ids = scan_ids(ctx.view, label.as_deref());
3214 let slot = ctx
3215 .vars
3216 .slot(var)
3217 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3218 for &id in &ids {
3219 let mut next = row.clone();
3220 next[slot] = Some(Cell::Node(id));
3221 group_stream(ctx, rest, &next, groups, key_order)?;
3222 }
3223 }
3224 PlanOp::ScanKey { var, key, label } => {
3225 let slot = ctx
3226 .vars
3227 .slot(var)
3228 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3229 if let Some(id) =
3230 resolve_scan_key_id(ctx.view, ctx.vars, row, key, label.as_deref(), ctx.params)?
3231 {
3232 let mut next = row.clone();
3233 next[slot] = Some(Cell::Node(id));
3234 group_stream(ctx, rest, &next, groups, key_order)?;
3235 }
3236 }
3237 PlanOp::IndexScan {
3238 var,
3239 label,
3240 field,
3241 value,
3242 } => {
3243 let ids = index_scan_ids(
3244 ctx.view,
3245 ctx.vars,
3246 row,
3247 label.as_deref(),
3248 field,
3249 value,
3250 ctx.params,
3251 )?;
3252 let slot = ctx
3253 .vars
3254 .slot(var)
3255 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3256 for &id in &ids {
3257 let mut next = row.clone();
3258 next[slot] = Some(Cell::Node(id));
3259 group_stream(ctx, rest, &next, groups, key_order)?;
3260 }
3261 }
3262 PlanOp::IndexIntersect {
3263 var,
3264 label,
3265 equalities,
3266 } => {
3267 let ids = index_intersect_ids(
3268 ctx.view,
3269 ctx.vars,
3270 row,
3271 label.as_deref(),
3272 equalities,
3273 ctx.params,
3274 )?;
3275 let slot = ctx
3276 .vars
3277 .slot(var)
3278 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3279 for id in ids {
3280 let mut next = row.clone();
3281 next[slot] = Some(Cell::Node(id));
3282 group_stream(ctx, rest, &next, groups, key_order)?;
3283 }
3284 }
3285 PlanOp::Expand {
3286 from,
3287 rel_var,
3288 etypes,
3289 dir,
3290 to,
3291 to_label,
3292 to_props,
3293 } => {
3294 let etypes = resolve_etypes(ctx.view, etypes);
3295 let exp_dir = map_dir(*dir);
3296 let to_slot = ctx
3297 .vars
3298 .slot(to)
3299 .ok_or_else(|| format!("unbound variable `{to}`"))?;
3300 let rel_slot = rel_var.as_ref().and_then(|rv| ctx.vars.slot(rv));
3301 let from_id = require_node(row, ctx.vars, from)?;
3302 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
3303 Some(Cell::Node(id)) => Some(*id),
3304 Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
3305 return Err(format!("variable `{to}` is not a node"))
3306 }
3307 None => None,
3308 };
3309 for e in expand(ctx.view, from_id, etypes.as_deref(), exp_dir) {
3310 if row_has_edge(row, &e) {
3311 continue;
3312 }
3313 let nbr = neighbor(from_id, &e, *dir);
3314 if !ctx.view.visible(nbr) {
3315 continue;
3316 }
3317 if let Some(want) = bound_to {
3318 if nbr != want {
3319 continue;
3320 }
3321 }
3322 if !node_matches(
3323 ctx.view,
3324 ctx.vars,
3325 row,
3326 nbr,
3327 to_label.as_deref(),
3328 to_props,
3329 ctx.params,
3330 )? {
3331 continue;
3332 }
3333 let mut next = row.clone();
3334 if let Some(slot) = rel_slot {
3335 next[slot] = Some(Cell::Rel(e));
3336 }
3337 if bound_to.is_none() {
3338 next[to_slot] = Some(Cell::Node(nbr));
3339 }
3340 group_stream(ctx, rest, &next, groups, key_order)?;
3341 }
3342 }
3343 PlanOp::Filter { expr } => {
3344 if eval_expr(ctx.view, ctx.vars, row, expr, ctx.params, 0)? {
3345 group_stream(ctx, rest, row, groups, key_order)?;
3346 }
3347 }
3348 PlanOp::LookupProps { var, props } => {
3349 let id = require_node(row, ctx.vars, var)?;
3350 if node_matches(ctx.view, ctx.vars, row, id, None, props, ctx.params)? {
3351 group_stream(ctx, rest, row, groups, key_order)?;
3352 }
3353 }
3354 PlanOp::JoinBound { var, label, props } => {
3355 let id = require_node(row, ctx.vars, var)?;
3356 if node_matches(
3357 ctx.view,
3358 ctx.vars,
3359 row,
3360 id,
3361 label.as_deref(),
3362 props,
3363 ctx.params,
3364 )? {
3365 group_stream(ctx, rest, row, groups, key_order)?;
3366 }
3367 }
3368 PlanOp::VarExpand {
3369 from,
3370 rel_var,
3371 etypes,
3372 dir,
3373 to,
3374 min,
3375 max,
3376 } => {
3377 let new_rows = exec_var_expand(
3378 ctx.view,
3379 ctx.vars,
3380 std::slice::from_ref(row),
3381 from,
3382 rel_var,
3383 etypes,
3384 *dir,
3385 to,
3386 *min,
3387 *max,
3388 )?;
3389 for nr in &new_rows {
3390 group_stream(ctx, rest, nr, groups, key_order)?;
3391 }
3392 }
3393 PlanOp::ShortestPath {
3394 from,
3395 rel_var,
3396 etypes,
3397 dir,
3398 to,
3399 max_hops,
3400 } => {
3401 let new_rows = exec_shortest_path(
3402 ctx.view,
3403 ctx.vars,
3404 std::slice::from_ref(row),
3405 from,
3406 rel_var,
3407 etypes,
3408 *dir,
3409 to,
3410 *max_hops,
3411 )?;
3412 for nr in &new_rows {
3413 group_stream(ctx, rest, nr, groups, key_order)?;
3414 }
3415 }
3416 PlanOp::Project { .. } => {
3418 return Err(
3419 "group executor: Project in producer slice — structurally malformed".to_string(),
3420 );
3421 }
3422 PlanOp::Distinct => {
3423 return Err(
3424 "group executor: Distinct in producer slice — structurally malformed".to_string(),
3425 );
3426 }
3427 PlanOp::OrderBy { .. } => {
3428 return Err(
3429 "group executor: OrderBy in producer slice — structurally malformed".to_string(),
3430 );
3431 }
3432 PlanOp::Skip(_) => {
3433 return Err(
3434 "group executor: Skip in producer slice — structurally malformed".to_string(),
3435 );
3436 }
3437 PlanOp::Limit(_) => {
3438 return Err(
3439 "group executor: Limit in producer slice — structurally malformed".to_string(),
3440 );
3441 }
3442 PlanOp::Aggregate { .. } => {
3443 return Err(
3444 "group executor: Aggregate in producer slice — structurally malformed".to_string(),
3445 );
3446 }
3447 PlanOp::GroupAggregate { .. } => {
3448 return Err(
3449 "group executor: nested GroupAggregate in producer slice — structurally malformed"
3450 .to_string(),
3451 );
3452 }
3453 PlanOp::With { .. } => {
3454 return Err(
3455 "group executor: With in producer slice — structurally malformed".to_string(),
3456 );
3457 }
3458 PlanOp::Unwind { .. } => {
3459 return Err(
3460 "group executor: Unwind in producer slice — structurally malformed".to_string(),
3461 );
3462 }
3463 PlanOp::LeftOuterApply { .. } => {
3464 return Err(
3465 "group executor: LeftOuterApply in producer slice — structurally malformed"
3466 .to_string(),
3467 );
3468 }
3469 }
3470 Ok(())
3471}
3472
3473fn pull_rows(
3487 ctx: &PullCtx<'_>,
3488 ops: &[PlanOp],
3489 row: &mut Row,
3490 result: &mut Vec<Vec<Option<Value>>>,
3491) -> Result<(), String> {
3492 if result.len() >= ctx.bound {
3493 return Ok(());
3494 }
3495 let (op, rest) = match ops.split_first() {
3496 Some(pair) => pair,
3497 None => {
3498 let mut cells = Vec::with_capacity(ctx.project_items.len());
3500 for item in ctx.project_items {
3501 cells.push(project_item(ctx.view, ctx.vars, row, item, ctx.params)?);
3502 }
3503 result.push(cells);
3504 return Ok(());
3505 }
3506 };
3507 match op {
3508 PlanOp::ScanLabel { var, label } => {
3509 let slot = ctx
3510 .vars
3511 .slot(var)
3512 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3513 let want_sym = label.as_deref().and_then(|l| ctx.view.syms.get(l));
3516 if label.is_some() && want_sym.is_none() {
3517 return Ok(());
3518 }
3519 let prev = row[slot].clone();
3521
3522 let fused_filter = rest.first().and_then(|next_op| {
3528 if let PlanOp::Filter {
3529 expr:
3530 Expr::Cmp {
3531 lhs:
3532 Operand::Prop {
3533 var: ref fv,
3534 field: ref f,
3535 },
3536 op: ref cmp_op_ref,
3537 rhs: Operand::Lit(ref lit),
3538 },
3539 } = *next_op
3540 {
3541 if fv == var {
3542 return Some((f.as_str(), cmp_op_ref, lit));
3543 }
3544 }
3545 None
3546 });
3547
3548 if let Some((field, cmp_op_ref, lit)) = fused_filter {
3549 #[cfg(test)]
3552 FUSED_SCAN_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3553 let col = ctx.view.props.column(field);
3554 let rest_after_filter = &rest[1..];
3555 for (i, &sym) in ctx.view.labels.iter().enumerate() {
3556 if result.len() >= ctx.bound {
3557 break;
3558 }
3559 if sym == u32::MAX {
3560 continue;
3561 }
3562 if let Some(ws) = want_sym {
3563 if sym != ws {
3564 continue;
3565 }
3566 }
3567 let id = i as u32;
3568 if !ctx.view.visible(id) {
3569 continue;
3570 }
3571 if let Some(v) = col.get(id) {
3572 if eval_cmp(cmp_op_ref, v, lit) {
3573 row[slot] = Some(Cell::Node(id));
3574 pull_rows(ctx, rest_after_filter, row, result)?;
3575 }
3576 }
3577 }
3578 } else {
3579 for (i, &sym) in ctx.view.labels.iter().enumerate() {
3582 if result.len() >= ctx.bound {
3583 break;
3584 }
3585 if sym == u32::MAX {
3587 continue;
3588 }
3589 if let Some(ws) = want_sym {
3591 if sym != ws {
3592 continue;
3593 }
3594 }
3595 let id = i as u32;
3596 if !ctx.view.visible(id) {
3597 continue;
3598 }
3599 row[slot] = Some(Cell::Node(id));
3600 pull_rows(ctx, rest, row, result)?;
3601 }
3602 }
3603 row[slot] = prev;
3609 }
3610 PlanOp::ScanKey { var, key, label } => {
3611 let slot = ctx
3612 .vars
3613 .slot(var)
3614 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3615 if let Some(id) =
3616 resolve_scan_key_id(ctx.view, ctx.vars, row, key, label.as_deref(), ctx.params)?
3617 {
3618 let prev = row[slot].clone();
3619 row[slot] = Some(Cell::Node(id));
3620 pull_rows(ctx, rest, row, result)?;
3621 row[slot] = prev;
3622 }
3623 }
3624 PlanOp::IndexScan {
3625 var,
3626 label,
3627 field,
3628 value,
3629 } => {
3630 let slot = ctx
3631 .vars
3632 .slot(var)
3633 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3634 let ids = index_scan_ids(
3635 ctx.view,
3636 ctx.vars,
3637 row,
3638 label.as_deref(),
3639 field,
3640 value,
3641 ctx.params,
3642 )?;
3643 let prev = row[slot].clone();
3644 for id in ids {
3645 if result.len() >= ctx.bound {
3646 break;
3647 }
3648 row[slot] = Some(Cell::Node(id));
3649 pull_rows(ctx, rest, row, result)?;
3650 }
3651 row[slot] = prev;
3652 }
3653 PlanOp::IndexIntersect {
3654 var,
3655 label,
3656 equalities,
3657 } => {
3658 let slot = ctx
3659 .vars
3660 .slot(var)
3661 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3662 let ids = index_intersect_ids(
3663 ctx.view,
3664 ctx.vars,
3665 row,
3666 label.as_deref(),
3667 equalities,
3668 ctx.params,
3669 )?;
3670 let prev = row[slot].clone();
3671 for id in ids {
3672 if result.len() >= ctx.bound {
3673 break;
3674 }
3675 row[slot] = Some(Cell::Node(id));
3676 pull_rows(ctx, rest, row, result)?;
3677 }
3678 row[slot] = prev;
3679 }
3680 PlanOp::Expand {
3681 from,
3682 rel_var,
3683 etypes,
3684 dir,
3685 to,
3686 to_label,
3687 to_props,
3688 } => {
3689 let etypes = resolve_etypes(ctx.view, etypes);
3690 let exp_dir = map_dir(*dir);
3691 let to_slot = ctx
3692 .vars
3693 .slot(to)
3694 .ok_or_else(|| format!("unbound variable `{to}`"))?;
3695 let rel_slot = rel_var.as_ref().and_then(|rv| ctx.vars.slot(rv));
3696 let from_id = require_node(row, ctx.vars, from)?;
3697 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
3698 Some(Cell::Node(id)) => Some(*id),
3699 Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
3700 return Err(format!("variable `{to}` is not a node"))
3701 }
3702 None => None,
3703 };
3704 for e in expand(ctx.view, from_id, etypes.as_deref(), exp_dir) {
3705 if result.len() >= ctx.bound {
3706 break;
3707 }
3708 if row_has_edge(row, &e) {
3709 continue;
3710 }
3711 let nbr = neighbor(from_id, &e, *dir);
3712 if !ctx.view.visible(nbr) {
3713 continue;
3714 }
3715 if let Some(want) = bound_to {
3716 if nbr != want {
3717 continue;
3718 }
3719 }
3720 if !node_matches(
3721 ctx.view,
3722 ctx.vars,
3723 row,
3724 nbr,
3725 to_label.as_deref(),
3726 to_props,
3727 ctx.params,
3728 )? {
3729 continue;
3730 }
3731 let mut next = row.clone();
3732 if let Some(slot) = rel_slot {
3733 next[slot] = Some(Cell::Rel(e));
3734 }
3735 if bound_to.is_none() {
3736 next[to_slot] = Some(Cell::Node(nbr));
3737 }
3738 #[cfg(test)]
3739 record_expand_row();
3740 pull_rows(ctx, rest, &mut next, result)?;
3741 }
3742 }
3743 PlanOp::Filter { expr } => {
3744 if eval_expr(ctx.view, ctx.vars, row, expr, ctx.params, 0)? {
3745 pull_rows(ctx, rest, row, result)?;
3746 }
3747 }
3748 PlanOp::LookupProps { var, props } => {
3749 let id = require_node(row, ctx.vars, var)?;
3750 if node_matches(ctx.view, ctx.vars, row, id, None, props, ctx.params)? {
3751 pull_rows(ctx, rest, row, result)?;
3752 }
3753 }
3754 PlanOp::JoinBound { var, label, props } => {
3755 let id = require_node(row, ctx.vars, var)?;
3756 if node_matches(
3757 ctx.view,
3758 ctx.vars,
3759 row,
3760 id,
3761 label.as_deref(),
3762 props,
3763 ctx.params,
3764 )? {
3765 pull_rows(ctx, rest, row, result)?;
3766 }
3767 }
3768 PlanOp::Project { .. } => {
3773 return Err(
3774 "pull executor: Project reached pull_rows — plan is structurally malformed"
3775 .to_string(),
3776 );
3777 }
3778 PlanOp::Distinct => {
3779 return Err(
3780 "pull executor: Distinct reached pull_rows — DISTINCT queries must use \
3781 the staged path (row_bound returns None)"
3782 .to_string(),
3783 );
3784 }
3785 PlanOp::OrderBy { .. } => {
3786 return Err(
3787 "pull executor: OrderBy reached pull_rows — queries with ORDER BY \
3788 must use the staged path (row_bound returns None)"
3789 .to_string(),
3790 );
3791 }
3792 PlanOp::Skip(_) => {
3793 return Err(
3794 "pull executor: Skip reached pull_rows — Skip must appear after Project"
3795 .to_string(),
3796 );
3797 }
3798 PlanOp::Limit(_) => {
3799 return Err(
3800 "pull executor: Limit reached pull_rows — Limit must appear after Project"
3801 .to_string(),
3802 );
3803 }
3804 PlanOp::Aggregate { .. } => {
3805 return Err(
3806 "pull executor: Aggregate reached pull_rows — aggregate plans must use \
3807 the execute_aggregate path (routed before pull in execute_inner)"
3808 .to_string(),
3809 );
3810 }
3811 PlanOp::VarExpand { .. } => {
3816 return Err(
3817 "pull executor: VarExpand reached pull_rows — variable-length path \
3818 plans must use the staged path (row_bound returns None)"
3819 .to_string(),
3820 );
3821 }
3822 PlanOp::ShortestPath { .. } => {
3823 return Err(
3824 "pull executor: ShortestPath reached pull_rows — shortestPath plans \
3825 must use the staged path (row_bound returns None)"
3826 .to_string(),
3827 );
3828 }
3829 PlanOp::GroupAggregate { .. } => {
3830 return Err(
3831 "pull executor: GroupAggregate reached pull_rows — grouped aggregate plans \
3832 must use the execute_group_aggregate path (routed before pull in execute_inner)"
3833 .to_string(),
3834 );
3835 }
3836 PlanOp::With { .. } => {
3837 return Err(
3838 "pull executor: With reached pull_rows — pipeline plans must use the staged path \
3839 (row_bound returns None for plans containing With)"
3840 .to_string(),
3841 );
3842 }
3843 PlanOp::Unwind { .. } => {
3844 return Err(
3845 "pull executor: Unwind reached pull_rows — pipeline plans must use the staged path \
3846 (row_bound returns None for plans containing Unwind)"
3847 .to_string(),
3848 );
3849 }
3850 PlanOp::LeftOuterApply { .. } => {
3851 return Err(
3852 "pull executor: LeftOuterApply reached pull_rows — OPTIONAL MATCH plans must use \
3853 the staged path (row_bound returns None for plans containing LeftOuterApply)"
3854 .to_string(),
3855 );
3856 }
3857 }
3858 Ok(())
3859}
3860
3861fn eval_expr(
3862 view: &GraphView,
3863 vars: &VarTable,
3864 row: &Row,
3865 expr: &Expr,
3866 params: &Params,
3867 depth: u32,
3868) -> Result<bool, String> {
3869 if depth > 256 {
3870 return Err("expression nesting too deep".into());
3871 }
3872 match expr {
3873 Expr::And(lhs, rhs) => {
3874 let l = eval_expr(view, vars, row, lhs, params, depth + 1)?;
3875 let r = eval_expr(view, vars, row, rhs, params, depth + 1)?;
3876 Ok(l && r)
3877 }
3878 Expr::Or(lhs, rhs) => {
3879 let l = eval_expr(view, vars, row, lhs, params, depth + 1)?;
3880 let r = eval_expr(view, vars, row, rhs, params, depth + 1)?;
3881 Ok(l || r)
3882 }
3883 Expr::Not(inner) => Ok(!eval_expr(view, vars, row, inner, params, depth + 1)?),
3884 Expr::Cmp { lhs, op, rhs } => {
3885 let l = resolve_operand(view, vars, row, lhs, params)?;
3886 let r = resolve_operand(view, vars, row, rhs, params)?;
3887 match (l, r) {
3888 (Some(a), Some(b)) => Ok(eval_cmp(op, &a, &b)),
3889 _ => Ok(false),
3890 }
3891 }
3892 Expr::Truthy(op) => {
3893 let val = resolve_operand(view, vars, row, op, params)?;
3894 Ok(match val {
3895 None => false,
3896 Some(Value::Bool(b)) => b,
3897 Some(Value::Int(n)) => n != 0,
3898 Some(Value::Float(f)) => f != 0.0,
3899 Some(Value::Str(s)) => !s.is_empty(),
3900 Some(Value::List(v)) => !v.is_empty(),
3901 Some(Value::Map(m)) => !m.is_empty(),
3902 })
3903 }
3904 Expr::IsNull(op) => {
3905 let val = resolve_operand(view, vars, row, op, params)?;
3906 Ok(val.is_none())
3907 }
3908 Expr::IsNotNull(op) => {
3909 let val = resolve_operand(view, vars, row, op, params)?;
3910 Ok(val.is_some())
3911 }
3912 Expr::In { expr, list } => eval_in(view, vars, row, expr, list, params),
3913 }
3914}
3915
3916fn eval_in(
3917 view: &GraphView,
3918 vars: &VarTable,
3919 row: &Row,
3920 expr: &Operand,
3921 list: &[Operand],
3922 params: &Params,
3923) -> Result<bool, String> {
3924 let Some(needle) = resolve_operand(view, vars, row, expr, params)? else {
3925 return Ok(false);
3926 };
3927 for item_op in list {
3928 match resolve_operand(view, vars, row, item_op, params)? {
3929 None => {}
3930 Some(Value::List(items)) => {
3931 for item in items {
3932 if crate::filter::eval_cmp(&crate::filter::CmpOp::Eq, &needle, &item) {
3933 return Ok(true);
3934 }
3935 }
3936 }
3937 Some(item) if crate::filter::eval_cmp(&crate::filter::CmpOp::Eq, &needle, &item) => {
3938 return Ok(true);
3939 }
3940 Some(_) => {}
3941 }
3942 }
3943 Ok(false)
3944}
3945
3946fn exec_distinct(table: &mut Projected) -> Result<(), String> {
3948 let cap = max_intermediate_rows();
3949 let mut seen: BTreeSet<Vec<Option<ValueKey>>> = BTreeSet::new();
3950 let mut out = Vec::with_capacity(table.rows.len().min(cap));
3951 for row in table.rows.drain(..) {
3952 let key: Vec<Option<ValueKey>> = row
3953 .iter()
3954 .map(|cell| cell.as_ref().and_then(group_key_normalize))
3955 .collect();
3956 if seen.insert(key) {
3957 if out.len() >= cap {
3958 return Err(row_cap_err(cap));
3959 }
3960 out.push(row);
3961 }
3962 }
3963 table.rows = out;
3964 Ok(())
3965}
3966
3967fn column_name(item: &RetItem) -> String {
3968 if let Some(alias) = &item.alias {
3969 return alias.clone();
3970 }
3971 match &item.value {
3972 RetVal::Var(v) => v.clone(),
3973 RetVal::Prop { var, field } => format!("{var}.{field}"),
3974 RetVal::Agg { func, arg } => {
3977 use crate::cypher::ast::AggArg;
3978 let f = match func {
3979 AggFunc::Count => "COUNT",
3980 AggFunc::Sum => "SUM",
3981 AggFunc::Avg => "AVG",
3982 AggFunc::Min => "MIN",
3983 AggFunc::Max => "MAX",
3984 AggFunc::Collect => "COLLECT",
3985 };
3986 let a = match arg {
3987 AggArg::Star => "*".to_string(),
3988 AggArg::Var(v) => v.clone(),
3989 AggArg::Prop { var, field } => format!("{var}.{field}"),
3990 };
3991 format!("{f}({a})")
3992 }
3993 RetVal::FuncCall { name, args } => {
3994 let arg_strs: Vec<String> = args
3995 .iter()
3996 .map(|a| match a {
3997 Operand::Var(v) => v.clone(),
3998 Operand::Prop { var, field } => format!("{var}.{field}"),
3999 Operand::Lit(_) => "<lit>".to_string(),
4000 Operand::Param(p) => format!("${p}"),
4001 Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
4002 Operand::BinArith { .. } => "<arith>".to_string(),
4003 Operand::Case { .. } => "<case>".to_string(),
4004 })
4005 .collect();
4006 format!("{name}({})", arg_strs.join(", "))
4007 }
4008 RetVal::ScalarExpr(_) => "<expr>".to_string(),
4009 }
4010}
4011
4012fn exec_project(
4013 view: &GraphView,
4014 vars: &VarTable,
4015 rows: &[Row],
4016 items: &[RetItem],
4017 params: &Params,
4018) -> Result<Projected, String> {
4019 let columns: Vec<String> = items.iter().map(column_name).collect();
4020 let mut out_rows = Vec::with_capacity(rows.len());
4021 for row in rows {
4022 let mut cells = Vec::with_capacity(items.len());
4023 for item in items {
4024 cells.push(project_item(view, vars, row, item, params)?);
4025 }
4026 out_rows.push(cells);
4027 }
4028 Ok(Projected {
4029 columns,
4030 rows: out_rows,
4031 })
4032}
4033
4034fn project_item(
4035 view: &GraphView,
4036 vars: &VarTable,
4037 row: &Row,
4038 item: &RetItem,
4039 params: &Params,
4040) -> Result<Option<Value>, String> {
4041 match &item.value {
4042 RetVal::Var(v) => {
4043 let slot = vars.slot(v).ok_or_else(|| format!("unbound variable `{v}`"))?;
4045 match row.get(slot).and_then(|c| c.as_ref()) {
4046 None => Ok(None),
4048 Some(Cell::Node(id)) => match view.ids.key_of(*id) {
4049 Some(key) => Ok(Some(Value::Str(key.to_owned()))),
4050 None => Err(format!("unknown node id {id}")),
4051 },
4052 Some(Cell::Scalar(val)) => Ok(Some(val.clone())),
4054 Some(Cell::Rel(_)) => Err(format!(
4055 "variable `{v}` is a relationship; return its properties ({v}.field) instead"
4056 )),
4057 Some(Cell::Path(hops)) => Ok(Some(Value::Int(*hops as i64))),
4058 }
4059 }
4060 RetVal::Prop { var, field } => resolve_prop(view, vars, row, var, field),
4061 RetVal::Agg { .. } => Err(
4064 "project_item: Agg variant reached exec_project — aggregate plans must not contain Project"
4065 .to_string(),
4066 ),
4067 RetVal::FuncCall { name, args } => eval_func(name, args, view, vars, row, params),
4068 RetVal::ScalarExpr(op) => resolve_operand(view, vars, row, op, params),
4069 }
4070}
4071
4072fn order_column(item: &OrderItem) -> String {
4073 match &item.target {
4074 OrderTarget::Alias(name) | OrderTarget::Var(name) => name.clone(),
4075 OrderTarget::Prop { var, field } => format!("{var}.{field}"),
4076 }
4077}
4078
4079fn exec_order_by(table: &mut Projected, items: &[OrderItem]) -> Result<(), String> {
4080 let mut keys = Vec::with_capacity(items.len());
4081 for item in items {
4082 let name = order_column(item);
4083 let idx = table
4084 .columns
4085 .iter()
4086 .position(|c| c == &name)
4087 .ok_or_else(|| format!("ORDER BY target `{name}` is not a projected column"))?;
4088 keys.push((idx, item.descending));
4089 }
4090 table.rows.sort_by(|a, b| {
4091 for &(idx, desc) in &keys {
4092 let c = cmp_optional(
4093 a.get(idx).and_then(|x| x.as_ref()),
4094 b.get(idx).and_then(|x| x.as_ref()),
4095 desc,
4096 );
4097 if c != std::cmp::Ordering::Equal {
4098 return c;
4099 }
4100 }
4101 std::cmp::Ordering::Equal
4102 });
4103 Ok(())
4104}
4105
4106fn resolve_ls(ls: &LimitSkip, params: &Params) -> Result<u64, String> {
4111 match ls {
4112 LimitSkip::Exact(n) => Ok(*n),
4113 LimitSkip::Param(name) => {
4114 let val = params
4115 .0
4116 .get(name)
4117 .ok_or_else(|| format!("missing parameter `{name}` (used in LIMIT/SKIP)"))?;
4118 match val {
4119 Value::Int(i) if *i >= 0 => Ok(*i as u64),
4120 Value::Int(i) => Err(format!(
4121 "LIMIT/SKIP parameter `{name}` must be a non-negative integer, got {i}"
4122 )),
4123 other => Err(format!(
4124 "LIMIT/SKIP parameter `{name}` must be an integer, got {other:?}"
4125 )),
4126 }
4127 }
4128 }
4129}
4130
4131fn apply_skip<T>(rows: &mut Vec<T>, n: u64) {
4132 let n = usize::try_from(n).unwrap_or(usize::MAX);
4133 if n >= rows.len() {
4134 rows.clear();
4135 } else {
4136 rows.drain(0..n);
4137 }
4138}
4139
4140fn apply_limit<T>(rows: &mut Vec<T>, n: u64) {
4141 let n = usize::try_from(n).unwrap_or(usize::MAX);
4142 rows.truncate(n);
4143}
4144
4145#[cfg(test)]
4146mod tests {
4147 use super::{execute, resolve_operand, Params, Row, VarTable};
4148 use crate::cypher::ast::{
4149 ArithOp, LimitSkip, Operand, OrderItem, OrderTarget, RetItem, RetVal,
4150 };
4151 use crate::cypher::plan::{plan, PlanOp};
4152 use crate::cypher::{lex, parse, RelDir};
4153 use crate::result::ResultSet;
4154 use crate::view::GraphView;
4155 use core_storage::v8::seam::{ColumnsView, EdgePropsView, TopologyView};
4156 use core_storage::{ColumnStore, EdgeProps, IdMap, Interner, Topology, Value};
4157 use proptest::prelude::*;
4158 use std::collections::BTreeMap;
4159
4160 struct Fx {
4161 ids: IdMap,
4162 syms: Interner,
4163 labels: Vec<u32>,
4164 props: ColumnStore,
4165 topo: Topology,
4166 eprops: EdgeProps,
4167 }
4168
4169 impl Fx {
4170 fn new() -> Self {
4171 Fx {
4172 ids: IdMap::new(),
4173 syms: Interner::new(),
4174 labels: vec![],
4175 props: ColumnStore::new(),
4176 topo: Topology::new(),
4177 eprops: EdgeProps::new(),
4178 }
4179 }
4180
4181 fn add(&mut self, label: &str, key: &str, props: Vec<(&str, Value)>) -> u32 {
4182 let id = self.ids.get_or_insert(key);
4183 let sym = self.syms.intern(label);
4184 self.labels.resize(id as usize + 1, u32::MAX);
4185 self.labels[id as usize] = sym;
4186 for (f, v) in props {
4187 self.props.set(id, f, v);
4188 }
4189 id
4190 }
4191
4192 fn edge(&mut self, etype: &str, src: u32, dst: u32, props: Vec<(&str, Value)>) {
4193 let et = self.syms.intern(etype);
4194 self.topo.add_edge(et, src, dst);
4195 for (f, v) in props {
4196 self.eprops.set(et, src, dst, f, v);
4197 }
4198 }
4199
4200 fn view(&self) -> GraphView<'_> {
4201 GraphView {
4202 ids: &self.ids,
4203 syms: &self.syms,
4204 labels: &self.labels,
4205 props: ColumnsView::owned(&self.props),
4206 topo: TopologyView::owned(&self.topo),
4207 edge_props: EdgePropsView::owned(&self.eprops),
4208 mask: None,
4209 prop_index: None,
4210 }
4211 }
4212
4213 fn view_indexed<'a>(
4214 &'a self,
4215 index: &'a core_storage::property_index::PropertyIndex,
4216 ) -> GraphView<'a> {
4217 GraphView {
4218 prop_index: Some(index),
4219 ..self.view()
4220 }
4221 }
4222 }
4223
4224 fn compile(src: &str) -> Vec<PlanOp> {
4225 plan(&parse(&lex(src).expect("lex")).expect("parse")).expect("plan")
4226 }
4227
4228 fn run(
4229 view: &GraphView,
4230 src: &str,
4231 params: &BTreeMap<String, Value>,
4232 ) -> Result<ResultSet, String> {
4233 execute(view, &compile(src), &Params(params))
4234 }
4235
4236 fn s(v: &str) -> Value {
4237 Value::Str(v.into())
4238 }
4239
4240 fn f(v: f64) -> Value {
4241 Value::Float(v)
4242 }
4243
4244 fn i(v: i64) -> Value {
4245 Value::Int(v)
4246 }
4247
4248 fn rows_of(rs: &ResultSet) -> Vec<Vec<Option<Value>>> {
4249 (0..rs.len()).map(|i| rs.row(i).to_vec()).collect()
4250 }
4251
4252 fn col(rs: &ResultSet, name: &str) -> Vec<Option<Value>> {
4253 (0..rs.len()).map(|i| rs.get(i, name).cloned()).collect()
4254 }
4255
4256 fn hop_graph() -> Fx {
4257 let mut fx = Fx::new();
4258 let ada = fx.add("Person", "ada", vec![]);
4259 let bob = fx.add("Person", "bob", vec![]);
4260 let cam = fx.add("Person", "cam", vec![]);
4261 let acme = fx.add("Company", "acme", vec![]);
4262 fx.edge("KNOWS", ada, bob, vec![]);
4263 fx.edge("KNOWS", ada, cam, vec![]);
4264 fx.edge("KNOWS", bob, cam, vec![]);
4265 fx.edge("LIKES", ada, acme, vec![]);
4266 fx
4267 }
4268
4269 fn undirected_graph() -> Fx {
4270 let mut fx = Fx::new();
4271 let a = fx.add("N", "a", vec![]);
4272 let b = fx.add("N", "b", vec![]);
4273 fx.edge("T", a, b, vec![("w", i(42))]);
4274 fx
4275 }
4276
4277 fn triangle() -> Fx {
4278 let mut fx = Fx::new();
4279 let a = fx.add("N", "a", vec![]);
4280 let b = fx.add("N", "b", vec![]);
4281 let c = fx.add("N", "c", vec![]);
4282 fx.edge("T", a, b, vec![("eid", i(1))]);
4283 fx.edge("T", b, c, vec![("eid", i(2))]);
4284 fx.edge("T", c, a, vec![("eid", i(3))]);
4285 fx
4286 }
4287
4288 fn single_edge() -> (Fx, u32, u32) {
4289 let mut fx = Fx::new();
4290 let a = fx.add("N", "a", vec![]);
4291 let b = fx.add("N", "b", vec![]);
4292 fx.edge("T", a, b, vec![]);
4293 (fx, a, b)
4294 }
4295
4296 fn dogfood_graph() -> Fx {
4298 let mut fx = Fx::new();
4299 let t1 = fx.add("Talent", "t1", vec![("id", s("t1"))]);
4300 let acme = fx.add("Company", "acme", vec![]);
4301 let beta = fx.add("Company", "beta", vec![]);
4302 let gamma = fx.add("Company", "gamma", vec![]);
4303 let delta = fx.add("Company", "delta", vec![]);
4304 let echo = fx.add("Company", "echo", vec![]);
4305 let foxtrot = fx.add("Company", "foxtrot", vec![]);
4306 let zeta = fx.add("Company", "zeta", vec![]);
4307 fx.edge("INDUSTRY_ALIGNMENT", acme, t1, vec![("score", f(0.9))]);
4308 fx.edge("SPECIALTY_MATCH", acme, t1, vec![("score", f(0.8))]);
4309 fx.edge("INDUSTRY_ALIGNMENT", beta, t1, vec![("score", f(0.6))]);
4310 fx.edge("SPECIALTY_MATCH", beta, t1, vec![("score", f(0.7))]);
4311 fx.edge("INDUSTRY_ALIGNMENT", gamma, t1, vec![("score", f(0.4))]);
4312 fx.edge("SPECIALTY_MATCH", gamma, t1, vec![("score", f(0.9))]);
4313 fx.edge("INDUSTRY_ALIGNMENT", delta, t1, vec![("score", f(0.8))]);
4314 fx.edge("SPECIALTY_MATCH", delta, t1, vec![("score", f(0.3))]);
4315 fx.edge("INDUSTRY_ALIGNMENT", echo, t1, vec![("score", f(0.5))]);
4316 fx.edge("SPECIALTY_MATCH", echo, t1, vec![("score", f(0.5))]);
4317 fx.edge("INDUSTRY_ALIGNMENT", foxtrot, t1, vec![("score", f(0.95))]);
4318 fx.edge("INDUSTRY_ALIGNMENT", zeta, t1, vec![("score", f(0.9))]);
4319 fx.edge("SPECIALTY_MATCH", zeta, t1, vec![("score", f(0.6))]);
4320 fx
4321 }
4322
4323 const DOGFOOD: &str = "\
4324MATCH (t:Talent {id: $tid}) \
4325MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
4326MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
4327WHERE i.score >= 0.5 AND s.score >= 0.5 \
4328RETURN c, i.score AS industry, s.score AS specialty \
4329ORDER BY industry DESC, specialty DESC \
4330LIMIT 10";
4331
4332 fn tid_params() -> BTreeMap<String, Value> {
4333 let mut p = BTreeMap::new();
4334 p.insert("tid".into(), s("t1"));
4335 p
4336 }
4337
4338 #[test]
4339 fn single_hop_match_label_and_etype_filters() {
4340 let fx = hop_graph();
4341 let v = fx.view();
4342 let rs = run(
4343 &v,
4344 "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b",
4345 &BTreeMap::new(),
4346 )
4347 .expect("single-hop");
4348 assert_eq!(rs.columns(), &["a".to_string(), "b".to_string()]);
4349 assert_eq!(
4350 rows_of(&rs),
4351 vec![
4352 vec![Some(s("ada")), Some(s("bob"))],
4353 vec![Some(s("ada")), Some(s("cam"))],
4354 vec![Some(s("bob")), Some(s("cam"))],
4355 ]
4356 );
4357 let likes = run(
4359 &v,
4360 "MATCH (a:Person)-[:LIKES]->(b:Company) RETURN a, b",
4361 &BTreeMap::new(),
4362 )
4363 .unwrap();
4364 assert_eq!(rows_of(&likes), vec![vec![Some(s("ada")), Some(s("acme"))]]);
4365 let no_combo = run(
4366 &v,
4367 "MATCH (a:Person)-[:KNOWS]->(b:Company) RETURN a, b",
4368 &BTreeMap::new(),
4369 )
4370 .unwrap();
4371 assert!(no_combo.is_empty());
4372 }
4373
4374 #[test]
4375 fn undirected_match_finds_both_orientations_and_binds_true_triple() {
4376 let fx = undirected_graph();
4377 let v = fx.view();
4378 let rs =
4381 run(&v, "MATCH (x)-[r:T]-(y) RETURN x, y, r.w", &BTreeMap::new()).expect("undirected");
4382 assert_eq!(
4383 rows_of(&rs),
4384 vec![
4385 vec![Some(s("a")), Some(s("b")), Some(i(42))],
4386 vec![Some(s("b")), Some(s("a")), Some(i(42))],
4387 ]
4388 );
4389
4390 let left = run(
4391 &v,
4392 "MATCH (y)<-[r:T]-(x) RETURN x, y, r.w",
4393 &BTreeMap::new(),
4394 )
4395 .unwrap();
4396 assert_eq!(
4397 rows_of(&left),
4398 vec![vec![Some(s("a")), Some(s("b")), Some(i(42))]]
4399 );
4400 }
4401
4402 #[test]
4403 fn relationship_uniqueness_triangle_and_two_hop_cycle() {
4404 let tri = triangle();
4405 let v = tri.view();
4406 let rs = run(
4407 &v,
4408 "MATCH (x)-[r1:T]->(y)-[r2:T]->(z) RETURN x, y, z, r1.eid, r2.eid",
4409 &BTreeMap::new(),
4410 )
4411 .expect("triangle 2-hop");
4412 assert_eq!(
4413 rows_of(&rs),
4414 vec![
4415 vec![
4416 Some(s("a")),
4417 Some(s("b")),
4418 Some(s("c")),
4419 Some(i(1)),
4420 Some(i(2))
4421 ],
4422 vec![
4423 Some(s("b")),
4424 Some(s("c")),
4425 Some(s("a")),
4426 Some(i(2)),
4427 Some(i(3))
4428 ],
4429 vec![
4430 Some(s("c")),
4431 Some(s("a")),
4432 Some(s("b")),
4433 Some(i(3)),
4434 Some(i(1))
4435 ],
4436 ]
4437 );
4438 for row in rows_of(&rs) {
4439 assert_ne!(row[3], row[4], "r1 must never bind the same edge as r2");
4440 }
4441
4442 let (mut one, a, b) = single_edge();
4443 let v = one.view();
4444 let cycle = run(
4445 &v,
4446 "MATCH (x)-[:T]->(y)-[:T]->(x) RETURN x",
4447 &BTreeMap::new(),
4448 )
4449 .expect("2-hop cycle");
4450 assert!(
4451 cycle.is_empty(),
4452 "single directed edge cannot close a 2-hop cycle"
4453 );
4454
4455 let undirected_cycle = run(&v, "MATCH (x)-[:T]-(y)-[:T]-(x) RETURN x", &BTreeMap::new())
4457 .expect("undirected uniqueness");
4458 assert!(
4459 undirected_cycle.is_empty(),
4460 "relationship uniqueness must reject walking the same triple back"
4461 );
4462
4463 one.edge("T", b, a, vec![]);
4464 let v = one.view();
4465 let with_recip = run(
4466 &v,
4467 "MATCH (x)-[:T]->(y)-[:T]->(x) RETURN x",
4468 &BTreeMap::new(),
4469 )
4470 .unwrap();
4471 assert_eq!(col(&with_recip, "x"), vec![Some(s("a")), Some(s("b"))]);
4472 }
4473
4474 #[test]
4475 fn multi_match_join_bound_and_bound_destination_expand() {
4476 let fx = dogfood_graph();
4477 let v = fx.view();
4478 let rs = run(
4481 &v,
4482 "MATCH (t:Talent {id: $tid}) \
4483 MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
4484 MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
4485 RETURN c",
4486 &tid_params(),
4487 )
4488 .expect("join + bound dest");
4489 assert_eq!(
4490 col(&rs, "c"),
4491 vec![
4492 Some(s("acme")),
4493 Some(s("beta")),
4494 Some(s("gamma")),
4495 Some(s("delta")),
4496 Some(s("echo")),
4497 Some(s("zeta")),
4498 ]
4499 );
4500
4501 let keep = run(&v, "MATCH (c:Company) MATCH (c) RETURN c", &BTreeMap::new()).unwrap();
4503 assert_eq!(keep.len(), 7);
4504 let drop = run(
4506 &v,
4507 "MATCH (c:Company) MATCH (c:Talent) RETURN c",
4508 &BTreeMap::new(),
4509 )
4510 .unwrap();
4511 assert!(drop.is_empty());
4512 }
4513
4514 #[test]
4515 fn scan_key_exec_does_not_use_label_scan() {
4516 let mut fx = Fx::new();
4517 fx.add("Person", "p1", vec![]);
4518 fx.add("Person", "p2", vec![]);
4519 fx.add("Person", "p3", vec![]);
4520 fx.add("Company", "c1", vec![]);
4521 let v = fx.view();
4522 let params = BTreeMap::new();
4523
4524 let fires_before = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
4525 let rs = run(&v, "MATCH (n:Person {id: 'p2'}) RETURN n", ¶ms).expect("scan key");
4526 let fires_after = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
4527 assert!(
4528 fires_after > fires_before,
4529 "SCAN_KEY_FIRES must increment; before={fires_before} after={fires_after}"
4530 );
4531 assert_eq!(rows_of(&rs), vec![vec![Some(s("p2"))]]);
4532
4533 let miss = run(&v, "MATCH (n:Person {id: 'nope'}) RETURN n", ¶ms).unwrap();
4534 assert!(
4535 miss.is_empty(),
4536 "missing key must be zero rows, not an error"
4537 );
4538
4539 let wrong = run(&v, "MATCH (n:Person {id: 'c1'}) RETURN n", ¶ms).unwrap();
4540 assert!(
4541 wrong.is_empty(),
4542 "wrong label must be zero rows, not an error"
4543 );
4544 }
4545
4546 #[test]
4547 fn rel_var_edge_prop_filter() {
4548 let mut fx = Fx::new();
4549 let a = fx.add("N", "a", vec![]);
4550 let b = fx.add("N", "b", vec![]);
4551 let c = fx.add("N", "c", vec![]);
4552 fx.edge("T", a, b, vec![("w", f(0.7))]);
4553 fx.edge("T", a, c, vec![("w", f(0.3))]);
4554 let v = fx.view();
4555 let rs = run(
4556 &v,
4557 "MATCH (x)-[r:T]->(y) WHERE r.w >= 0.5 RETURN y, r.w",
4558 &BTreeMap::new(),
4559 )
4560 .expect("edge-prop filter");
4561 assert_eq!(rows_of(&rs), vec![vec![Some(s("b")), Some(f(0.7))]]);
4562 let fail = run(
4563 &v,
4564 "MATCH (x)-[r:T]->(y) WHERE r.w >= 0.8 RETURN y",
4565 &BTreeMap::new(),
4566 )
4567 .unwrap();
4568 assert!(fail.is_empty());
4569 }
4570
4571 #[test]
4572 fn params_present_resolve_missing_is_err_before_rows() {
4573 let fx = dogfood_graph();
4574 let v = fx.view();
4575 let hit =
4576 run(&v, "MATCH (t:Talent {id: $tid}) RETURN t", &tid_params()).expect("present param");
4577 assert_eq!(col(&hit, "t"), vec![Some(s("t1"))]);
4578
4579 let err = run(
4581 &v,
4582 "MATCH (t:NoSuchLabel {id: $tid}) RETURN t",
4583 &BTreeMap::new(),
4584 )
4585 .expect_err("missing param must be Err, not Ok(empty)");
4586 assert!(
4587 err.contains("tid")
4588 && (err.contains("param") || err.contains("Param") || err.contains("missing")),
4589 "missing-param error must name the parameter, got: {err}"
4590 );
4591
4592 let err = run(
4593 &v,
4594 "MATCH (t:Talent) WHERE t.id = $tid RETURN t",
4595 &BTreeMap::new(),
4596 )
4597 .expect_err("missing WHERE param");
4598 assert!(err.contains("tid"), "got: {err}");
4599 }
4600
4601 #[test]
4602 fn order_by_none_last_then_skip_limit() {
4603 let mut fx = Fx::new();
4604 fx.add("Person", "ada", vec![("age", i(30))]);
4605 fx.add("Person", "bob", vec![]); fx.add("Person", "cam", vec![("age", i(10))]);
4607 fx.add("Person", "dan", vec![("age", i(20))]);
4608 let v = fx.view();
4609
4610 let asc = run(
4611 &v,
4612 "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age",
4613 &BTreeMap::new(),
4614 )
4615 .unwrap();
4616 assert_eq!(
4617 rows_of(&asc),
4618 vec![
4619 vec![Some(s("cam")), Some(i(10))],
4620 vec![Some(s("dan")), Some(i(20))],
4621 vec![Some(s("ada")), Some(i(30))],
4622 vec![Some(s("bob")), None],
4623 ]
4624 );
4625
4626 let desc = run(
4627 &v,
4628 "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age DESC",
4629 &BTreeMap::new(),
4630 )
4631 .unwrap();
4632 assert_eq!(
4633 rows_of(&desc),
4634 vec![
4635 vec![Some(s("ada")), Some(i(30))],
4636 vec![Some(s("dan")), Some(i(20))],
4637 vec![Some(s("cam")), Some(i(10))],
4638 vec![Some(s("bob")), None],
4639 ]
4640 );
4641
4642 let skip_lim = run(
4643 &v,
4644 "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age SKIP 1 LIMIT 2",
4645 &BTreeMap::new(),
4646 )
4647 .unwrap();
4648 assert_eq!(
4649 rows_of(&skip_lim),
4650 vec![
4651 vec![Some(s("dan")), Some(i(20))],
4652 vec![Some(s("ada")), Some(i(30))],
4653 ]
4654 );
4655
4656 let desc_sl = run(
4657 &v,
4658 "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age DESC SKIP 1 LIMIT 2",
4659 &BTreeMap::new(),
4660 )
4661 .unwrap();
4662 assert_eq!(
4663 rows_of(&desc_sl),
4664 vec![
4665 vec![Some(s("dan")), Some(i(20))],
4666 vec![Some(s("cam")), Some(i(10))],
4667 ]
4668 );
4669 }
4670
4671 #[test]
4672 fn unknown_label_and_etype_are_ok_empty() {
4673 let fx = hop_graph();
4674 let v = fx.view();
4675 let lab = run(&v, "MATCH (x:Nope) RETURN x", &BTreeMap::new()).expect("unknown label");
4676 assert!(lab.is_empty());
4677 let et = run(
4678 &v,
4679 "MATCH (a)-[:NO_SUCH_ETYPE]->(b) RETURN a",
4680 &BTreeMap::new(),
4681 )
4682 .expect("unknown etype");
4683 assert!(et.is_empty());
4684 }
4685
4686 #[test]
4687 fn execute_is_deterministic() {
4688 let fx = dogfood_graph();
4689 let v = fx.view();
4690 let p = tid_params();
4691 let a = run(&v, DOGFOOD, &p).expect("first");
4692 let b = run(&v, DOGFOOD, &p).expect("second");
4693 assert_eq!(a, b);
4694 let hop = hop_graph();
4695 let hv = hop.view();
4696 let q = "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b";
4697 assert_eq!(run(&hv, q, &BTreeMap::new()), run(&hv, q, &BTreeMap::new()));
4698 }
4699
4700 #[test]
4701 fn dogfood_pipeline_exact_rows() {
4702 let fx = dogfood_graph();
4703 let v = fx.view();
4704 let rs = run(&v, DOGFOOD, &tid_params()).expect("dogfood");
4705 assert_eq!(
4706 rs.columns(),
4707 &[
4708 "c".to_string(),
4709 "industry".to_string(),
4710 "specialty".to_string()
4711 ]
4712 );
4713 assert_eq!(
4715 rows_of(&rs),
4716 vec![
4717 vec![Some(s("acme")), Some(f(0.9)), Some(f(0.8))],
4718 vec![Some(s("zeta")), Some(f(0.9)), Some(f(0.6))],
4719 vec![Some(s("beta")), Some(f(0.6)), Some(f(0.7))],
4720 vec![Some(s("echo")), Some(f(0.5)), Some(f(0.5))],
4721 ]
4722 );
4723 }
4724
4725 #[test]
4726 fn unknown_var_in_op_is_err_not_panic() {
4727 let fx = hop_graph();
4728 let v = fx.view();
4729 let plan = vec![
4730 PlanOp::ScanLabel {
4731 var: "a".into(),
4732 label: None,
4733 },
4734 PlanOp::Expand {
4735 from: "zzz".into(),
4736 rel_var: Some("r".into()),
4737 etypes: vec![],
4738 dir: RelDir::Right,
4739 to: "b".into(),
4740 to_label: None,
4741 to_props: vec![],
4742 },
4743 PlanOp::Project {
4744 items: vec![RetItem {
4745 value: RetVal::Var("a".into()),
4746 alias: None,
4747 }],
4748 },
4749 ];
4750 let params = BTreeMap::new();
4751 let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4752 execute(&v, &plan, &Params(¶ms))
4753 }));
4754 assert!(caught.is_ok(), "execute panicked on unknown var");
4755 let err = caught.unwrap().expect_err("unknown var must be Err");
4756 assert!(
4757 err.contains("zzz") && err.to_ascii_lowercase().contains("unbound"),
4758 "got: {err}"
4759 );
4760
4761 let join = vec![
4762 PlanOp::JoinBound {
4763 var: "ghost".into(),
4764 label: None,
4765 props: vec![],
4766 },
4767 PlanOp::Project {
4768 items: vec![RetItem {
4769 value: RetVal::Var("ghost".into()),
4770 alias: None,
4771 }],
4772 },
4773 ];
4774 let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4775 execute(&v, &join, &Params(¶ms))
4776 }));
4777 assert!(caught.is_ok(), "execute panicked on JoinBound unknown var");
4778 assert!(caught.unwrap().is_err());
4779 }
4780
4781 #[test]
4782 fn dest_props_on_bound_expand_are_applied() {
4783 let fx = dogfood_graph();
4784 let v = fx.view();
4785 let rs = run(
4786 &v,
4787 "MATCH (t:Talent {id: $tid}) \
4788 MATCH (c:Company)-[r:INDUSTRY_ALIGNMENT]->(t:Talent {id: $tid}) \
4789 RETURN c",
4790 &tid_params(),
4791 )
4792 .unwrap();
4793 assert_eq!(
4795 col(&rs, "c"),
4796 vec![
4797 Some(s("acme")),
4798 Some(s("beta")),
4799 Some(s("gamma")),
4800 Some(s("delta")),
4801 Some(s("echo")),
4802 Some(s("foxtrot")),
4803 Some(s("zeta")),
4804 ]
4805 );
4806 let miss = run(
4807 &v,
4808 "MATCH (t:Talent {id: $tid}) \
4809 MATCH (c:Company)-[r:INDUSTRY_ALIGNMENT]->(t {id: 'nope'}) \
4810 RETURN c",
4811 &tid_params(),
4812 )
4813 .unwrap();
4814 assert!(miss.is_empty());
4815 }
4816
4817 #[test]
4818 fn missing_node_prop_projects_none_and_pattern_misses() {
4819 let mut fx = Fx::new();
4820 fx.add("Person", "ada", vec![("age", i(30))]);
4821 fx.add("Person", "bob", vec![]);
4822 let v = fx.view();
4823 let rs = run(&v, "MATCH (p:Person) RETURN p.age", &BTreeMap::new()).unwrap();
4824 assert_eq!(col(&rs, "p.age"), vec![Some(i(30)), None]);
4825 let pat = run(&v, "MATCH (p:Person {age: 30}) RETURN p", &BTreeMap::new()).unwrap();
4826 assert_eq!(col(&pat, "p"), vec![Some(s("ada"))]);
4827 }
4828
4829 #[test]
4830 fn unlabeled_match_does_not_project_sentinel_ghost_key() {
4831 let mut fx = Fx::new();
4832 fx.add("Person", "ada", vec![]);
4833 fx.ids.get_or_insert("ghost");
4836 fx.labels.resize(fx.ids.len(), u32::MAX);
4837 fx.add("Person", "bob", vec![]);
4838 let v = fx.view();
4839 let rs = run(&v, "MATCH (n) RETURN n", &BTreeMap::new()).expect("unlabeled scan");
4840 assert_eq!(col(&rs, "n"), vec![Some(s("ada")), Some(s("bob"))]);
4841 assert!(
4842 !rows_of(&rs)
4843 .iter()
4844 .any(|row| row.iter().any(|c| *c == Some(s("ghost")))),
4845 "sentinel slot must not project a ghost key"
4846 );
4847 }
4848
4849 #[test]
4850 fn execute_never_panics_on_hostile_plans() {
4851 let fx = hop_graph();
4852 let v = fx.view();
4853 let params = BTreeMap::new();
4854 let hostile = vec![
4855 vec![],
4856 vec![PlanOp::Project { items: vec![] }],
4857 vec![PlanOp::OrderBy {
4858 items: vec![OrderItem {
4859 target: OrderTarget::Alias("nope".into()),
4860 descending: false,
4861 }],
4862 }],
4863 vec![PlanOp::Filter {
4864 expr: crate::cypher::ast::Expr::Cmp {
4865 lhs: Operand::Prop {
4866 var: "missing".into(),
4867 field: "x".into(),
4868 },
4869 op: crate::filter::CmpOp::Eq,
4870 rhs: Operand::Lit(i(1)),
4871 },
4872 }],
4873 vec![
4874 PlanOp::ScanLabel {
4875 var: "a".into(),
4876 label: None,
4877 },
4878 PlanOp::LookupProps {
4879 var: "zzz".into(),
4880 props: vec![("k".into(), Operand::Lit(i(1)))],
4881 },
4882 ],
4883 vec![
4884 PlanOp::Skip(LimitSkip::Exact(99)),
4885 PlanOp::Limit(LimitSkip::Exact(0)),
4886 ],
4887 ];
4888 for plan in hostile {
4889 let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4890 execute(&v, &plan, &Params(¶ms))
4891 }));
4892 assert!(caught.is_ok(), "execute panicked on hostile plan: {plan:?}");
4893 }
4894 }
4895
4896 #[test]
4897 fn unlabeled_and_labeled_scans_skip_tombstoned_ids() {
4898 let mut fx = Fx::new();
4899 let ada = fx.add("Person", "ada", vec![]);
4900 let bob = fx.add("Person", "bob", vec![]);
4901 fx.edge("KNOWS", ada, bob, vec![]);
4902 fx.ids.delete("ada");
4904 fx.labels[ada as usize] = u32::MAX;
4905 let knows = fx.syms.get("KNOWS").unwrap();
4906 fx.topo.remove_edge(knows, ada, bob);
4907 let v = fx.view();
4908
4909 let labeled = run(&v, "MATCH (p:Person) RETURN p", &BTreeMap::new()).unwrap();
4910 assert_eq!(col(&labeled, "p"), vec![Some(s("bob"))]);
4911 let unlabeled = run(&v, "MATCH (n) RETURN n", &BTreeMap::new()).unwrap();
4912 assert_eq!(col(&unlabeled, "n"), vec![Some(s("bob"))]);
4913 let hop = run(&v, "MATCH (x)-[:KNOWS]->(y) RETURN x, y", &BTreeMap::new()).unwrap();
4914 assert!(
4915 hop.is_empty(),
4916 "expand cannot yield edges to a deleted node once topology is swept"
4917 );
4918 }
4919
4920 proptest! {
4925 #[test]
4938 fn prop_bounded_equals_unbounded_slice(
4939 n_nodes in 2u32..10u32,
4940 edge_pairs in proptest::collection::vec(
4941 (any::<u32>(), any::<u32>()), 0..20usize
4942 ),
4943 recip_pairs in proptest::collection::vec(
4945 (any::<u32>(), any::<u32>()), 0..8usize
4946 ),
4947 n_hops in 1u32..4u32, use_filter in any::<bool>(),
4949 threshold in 0i64..8i64, limit in 1u64..10u64,
4951 skip in 0u64..4u64,
4952 ) {
4953 let mut fx = Fx::new();
4954 let mut node_ids = Vec::new();
4955 for idx in 0..n_nodes {
4956 let id = fx.add("N", &format!("n{idx}"), vec![("v", i(idx as i64 % 8))]);
4958 node_ids.push(id);
4959 }
4960 let n = node_ids.len();
4961
4962 for (si, di) in &edge_pairs {
4964 let si = (*si as usize) % n;
4965 let di = (*di as usize) % n;
4966 if si != di {
4967 fx.edge("T", node_ids[si], node_ids[di], vec![]);
4968 }
4969 }
4970 for (si, di) in &recip_pairs {
4973 let si = (*si as usize) % n;
4974 let di = (*di as usize) % n;
4975 if si != di {
4976 fx.edge("T", node_ids[si], node_ids[di], vec![]);
4977 fx.edge("T", node_ids[di], node_ids[si], vec![]);
4978 }
4979 }
4980
4981 let v = fx.view();
4982 let params = BTreeMap::new();
4983
4984 let var_names = ["a", "b", "c", "d"];
4987 let hop_count = n_hops as usize;
4988 let mut pattern = format!("({}:N)", var_names[0]);
4989 for h in 0..hop_count {
4990 pattern.push_str(&format!("-[:T]->({}", var_names[h + 1]));
4991 if h + 1 == hop_count {
4993 pattern.push_str(":N)");
4994 } else {
4995 pattern.push(')');
4996 }
4997 }
4998 let last_var = var_names[hop_count];
4999 let where_clause = if use_filter {
5000 format!(" WHERE {last_var}.v > {threshold}")
5001 } else {
5002 String::new()
5003 };
5004 let ret_vars: Vec<&str> = var_names[..=hop_count].to_vec();
5005 let ret_clause = ret_vars.join(", ");
5006
5007 let full_q = format!("MATCH {pattern}{where_clause} RETURN {ret_clause}");
5008 let bounded_q = format!(
5009 "MATCH {pattern}{where_clause} RETURN {ret_clause} SKIP {skip} LIMIT {limit}"
5010 );
5011
5012 let full_plan = compile(&full_q);
5013 let unbounded = super::with_max_intermediate_rows(100_000, || {
5016 super::execute_unbounded(&v, &full_plan, &Params(¶ms))
5017 });
5018 let unbounded = match unbounded {
5021 Ok(rs) => rs,
5022 Err(_) => return Ok(()),
5023 };
5024 let total = unbounded.len();
5025 let full_rows = rows_of(&unbounded);
5026
5027 let bounded = super::with_max_intermediate_rows(100_000, || {
5028 run(&v, &bounded_q, ¶ms)
5029 }).expect("bounded must not error");
5030
5031 let s = (skip as usize).min(total);
5032 let e = (skip as usize + limit as usize).min(total);
5033 prop_assert_eq!(
5034 rows_of(&bounded),
5035 full_rows[s..e].to_vec(),
5036 "hops={} filter={} threshold={} SKIP {} LIMIT {}: \
5037 bounded != unbounded[{}..{}]",
5038 hop_count, use_filter, threshold, skip, limit, s, e
5039 );
5040 }
5041 }
5042
5043 proptest! {
5044 #[test]
5062 fn prop_scan_filter_fused_equals_unbounded_slice(
5063 n_nodes in 0u32..20u32,
5064 prop_mask in any::<u32>(),
5066 float_mask in any::<u32>(),
5068 threshold in -1i64..8i64,
5071 op_idx in 0u32..6u32,
5073 skip in 0u64..5u64,
5074 limit in 1u64..8u64,
5075 ) {
5076 let op_str = match op_idx {
5077 0 => "=",
5078 1 => "<>",
5079 2 => "<",
5080 3 => "<=",
5081 4 => ">",
5082 _ => ">=",
5083 };
5084
5085 let mut fx = Fx::new();
5086 for idx in 0..n_nodes {
5087 let has_prop = (prop_mask >> (idx % 32)) & 1 == 1;
5088 let use_float = (float_mask >> (idx % 32)) & 1 == 1;
5089 let props: Vec<(&str, Value)> = if has_prop {
5091 if use_float {
5092 vec![("v", f(idx as f64 % 7.0))]
5093 } else {
5094 vec![("v", i(idx as i64 % 7))]
5095 }
5096 } else {
5097 vec![]
5098 };
5099 fx.add("N", &format!("n{idx}"), props);
5100 }
5101
5102 let v = fx.view();
5103 let params = BTreeMap::new();
5104
5105 let full_q = format!("MATCH (n:N) WHERE n.v {op_str} {threshold} RETURN n");
5107 let bounded_q = format!(
5108 "MATCH (n:N) WHERE n.v {op_str} {threshold} RETURN n SKIP {skip} LIMIT {limit}"
5109 );
5110
5111 let full_plan = compile(&full_q);
5112 let unbounded = super::execute_unbounded(&v, &full_plan, &Params(¶ms));
5113 let unbounded = match unbounded {
5114 Ok(rs) => rs,
5115 Err(_) => return Ok(()),
5116 };
5117 let total = unbounded.len();
5118 let full_rows = rows_of(&unbounded);
5119
5120 let fires_before =
5122 super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
5123 let bounded = run(&v, &bounded_q, ¶ms).expect("fused bounded must not error");
5124 let fires_after =
5125 super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
5126
5127 if n_nodes > 0 && op_idx != 0 {
5135 prop_assert!(
5136 fires_after > fires_before,
5137 "fused arm did NOT fire for op={} threshold={} n_nodes={}: \
5138 counter before={} after={}",
5139 op_str,
5140 threshold,
5141 n_nodes,
5142 fires_before,
5143 fires_after
5144 );
5145 }
5146
5147 let s = (skip as usize).min(total);
5148 let e = (skip as usize + limit as usize).min(total);
5149 prop_assert_eq!(
5150 rows_of(&bounded),
5151 full_rows[s..e].to_vec(),
5152 "fused path: op={} threshold={} n_nodes={} SKIP {} LIMIT {}: \
5153 bounded != unbounded[{}..{}]",
5154 op_str, threshold, n_nodes, skip, limit, s, e
5155 );
5156
5157 let compound_q = format!(
5161 "MATCH (n:N) WHERE n.v {} {} AND n.v >= -999 RETURN n SKIP {} LIMIT {}",
5162 op_str, threshold, skip, limit
5163 );
5164 let fires_before_compound =
5165 super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
5166 let compound_bounded =
5167 run(&v, &compound_q, ¶ms).expect("compound-AND bounded must not error");
5168 let fires_after_compound =
5169 super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
5170
5171 prop_assert_eq!(
5173 fires_after_compound,
5174 fires_before_compound,
5175 "fused arm fired for compound-AND shape (should use generic path): \
5176 op={} threshold={} n_nodes={}",
5177 op_str,
5178 threshold,
5179 n_nodes
5180 );
5181 prop_assert_eq!(
5182 rows_of(&compound_bounded),
5183 full_rows[s..e].to_vec(),
5184 "compound-AND fallback: op={} threshold={} n_nodes={} SKIP {} LIMIT {}: \
5185 result differs from fused",
5186 op_str, threshold, n_nodes, skip, limit
5187 );
5188 }
5189 }
5190
5191 #[test]
5202 fn dense_hop1_with_filter_survives_pull() {
5203 const LEAVES: usize = 120; const CAP: usize = 100;
5205
5206 let mut fx = Fx::new();
5207 let src = fx.add("Src", "src", vec![]);
5208 for idx in 0..LEAVES {
5209 let leaf = fx.add("Leaf", &format!("l{idx}"), vec![("v", i(idx as i64))]);
5210 fx.edge("T", src, leaf, vec![]);
5211 }
5212 let v = fx.view();
5213 let params = BTreeMap::new();
5214
5215 let staged_err = super::with_max_intermediate_rows(CAP, || {
5217 super::execute_unbounded(
5218 &v,
5219 &compile("MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v >= 110 RETURN l, l.v"),
5220 &Params(¶ms),
5221 )
5222 });
5223 assert!(
5224 staged_err.is_err(),
5225 "staged path must error on 120 leaves with cap={CAP}"
5226 );
5227 assert!(
5228 staged_err
5229 .unwrap_err()
5230 .contains("intermediate result exceeds"),
5231 "wrong error message"
5232 );
5233
5234 let ok = super::with_max_intermediate_rows(CAP, || {
5238 run(
5239 &v,
5240 "MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v >= 110 RETURN l, l.v LIMIT 5",
5241 ¶ms,
5242 )
5243 });
5244 let rs = ok.expect("pull-based must survive despite dense hop-1 exceeding cap");
5245 assert_eq!(rs.len(), 5, "LIMIT 5 must return exactly 5 rows");
5246
5247 let vs: Vec<i64> = (0..rs.len())
5249 .filter_map(|i| match rs.get(i, "l.v") {
5250 Some(Value::Int(n)) => Some(*n),
5251 _ => None,
5252 })
5253 .collect();
5254 assert_eq!(vs.len(), 5, "all projected rows must have v");
5255 for v_val in &vs {
5256 assert!(*v_val >= 110, "filter must hold: v={v_val} is not >= 110");
5257 }
5258
5259 let (pull_result, pull_produced) = super::with_expand_counter(|| {
5263 super::with_max_intermediate_rows(1_000_000, || {
5264 run(
5265 &v,
5266 "MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v < 10 RETURN l LIMIT 5",
5267 ¶ms,
5268 )
5269 })
5270 });
5271 pull_result.expect("pull must succeed without cap");
5272 assert!(
5274 pull_produced <= 5,
5275 "pull expand count {pull_produced} should be ≤ 5 (stops after 5 passing leaves)"
5276 );
5277
5278 let (staged_result, staged_produced) = super::with_expand_counter(|| {
5279 super::with_max_intermediate_rows(1_000_000, || {
5280 super::execute_unbounded(
5281 &v,
5282 &compile("MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v < 10 RETURN l"),
5283 &Params(¶ms),
5284 )
5285 })
5286 });
5287 staged_result.expect("staged must succeed with 1M cap");
5288 assert_eq!(
5289 staged_produced, LEAVES,
5290 "staged must expand all {LEAVES} leaves"
5291 );
5292
5293 assert!(
5294 staged_produced >= pull_produced * 10,
5295 "staged ({staged_produced}) must be ≥ 10× pull ({pull_produced})"
5296 );
5297 }
5298
5299 #[test]
5317 fn harness_shape_two_hop_dense_survives_pull() {
5318 const N_TALENT: usize = 70;
5319 const N_COMPANY: usize = 20;
5320 const N_INDUSTRY: usize = 3;
5321 const CAP: usize = 100;
5322 const LIMIT: usize = 10;
5323
5324 let mut fx = Fx::new();
5325
5326 let mut talent_ids: Vec<u32> = Vec::new();
5328 let mut talent_industry: Vec<usize> = Vec::new();
5329 for i in 0..N_TALENT {
5330 let ind = i % N_INDUSTRY;
5331 let id = fx.add(
5332 "Talent",
5333 &format!("t{i}"),
5334 vec![("industry", s(&ind.to_string()))],
5335 );
5336 talent_ids.push(id);
5337 talent_industry.push(ind);
5338 }
5339
5340 let mut company_ids: Vec<u32> = Vec::new();
5342 let mut company_industry: Vec<usize> = Vec::new();
5343 for i in 0..N_COMPANY {
5344 let ind = i % N_INDUSTRY;
5345 let id = fx.add(
5346 "Company",
5347 &format!("c{i}"),
5348 vec![("industry", s(&ind.to_string()))],
5349 );
5350 company_ids.push(id);
5351 company_industry.push(ind);
5352 }
5353
5354 for (ti, &tid) in talent_ids.iter().enumerate() {
5356 for (ci, &cid) in company_ids.iter().enumerate() {
5357 if talent_industry[ti] == company_industry[ci] {
5358 fx.edge("INDUSTRY_ALIGNMENT", tid, cid, vec![]);
5359 }
5360 }
5361 }
5362
5363 let v = fx.view();
5364 let params = BTreeMap::new();
5365 let query = format!(
5366 "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company)\
5367 <-[:INDUSTRY_ALIGNMENT]-(t2:Talent) RETURN t, c, t2 LIMIT {LIMIT}"
5368 );
5369
5370 const UNBOUNDED_Q: &str = "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company)\
5372 <-[:INDUSTRY_ALIGNMENT]-(t2:Talent) RETURN t, c, t2";
5373 let staged_err = super::with_max_intermediate_rows(CAP, || {
5374 super::execute_unbounded(&v, &compile(UNBOUNDED_Q), &Params(¶ms))
5375 });
5376 assert!(
5377 staged_err.is_err(),
5378 "staged must error with cap={CAP} on harness-shape graph"
5379 );
5380 assert!(
5381 staged_err
5382 .unwrap_err()
5383 .contains("intermediate result exceeds"),
5384 "wrong error"
5385 );
5386
5387 let ok = super::with_max_intermediate_rows(CAP, || run(&v, &query, ¶ms));
5389 let rs = ok.expect("pull-based must complete on harness-shape with LIMIT 10");
5390 assert_eq!(rs.len(), LIMIT, "must return exactly {LIMIT} rows");
5391
5392 for i in 0..rs.len() {
5397 let row = rs.row(i);
5398 assert_eq!(row.len(), 3, "each row must have 3 columns (t, c, t2)");
5399 assert!(row.iter().all(|c| c.is_some()), "all cells must be Some");
5400 }
5401 }
5402
5403 #[test]
5404 fn intermediate_row_cap_errors_on_scan_and_expand() {
5405 let cap_msg = |n: usize| {
5406 format!(
5407 "intermediate result exceeds {n} rows; add a LIMIT or constrain patterns with shared variables"
5408 )
5409 };
5410
5411 let mut scan_fx = Fx::new();
5412 scan_fx.add("N", "a", vec![]);
5413 scan_fx.add("N", "b", vec![]);
5414 scan_fx.add("N", "c", vec![]);
5415 let sv = scan_fx.view();
5416 let scan_err = super::with_max_intermediate_rows(2, || {
5417 run(&sv, "MATCH (n:N) RETURN n", &BTreeMap::new())
5418 })
5419 .expect_err("3-row scan must exceed cap 2");
5420 assert_eq!(scan_err, cap_msg(2));
5421
5422 let mut exp_fx = Fx::new();
5423 let src = exp_fx.add("Src", "s", vec![]);
5424 let d1 = exp_fx.add("Dst", "d1", vec![]);
5425 let d2 = exp_fx.add("Dst", "d2", vec![]);
5426 exp_fx.edge("T", src, d1, vec![]);
5427 exp_fx.edge("T", src, d2, vec![]);
5428 let ev = exp_fx.view();
5429 let exp_err = super::with_max_intermediate_rows(1, || {
5431 run(&ev, "MATCH (x:Src)-[:T]->(y) RETURN x, y", &BTreeMap::new())
5432 })
5433 .expect_err("2-row expand must exceed cap 1");
5434 assert_eq!(exp_err, cap_msg(1));
5435 }
5436
5437 #[test]
5447 fn bounded_matches_unbounded_slice_various_limits() {
5448 let fx = hop_graph();
5450 let v = fx.view();
5451 let params = BTreeMap::new();
5452
5453 let full_plan = compile("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b");
5455 let full_rs = super::execute_unbounded(&v, &full_plan, &Params(¶ms)).unwrap();
5456 let full_rows = rows_of(&full_rs);
5457 assert_eq!(full_rows.len(), 3, "hop_graph has exactly 3 KNOWS paths");
5458
5459 for limit in [1u64, 2, 3, 10] {
5460 let q = format!("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b LIMIT {limit}");
5461 let rs = run(&v, &q, ¶ms).unwrap();
5462 let expected_len = (limit as usize).min(full_rows.len());
5463 assert_eq!(
5464 rs.len(),
5465 expected_len,
5466 "LIMIT {limit}: expected {expected_len} rows, got {}",
5467 rs.len()
5468 );
5469 assert_eq!(
5470 rows_of(&rs),
5471 full_rows[..expected_len],
5472 "LIMIT {limit}: rows differ from reference slice"
5473 );
5474 }
5475
5476 let skip_rs = run(
5478 &v,
5479 "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b SKIP 1 LIMIT 2",
5480 ¶ms,
5481 )
5482 .unwrap();
5483 assert_eq!(rows_of(&skip_rs), full_rows[1..3]);
5484
5485 let mut fx2 = Fx::new();
5487 fx2.add("N", "a", vec![("v", i(1))]);
5488 fx2.add("N", "b", vec![("v", i(2))]);
5489 fx2.add("N", "c", vec![("v", i(3))]);
5490 let v2 = fx2.view();
5491 let filter_full = super::execute_unbounded(
5493 &v2,
5494 &compile("MATCH (n:N) WHERE n.v > 1 RETURN n"),
5495 &Params(¶ms),
5496 )
5497 .unwrap();
5498 assert_eq!(filter_full.len(), 2);
5499
5500 let filter_lim = run(&v2, "MATCH (n:N) WHERE n.v > 1 RETURN n LIMIT 1", ¶ms).unwrap();
5501 assert_eq!(filter_lim.len(), 1, "LIMIT 1 on filter query");
5502 assert_eq!(rows_of(&filter_lim), rows_of(&filter_full)[..1]);
5503
5504 let tri = triangle();
5506 let tv = tri.view();
5507 let tri_full = super::execute_unbounded(
5509 &tv,
5510 &compile("MATCH (x)-[r1:T]->(y)-[r2:T]->(z) RETURN x, y, z"),
5511 &Params(¶ms),
5512 )
5513 .unwrap();
5514 assert_eq!(tri_full.len(), 3, "triangle has 3 unique two-hop paths");
5515 for limit in [1u64, 2, 3, 5] {
5516 let q = format!("MATCH (x)-[r1:T]->(y)-[r2:T]->(z) RETURN x, y, z LIMIT {limit}");
5517 let rs = run(&tv, &q, ¶ms).unwrap();
5518 let expected_len = (limit as usize).min(3);
5519 assert_eq!(
5520 rs.len(),
5521 expected_len,
5522 "triangle LIMIT {limit}: got {} rows",
5523 rs.len()
5524 );
5525 assert_eq!(
5526 rows_of(&rs),
5527 rows_of(&tri_full)[..expected_len],
5528 "triangle LIMIT {limit}: rows differ"
5529 );
5530 }
5531 }
5532
5533 #[test]
5537 fn expand_terminates_early_with_row_bound() {
5538 const LEAVES: usize = 500;
5539 let mut fx = Fx::new();
5540 let hub = fx.add("Hub", "hub", vec![]);
5541 for i in 0..LEAVES {
5542 let leaf = fx.add("Leaf", &format!("leaf-{i}"), vec![]);
5543 fx.edge("T", hub, leaf, vec![]);
5544 }
5545 let v = fx.view();
5546 let params = BTreeMap::new();
5547 let full_plan = compile("MATCH (h:Hub)-[:T]->(x:Leaf) RETURN x");
5548
5549 let (bounded_result, bounded_produced) = super::with_expand_counter(|| {
5551 run(&v, "MATCH (h:Hub)-[:T]->(x:Leaf) RETURN x LIMIT 5", ¶ms)
5552 });
5553 let bounded_rs = bounded_result.unwrap();
5554 assert_eq!(bounded_rs.len(), 5, "LIMIT 5 must return exactly 5 rows");
5555 assert!(
5556 bounded_produced <= 5,
5557 "bounded: exec_expand emitted {bounded_produced} rows, expected ≤ 5"
5558 );
5559
5560 let (unbounded_result, unbounded_produced) = super::with_expand_counter(|| {
5562 super::execute_unbounded(&v, &full_plan, &Params(¶ms))
5563 });
5564 let unbounded_rs = unbounded_result.unwrap();
5565 assert_eq!(
5566 unbounded_rs.len(),
5567 LEAVES,
5568 "unbounded must return all {LEAVES} rows"
5569 );
5570 assert_eq!(
5571 unbounded_produced, LEAVES,
5572 "unbounded: exec_expand must emit all {LEAVES} rows"
5573 );
5574
5575 assert!(
5577 unbounded_produced >= bounded_produced * 100,
5578 "unbounded ({unbounded_produced}) must be ≥ 100× bounded ({bounded_produced})"
5579 );
5580 }
5581
5582 #[test]
5588 fn bounded_query_survives_low_intermediate_row_cap() {
5589 let mut fx = Fx::new();
5591 let src = fx.add("Src", "s", vec![]);
5592 for i in 0..20usize {
5593 let dst = fx.add("Dst", &format!("d{i}"), vec![]);
5594 fx.edge("T", src, dst, vec![]);
5595 }
5596 let v = fx.view();
5597 let params = BTreeMap::new();
5598 let full_plan = compile("MATCH (s:Src)-[:T]->(d:Dst) RETURN d");
5599
5600 let cap_err = super::with_max_intermediate_rows(10, || {
5602 super::execute_unbounded(&v, &full_plan, &Params(¶ms))
5603 });
5604 assert!(
5605 cap_err.is_err(),
5606 "unbounded must hit the intermediate-row cap"
5607 );
5608 assert!(
5609 cap_err.unwrap_err().contains("intermediate result exceeds"),
5610 "cap error message must be the budget message"
5611 );
5612
5613 let ok = super::with_max_intermediate_rows(10, || {
5615 run(&v, "MATCH (s:Src)-[:T]->(d:Dst) RETURN d LIMIT 5", ¶ms)
5616 });
5617 assert_eq!(
5618 ok.unwrap().len(),
5619 5,
5620 "bounded (LIMIT 5) must complete with 5 rows, not a cap error"
5621 );
5622
5623 let at_cap = super::with_max_intermediate_rows(10, || {
5625 run(&v, "MATCH (s:Src)-[:T]->(d:Dst) RETURN d LIMIT 10", ¶ms)
5626 });
5627 assert_eq!(
5628 at_cap.unwrap().len(),
5629 10,
5630 "bounded at LIMIT==cap must complete with 10 rows"
5631 );
5632 }
5633
5634 #[test]
5639 fn count_star_returns_total_node_count() {
5640 let mut fx = Fx::new();
5641 fx.add("Person", "ada", vec![]);
5642 fx.add("Person", "bob", vec![]);
5643 fx.add("Person", "cam", vec![]);
5644 let v = fx.view();
5645 let params = BTreeMap::new();
5646
5647 let rs = run(&v, "MATCH (n:Person) RETURN COUNT(*)", ¶ms).expect("COUNT(*)");
5648 assert_eq!(rs.columns(), &["COUNT(*)".to_string()]);
5649 assert_eq!(rs.len(), 1);
5650 assert_eq!(rs.row(0), &[Some(i(3))]);
5651
5652 let rs_empty = run(&v, "MATCH (n:Ghost) RETURN COUNT(*)", ¶ms).expect("COUNT(*) empty");
5654 assert_eq!(rs_empty.row(0), &[Some(i(0))]);
5655 }
5656
5657 #[test]
5658 fn count_star_alias_sets_column_name() {
5659 let mut fx = Fx::new();
5660 fx.add("N", "a", vec![]);
5661 let v = fx.view();
5662 let params = BTreeMap::new();
5663 let rs = run(&v, "MATCH (n:N) RETURN COUNT(*) AS total", ¶ms).expect("COUNT AS");
5664 assert_eq!(rs.columns(), &["total".to_string()]);
5665 assert_eq!(rs.row(0), &[Some(i(1))]);
5666 }
5667
5668 #[test]
5669 fn count_var_skips_null_node_bindings() {
5670 let mut fx = Fx::new();
5673 fx.add("N", "a", vec![]);
5674 fx.add("N", "b", vec![]);
5675 let v = fx.view();
5676 let params = BTreeMap::new();
5677 let rs = run(&v, "MATCH (n:N) RETURN COUNT(n)", ¶ms).expect("COUNT(n)");
5678 assert_eq!(rs.columns(), &["COUNT(n)".to_string()]);
5679 assert_eq!(rs.row(0), &[Some(i(2))]);
5680 }
5681
5682 #[test]
5683 fn sum_numeric_prop_ignores_null_and_non_numeric() {
5684 let mut fx = Fx::new();
5685 fx.add("N", "a", vec![("v", i(10))]);
5686 fx.add("N", "b", vec![("v", i(20))]);
5687 fx.add("N", "c", vec![]); let v = fx.view();
5689 let params = BTreeMap::new();
5690 let rs = run(&v, "MATCH (n:N) RETURN SUM(n.v)", ¶ms).expect("SUM");
5691 assert_eq!(rs.columns(), &["SUM(n.v)".to_string()]);
5692 assert_eq!(rs.row(0), &[Some(f(30.0))]);
5694
5695 let rs_null = run(&v, "MATCH (n:N) RETURN SUM(n.missing)", ¶ms).expect("SUM null");
5697 assert_eq!(rs_null.row(0), &[None]);
5698 }
5699
5700 #[test]
5701 fn avg_numeric_prop() {
5702 let mut fx = Fx::new();
5703 fx.add("N", "a", vec![("v", i(10))]);
5704 fx.add("N", "b", vec![("v", i(30))]);
5705 let v = fx.view();
5706 let params = BTreeMap::new();
5707 let rs = run(&v, "MATCH (n:N) RETURN AVG(n.v) AS avg_v", ¶ms).expect("AVG");
5708 assert_eq!(rs.columns(), &["avg_v".to_string()]);
5709 assert_eq!(rs.row(0), &[Some(f(20.0))]);
5711
5712 let rs_empty = run(&v, "MATCH (n:Ghost) RETURN AVG(n.v)", ¶ms).expect("AVG empty");
5714 assert_eq!(rs_empty.row(0), &[None]);
5715 }
5716
5717 #[test]
5718 fn min_max_numeric_prop() {
5719 let mut fx = Fx::new();
5720 fx.add("N", "a", vec![("v", i(5))]);
5721 fx.add("N", "b", vec![("v", i(1))]);
5722 fx.add("N", "c", vec![("v", i(9))]);
5723 fx.add("N", "d", vec![]); let v = fx.view();
5725 let params = BTreeMap::new();
5726
5727 let min_rs = run(&v, "MATCH (n:N) RETURN MIN(n.v)", ¶ms).expect("MIN");
5728 assert_eq!(min_rs.row(0), &[Some(i(1))]);
5729
5730 let max_rs = run(&v, "MATCH (n:N) RETURN MAX(n.v)", ¶ms).expect("MAX");
5731 assert_eq!(max_rs.row(0), &[Some(i(9))]);
5732 }
5733
5734 #[test]
5737 fn min_max_mixed_int_float_props() {
5738 let mut fx = Fx::new();
5739 fx.add("N", "a", vec![("v", i(3))]);
5741 fx.add("N", "b", vec![("v", f(1.5))]);
5742 fx.add("N", "c", vec![("v", i(7))]);
5743 fx.add("N", "d", vec![("v", f(2.0))]);
5744 let v = fx.view();
5745 let params = BTreeMap::new();
5746
5747 let min_rs = run(&v, "MATCH (n:N) RETURN MIN(n.v)", ¶ms).expect("MIN mixed");
5748 assert_eq!(min_rs.row(0), &[Some(f(1.5))]);
5750
5751 let max_rs = run(&v, "MATCH (n:N) RETURN MAX(n.v)", ¶ms).expect("MAX mixed");
5752 assert_eq!(max_rs.row(0), &[Some(i(7))]);
5754 }
5755
5756 #[test]
5759 fn aggregate_limit_skip_order_by_are_no_ops() {
5760 let mut fx = Fx::new();
5761 fx.add("N", "a", vec![]);
5762 fx.add("N", "b", vec![]);
5763 fx.add("N", "c", vec![]);
5764 let v = fx.view();
5765 let params = BTreeMap::new();
5766
5767 let rs_lim5 =
5769 run(&v, "MATCH (n:N) RETURN COUNT(*) LIMIT 5", ¶ms).expect("COUNT(*) LIMIT 5");
5770 assert_eq!(
5771 rs_lim5.len(),
5772 1,
5773 "aggregate with LIMIT 5 must still return 1 row"
5774 );
5775 assert_eq!(rs_lim5.row(0), &[Some(i(3))]);
5776
5777 let rs_lim0 =
5779 run(&v, "MATCH (n:N) RETURN COUNT(*) LIMIT 0", ¶ms).expect("COUNT(*) LIMIT 0");
5780 assert_eq!(
5781 rs_lim0.len(),
5782 1,
5783 "aggregate with LIMIT 0 must still return 1 row"
5784 );
5785 assert_eq!(rs_lim0.row(0), &[Some(i(3))]);
5786
5787 let rs_skip =
5789 run(&v, "MATCH (n:N) RETURN COUNT(*) SKIP 100", ¶ms).expect("COUNT(*) SKIP 100");
5790 assert_eq!(
5791 rs_skip.len(),
5792 1,
5793 "aggregate with large SKIP must still return 1 row"
5794 );
5795
5796 let rs_ord = plan_src("MATCH (n:N) RETURN COUNT(*) ORDER BY n");
5800 assert!(
5803 rs_ord.is_ok(),
5804 "COUNT(*) ORDER BY should plan without error (ORDER BY dropped)"
5805 );
5806 let plan_ops = rs_ord.unwrap();
5807 assert!(
5809 !plan_ops
5810 .iter()
5811 .any(|op| matches!(op, crate::cypher::plan::PlanOp::OrderBy { .. })),
5812 "aggregate plan must not contain OrderBy"
5813 );
5814 }
5815
5816 #[test]
5817 fn count_star_no_budget_cap_applies() {
5818 let mut fx = Fx::new();
5821 let src = fx.add("Src", "s", vec![]);
5822 for i in 0..30usize {
5823 let dst = fx.add("Dst", &format!("d{i}"), vec![]);
5824 fx.edge("T", src, dst, vec![]);
5825 }
5826 let v = fx.view();
5827 let params = BTreeMap::new();
5828
5829 let cap_err =
5831 super::with_max_intermediate_rows(10, || run(&v, "MATCH (n:Dst) RETURN n", ¶ms));
5832 assert!(
5833 cap_err.is_err(),
5834 "staged path must error on 30 nodes with cap=10"
5835 );
5836
5837 let agg_ok = super::with_max_intermediate_rows(10, || {
5839 run(&v, "MATCH (n:Dst) RETURN COUNT(*)", ¶ms)
5840 })
5841 .expect("aggregate must not hit the intermediate-row cap");
5842 assert_eq!(
5843 agg_ok.row(0),
5844 &[Some(i(30))],
5845 "COUNT(*) must count all 30 nodes regardless of cap"
5846 );
5847 }
5848
5849 fn plan_src(src: &str) -> Result<Vec<crate::cypher::plan::PlanOp>, String> {
5850 use crate::cypher::{lex, parse, plan};
5851 let toks = lex(src).map_err(|e| format!("lex: {e}"))?;
5852 let ast = parse(&toks).map_err(|e| format!("parse: {e}"))?;
5853 plan(&ast).map_err(|e| format!("plan: {e}"))
5854 }
5855
5856 #[test]
5859 fn grouped_aggregation_plan_routing() {
5860 use crate::cypher::plan::PlanOp;
5861
5862 let ops = plan_src("MATCH (a:N) RETURN a, COUNT(*)")
5864 .expect("grouped aggregation must now succeed");
5865 assert!(
5866 ops.iter()
5867 .any(|op| matches!(op, PlanOp::GroupAggregate { .. })),
5868 "grouped aggregation plan must contain GroupAggregate op, got: {ops:?}"
5869 );
5870
5871 let ops2 = plan_src("MATCH (a:N) RETURN COUNT(*), COUNT(a)")
5873 .expect("multi-aggregate must now succeed");
5874 assert!(
5875 ops2.iter()
5876 .any(|op| matches!(op, PlanOp::GroupAggregate { .. })),
5877 "multi-aggregate plan must contain GroupAggregate op, got: {ops2:?}"
5878 );
5879
5880 let err3 = plan_src("MATCH (a:N) RETURN SUM(*)").expect_err("SUM(*) must be plan error");
5882 assert!(
5883 err3.to_ascii_lowercase().contains("sum") || err3.to_ascii_lowercase().contains("*"),
5884 "error must mention SUM or *, got: {err3}"
5885 );
5886 }
5887
5888 #[test]
5891 fn grouped_single_key_count() {
5892 let mut fx = Fx::new();
5894 fx.add("N", "a", vec![("t", s("X"))]);
5895 fx.add("N", "b", vec![("t", s("X"))]);
5896 fx.add("N", "c", vec![("t", s("Y"))]);
5897 let v = fx.view();
5898 let params = BTreeMap::new();
5899
5900 let rs = run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", ¶ms)
5901 .expect("single-key grouped COUNT must succeed");
5902 assert_eq!(
5903 rs.columns(),
5904 &["n.t".to_string(), "cnt".to_string()],
5905 "columns must match RETURN clause"
5906 );
5907 assert_eq!(rs.len(), 2, "must produce exactly 2 groups (X and Y)");
5908
5909 let find = |label: &Value| (0..rs.len()).find(|&i| rs.row(i)[0].as_ref() == Some(label));
5911 let xi = find(&s("X")).expect("group X must exist");
5912 let yi = find(&s("Y")).expect("group Y must exist");
5913 assert_eq!(rs.row(xi)[1], Some(i(2)), "X group count must be 2");
5914 assert_eq!(rs.row(yi)[1], Some(i(1)), "Y group count must be 1");
5915 }
5916
5917 #[test]
5918 fn grouped_two_keys_sum_avg() {
5919 let mut fx = Fx::new();
5921 fx.add(
5922 "N",
5923 "a",
5924 vec![("cat", s("A")), ("sub", s("1")), ("v", i(10))],
5925 );
5926 fx.add(
5927 "N",
5928 "b",
5929 vec![("cat", s("A")), ("sub", s("1")), ("v", i(20))],
5930 );
5931 fx.add(
5932 "N",
5933 "c",
5934 vec![("cat", s("A")), ("sub", s("2")), ("v", i(5))],
5935 );
5936 fx.add(
5937 "N",
5938 "d",
5939 vec![("cat", s("B")), ("sub", s("1")), ("v", i(100))],
5940 );
5941 let v = fx.view();
5942 let params = BTreeMap::new();
5943
5944 let rs = run(
5945 &v,
5946 "MATCH (n:N) RETURN n.cat, n.sub, SUM(n.v) AS total, AVG(n.v) AS avg_v",
5947 ¶ms,
5948 )
5949 .expect("two-key SUM + AVG must succeed");
5950 assert_eq!(
5951 rs.columns(),
5952 &[
5953 "n.cat".to_string(),
5954 "n.sub".to_string(),
5955 "total".to_string(),
5956 "avg_v".to_string()
5957 ]
5958 );
5959 assert_eq!(rs.len(), 3, "must produce 3 groups: (A,1), (A,2), (B,1)");
5960
5961 let find = |cat: &Value, sub: &Value| {
5962 (0..rs.len())
5963 .find(|&i| rs.row(i)[0].as_ref() == Some(cat) && rs.row(i)[1].as_ref() == Some(sub))
5964 };
5965 let a1 = find(&s("A"), &s("1")).expect("group (A,1) must exist");
5966 assert_eq!(rs.row(a1)[2], Some(f(30.0)), "(A,1) SUM must be 30.0");
5967 assert_eq!(rs.row(a1)[3], Some(f(15.0)), "(A,1) AVG must be 15.0");
5968
5969 let a2 = find(&s("A"), &s("2")).expect("group (A,2) must exist");
5970 assert_eq!(rs.row(a2)[2], Some(f(5.0)), "(A,2) SUM must be 5.0");
5971
5972 let b1 = find(&s("B"), &s("1")).expect("group (B,1) must exist");
5973 assert_eq!(rs.row(b1)[2], Some(f(100.0)), "(B,1) SUM must be 100.0");
5974 assert_eq!(rs.row(b1)[3], Some(f(100.0)), "(B,1) AVG must be 100.0");
5975 }
5976
5977 #[test]
5978 fn grouped_order_by_count_desc_limit() {
5979 let mut fx = Fx::new();
5981 fx.add("N", "a1", vec![("cat", s("A"))]);
5982 fx.add("N", "a2", vec![("cat", s("A"))]);
5983 fx.add("N", "a3", vec![("cat", s("A"))]);
5984 fx.add("N", "b1", vec![("cat", s("B"))]);
5985 fx.add("N", "b2", vec![("cat", s("B"))]);
5986 fx.add("N", "c1", vec![("cat", s("C"))]);
5987 fx.add("N", "d1", vec![("cat", s("D"))]);
5988 fx.add("N", "d2", vec![("cat", s("D"))]);
5989 fx.add("N", "d3", vec![("cat", s("D"))]);
5990 fx.add("N", "d4", vec![("cat", s("D"))]);
5991 fx.add("N", "e1", vec![("cat", s("E"))]);
5992 let v = fx.view();
5993 let params = BTreeMap::new();
5994
5995 let rs = run(
5996 &v,
5997 "MATCH (n:N) RETURN n.cat, COUNT(*) AS cnt ORDER BY cnt DESC LIMIT 3",
5998 ¶ms,
5999 )
6000 .expect("ORDER BY count DESC LIMIT 3 must succeed");
6001 assert_eq!(rs.len(), 3, "LIMIT 3 must return exactly 3 groups");
6002 assert_eq!(rs.row(0)[1], Some(i(4)), "row 0 must be count 4");
6004 assert_eq!(rs.row(0)[0], Some(s("D")), "row 0 must be category D");
6005 assert_eq!(rs.row(1)[1], Some(i(3)), "row 1 must be count 3");
6006 assert_eq!(rs.row(1)[0], Some(s("A")), "row 1 must be category A");
6007 assert_eq!(rs.row(2)[1], Some(i(2)), "row 2 must be count 2");
6008 assert_eq!(rs.row(2)[0], Some(s("B")), "row 2 must be category B");
6009
6010 let plan_ops =
6012 plan_src("MATCH (n:N) RETURN n.cat, COUNT(*) AS cnt ORDER BY cnt DESC LIMIT 3")
6013 .expect("plan must succeed");
6014 assert_eq!(
6015 crate::cypher::plan::row_bound(&plan_ops),
6016 None,
6017 "GroupAggregate plan with LIMIT must have row_bound = None"
6018 );
6019 }
6020
6021 #[test]
6022 fn grouped_empty_input_yields_zero_groups() {
6023 let fx = Fx::new(); let v = fx.view();
6025 let params = BTreeMap::new();
6026
6027 let rs = run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", ¶ms)
6028 .expect("grouped aggregate on empty graph must succeed");
6029 assert_eq!(rs.len(), 0, "empty input must yield zero groups");
6030 assert_eq!(
6031 rs.columns(),
6032 &["n.t".to_string(), "cnt".to_string()],
6033 "columns must still be present even with zero rows"
6034 );
6035 }
6036
6037 #[test]
6038 fn grouped_null_key_groups_together() {
6039 let mut fx = Fx::new();
6041 fx.add("N", "a", vec![("t", s("X"))]);
6042 fx.add("N", "b", vec![]); fx.add("N", "c", vec![]); fx.add("N", "d", vec![("t", s("Y"))]);
6045 let v = fx.view();
6046 let params = BTreeMap::new();
6047
6048 let rs = run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", ¶ms)
6049 .expect("null-key grouped aggregate must succeed");
6050 assert_eq!(rs.len(), 3, "must produce 3 groups: X, null, Y");
6051
6052 let null_row = (0..rs.len())
6054 .find(|&i| rs.row(i)[0].is_none())
6055 .expect("null group must be present");
6056 assert_eq!(
6057 rs.row(null_row)[1],
6058 Some(i(2)),
6059 "null group must count 2 rows (b and c)"
6060 );
6061
6062 let x_row = (0..rs.len())
6063 .find(|&i| rs.row(i)[0] == Some(s("X")))
6064 .expect("X group must exist");
6065 assert_eq!(rs.row(x_row)[1], Some(i(1)));
6066
6067 let y_row = (0..rs.len())
6068 .find(|&i| rs.row(i)[0] == Some(s("Y")))
6069 .expect("Y group must exist");
6070 assert_eq!(rs.row(y_row)[1], Some(i(1)));
6071 }
6072
6073 #[test]
6074 fn grouped_cap_error_on_high_cardinality() {
6075 let mut fx = Fx::new();
6078 fx.add("N", "a", vec![("t", s("A"))]);
6079 fx.add("N", "b", vec![("t", s("B"))]);
6080 fx.add("N", "c", vec![("t", s("C"))]);
6081 let v = fx.view();
6082 let params = BTreeMap::new();
6083
6084 let err = super::with_max_groups(2, || {
6085 run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", ¶ms)
6086 })
6087 .expect_err("must error when group count exceeds cap");
6088 assert!(
6089 err.to_ascii_lowercase().contains("group count"),
6090 "error must mention group count, got: {err}"
6091 );
6092 }
6093
6094 #[test]
6095 fn multi_aggregate_no_keys() {
6096 let mut fx = Fx::new();
6099 fx.add("N", "a", vec![]);
6100 fx.add("N", "b", vec![]);
6101 let v = fx.view();
6102 let params = BTreeMap::new();
6103
6104 let rs = run(&v, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", ¶ms)
6105 .expect("multi-aggregate no keys must succeed");
6106 assert_eq!(rs.len(), 1, "must produce exactly one result row");
6107 assert_eq!(rs.row(0)[0], Some(i(2)), "COUNT(*) must be 2");
6108 assert_eq!(rs.row(0)[1], Some(i(2)), "COUNT(n) must be 2");
6109 }
6110
6111 #[test]
6112 fn aggregate_over_hop_counts_edges() {
6113 let fx = hop_graph();
6114 let v = fx.view();
6115 let params = BTreeMap::new();
6116 let rs = run(
6118 &v,
6119 "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN COUNT(*)",
6120 ¶ms,
6121 )
6122 .expect("COUNT(*) on hop graph");
6123 assert_eq!(rs.row(0), &[Some(i(3))]);
6124 }
6125
6126 #[test]
6131 fn multi_aggregate_no_keys_empty_graph() {
6132 let fx = Fx::new(); let v = fx.view();
6138 let params = BTreeMap::new();
6139
6140 let rs = run(&v, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", ¶ms)
6141 .expect("empty-graph multi-agg must succeed");
6142 assert_eq!(rs.len(), 1, "must produce exactly 1 row on empty input");
6143 assert_eq!(
6144 rs.row(0)[0],
6145 Some(i(0)),
6146 "COUNT(*) on empty graph must be 0"
6147 );
6148 assert_eq!(
6149 rs.row(0)[1],
6150 Some(i(0)),
6151 "COUNT(n) on empty graph must be 0"
6152 );
6153 }
6154
6155 #[test]
6156 fn fast_path_and_group_path_agree_on_empty_input() {
6157 let fx = Fx::new();
6163 let v = fx.view();
6164 let params = BTreeMap::new();
6165
6166 let fast = run(&v, "MATCH (n:N) RETURN COUNT(*)", ¶ms)
6167 .expect("fast-path COUNT on empty graph");
6168 assert_eq!(fast.len(), 1, "fast path: 1 row on empty input");
6169 let fast_count = fast.row(0)[0].clone();
6170
6171 let grouped = run(&v, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", ¶ms)
6172 .expect("group-path COUNT on empty graph");
6173 assert_eq!(grouped.len(), 1, "group path: 1 row on empty input");
6174 let group_count = grouped.row(0)[0].clone();
6175
6176 assert_eq!(
6177 fast_count, group_count,
6178 "fast path and group path must agree on COUNT(*) for empty input"
6179 );
6180
6181 let mut fx2 = Fx::new();
6183 fx2.add("N", "x", vec![]);
6184 fx2.add("N", "y", vec![]);
6185 let v2 = fx2.view();
6186
6187 let fast2 = run(&v2, "MATCH (n:N) RETURN COUNT(*)", ¶ms)
6188 .expect("fast-path COUNT on 2-node graph");
6189 assert_eq!(fast2.row(0)[0], Some(i(2)), "fast path: COUNT(*) = 2");
6190
6191 let grouped2 = run(&v2, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", ¶ms)
6192 .expect("group-path COUNT on 2-node graph");
6193 assert_eq!(grouped2.row(0)[0], Some(i(2)), "group path: COUNT(*) = 2");
6194 assert_eq!(grouped2.row(0)[1], Some(i(2)), "group path: COUNT(n) = 2");
6195 }
6196
6197 #[test]
6202 fn grouped_int_float_key_unification() {
6203 let mut fx = Fx::new();
6210 fx.add("N", "a", vec![("score", i(1))]); fx.add("N", "b", vec![("score", f(1.0))]);
6212 let v = fx.view();
6213 let params = BTreeMap::new();
6214
6215 let rs = run(&v, "MATCH (n:N) RETURN n.score, COUNT(*) AS cnt", ¶ms)
6216 .expect("int/float unification must succeed");
6217 assert_eq!(
6218 rs.len(),
6219 1,
6220 "Int(1) and Float(1.0) must group together into 1 group"
6221 );
6222 assert_eq!(
6224 rs.row(0)[0],
6225 Some(i(1)),
6226 "key column must display Int(1) (first-seen)"
6227 );
6228 assert_eq!(rs.row(0)[1], Some(i(2)), "unified group must have count=2");
6229 }
6230
6231 #[test]
6232 fn distinct_collapses_duplicate_projected_values() {
6233 let mut fx = Fx::new();
6234 fx.add("N", "a1", vec![("city", s("Austin"))]);
6235 fx.add("N", "a2", vec![("city", s("Austin"))]);
6236 let v = fx.view();
6237 let params = BTreeMap::new();
6238 let rs = run(&v, "MATCH (n:N) RETURN DISTINCT n.city", ¶ms).unwrap();
6239 assert_eq!(rs.len(), 1);
6240 assert_eq!(rs.row(0)[0], Some(s("Austin")));
6241 }
6242
6243 #[test]
6244 fn distinct_int_float_unify() {
6245 let mut fx = Fx::new();
6246 fx.add("N", "a", vec![("score", i(1))]);
6247 fx.add("N", "b", vec![("score", f(1.0))]);
6248 let v = fx.view();
6249 let params = BTreeMap::new();
6250 let rs = run(&v, "MATCH (n:N) RETURN DISTINCT n.score", ¶ms).unwrap();
6251 assert_eq!(
6252 rs.len(),
6253 1,
6254 "Int(1) and Float(1.0) must DISTINCT as one row"
6255 );
6256 assert_eq!(rs.row(0)[0], Some(i(1)), "first-seen Int(1) wins display");
6257 }
6258
6259 #[test]
6260 fn distinct_caps_at_intermediate_row_budget() {
6261 let mut fx = Fx::new();
6262 fx.add("N", "a", vec![("city", s("Austin"))]);
6263 fx.add("N", "b", vec![("city", s("Paris"))]);
6264 let v = fx.view();
6265 let params = BTreeMap::new();
6266 let err = super::with_max_intermediate_rows(1, || {
6267 run(&v, "MATCH (n:N) RETURN DISTINCT n.city", ¶ms)
6268 })
6269 .expect_err("two distinct cities must exceed cap=1");
6270 assert!(
6271 err.contains("1") || err.contains("row"),
6272 "cap error must mention the budget, got: {err}"
6273 );
6274 }
6275
6276 #[test]
6277 fn where_in_list_filters_rows() {
6278 let mut fx = Fx::new();
6279 fx.add("N", "a", vec![("city", s("Austin"))]);
6280 fx.add("N", "p", vec![("city", s("Paris"))]);
6281 fx.add("N", "l", vec![("city", s("London"))]);
6282 let v = fx.view();
6283 let mut params = BTreeMap::new();
6284 params.insert("c".into(), s("Paris"));
6285 let rs = run(
6286 &v,
6287 "MATCH (n:N) WHERE n.city IN ['Austin', $c] RETURN n.city",
6288 ¶ms,
6289 )
6290 .unwrap();
6291 assert_eq!(rs.len(), 2);
6292 }
6293
6294 #[test]
6295 fn pure_int_keys_display_as_int() {
6296 let mut fx = Fx::new();
6301 fx.add("N", "a", vec![("age", i(10))]);
6302 fx.add("N", "b", vec![("age", i(20))]);
6303 fx.add("N", "c", vec![("age", i(10))]);
6304 let v = fx.view();
6305 let params = BTreeMap::new();
6306
6307 let rs = run(
6308 &v,
6309 "MATCH (n:N) RETURN n.age, COUNT(*) AS cnt ORDER BY n.age",
6310 ¶ms,
6311 )
6312 .expect("pure-Int group keys must succeed");
6313 assert_eq!(rs.len(), 2, "must have 2 groups: age=10 and age=20");
6314 assert_eq!(
6316 rs.row(0)[0],
6317 Some(i(10)),
6318 "age=10 key column must display as Int(10)"
6319 );
6320 assert_eq!(rs.row(0)[1], Some(i(2)), "age=10 group has 2 nodes");
6321 assert_eq!(
6322 rs.row(1)[0],
6323 Some(i(20)),
6324 "age=20 key column must display as Int(20)"
6325 );
6326 assert_eq!(rs.row(1)[1], Some(i(1)), "age=20 group has 1 node");
6327 }
6328
6329 #[test]
6334 fn var_expand_group_aggregate_staged_path() {
6335 let mut fx = Fx::new();
6345 let a = fx.add("N", "a", vec![("key", s("a"))]);
6346 let b = fx.add("N", "b", vec![("key", s("b"))]);
6347 let c = fx.add("N", "c", vec![("key", s("c"))]);
6348 fx.edge("T", a, b, vec![]);
6349 fx.edge("T", b, c, vec![]);
6350 let v = fx.view();
6351 let params = BTreeMap::new();
6352
6353 let rs = run(
6354 &v,
6355 "MATCH (x:N)-[*1..2]->(y:N) RETURN y.key, COUNT(*) AS cnt ORDER BY y.key",
6356 ¶ms,
6357 )
6358 .expect("VarExpand + GroupAggregate must succeed");
6359
6360 assert_eq!(rs.len(), 2, "must have 2 destination groups: b and c");
6361 assert_eq!(rs.row(0)[0], Some(s("b")), "first group key must be 'b'");
6362 assert_eq!(rs.row(0)[1], Some(i(1)), "b is reached via 1 path");
6363 assert_eq!(rs.row(1)[0], Some(s("c")), "second group key must be 'c'");
6364 assert_eq!(
6365 rs.row(1)[1],
6366 Some(i(2)),
6367 "c is reached via 2 paths (1-hop and 2-hop)"
6368 );
6369 }
6370
6371 #[test]
6379 fn pull_rows_var_expand_arm_returns_named_err() {
6380 let fx = Fx::new();
6381 let view = fx.view();
6382 let vars = super::VarTable {
6383 names: vec!["a".into(), "b".into()],
6384 };
6385 let project_items: Vec<crate::cypher::ast::RetItem> = vec![];
6386 let empty_params = BTreeMap::new();
6387 let params = super::Params(&empty_params);
6388 let ctx = super::PullCtx {
6389 view: &view,
6390 vars: &vars,
6391 project_items: &project_items,
6392 params: ¶ms,
6393 bound: 100,
6394 };
6395 let ops = vec![PlanOp::VarExpand {
6396 from: "a".into(),
6397 rel_var: None,
6398 etypes: vec![],
6399 dir: crate::cypher::RelDir::Right,
6400 to: "b".into(),
6401 min: 1,
6402 max: 3,
6403 }];
6404 let mut row = vec![None; vars.names.len()];
6405 let mut result = Vec::new();
6406 let err = super::pull_rows(&ctx, &ops, &mut row, &mut result)
6407 .expect_err("VarExpand must Err in pull_rows");
6408 assert!(
6409 err.contains("VarExpand") && err.contains("pull executor"),
6410 "error must name VarExpand and pull executor, got: {err}"
6411 );
6412 }
6413
6414 #[test]
6415 fn pull_rows_shortest_path_arm_returns_named_err() {
6416 let fx = Fx::new();
6417 let view = fx.view();
6418 let vars = super::VarTable {
6419 names: vec!["a".into(), "b".into()],
6420 };
6421 let project_items: Vec<crate::cypher::ast::RetItem> = vec![];
6422 let empty_params = BTreeMap::new();
6423 let params = super::Params(&empty_params);
6424 let ctx = super::PullCtx {
6425 view: &view,
6426 vars: &vars,
6427 project_items: &project_items,
6428 params: ¶ms,
6429 bound: 100,
6430 };
6431 let ops = vec![PlanOp::ShortestPath {
6432 from: "a".into(),
6433 rel_var: None,
6434 etypes: vec![],
6435 dir: crate::cypher::RelDir::Right,
6436 to: "b".into(),
6437 max_hops: 5,
6438 }];
6439 let mut row = vec![None; vars.names.len()];
6440 let mut result = Vec::new();
6441 let err = super::pull_rows(&ctx, &ops, &mut row, &mut result)
6442 .expect_err("ShortestPath must Err in pull_rows");
6443 assert!(
6444 err.contains("ShortestPath") && err.contains("pull executor"),
6445 "error must name ShortestPath and pull executor, got: {err}"
6446 );
6447 }
6448
6449 fn city_graph() -> Fx {
6453 let mut fx = Fx::new();
6454 fx.add(
6455 "Person",
6456 "alice",
6457 vec![("city", s("Boston")), ("age", i(30))],
6458 );
6459 fx.add("Person", "bob", vec![("city", s("Boston")), ("age", i(25))]);
6460 fx.add(
6461 "Person",
6462 "carol",
6463 vec![("city", s("Austin")), ("age", i(35))],
6464 );
6465 fx.add(
6466 "Person",
6467 "dave",
6468 vec![("city", s("Austin")), ("age", i(28))],
6469 );
6470 fx.add("Person", "eve", vec![("city", s("Boston")), ("age", i(22))]);
6471 fx
6472 }
6473
6474 #[test]
6477 fn with_aggregate_having_filters_groups() {
6478 let fx = city_graph();
6479 let view = fx.view();
6480 let params = BTreeMap::new();
6481 let rs = run(
6482 &view,
6483 "MATCH (p:Person) WITH p.city AS city, COUNT(*) AS cnt WHERE cnt > 2 RETURN city, cnt",
6484 ¶ms,
6485 )
6486 .expect("WITH HAVING query must succeed");
6487 assert_eq!(rs.len(), 1, "only Boston group has cnt > 2");
6488 assert_eq!(rs.get(0, "city"), Some(&s("Boston")));
6489 assert_eq!(rs.get(0, "cnt"), Some(&i(3)));
6490 }
6491
6492 #[test]
6494 fn with_order_limit_then_return() {
6495 let fx = city_graph();
6496 let view = fx.view();
6497 let params = BTreeMap::new();
6498 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)
6500 .expect("WITH ORDER LIMIT must succeed");
6501 assert_eq!(rs.len(), 2, "LIMIT 2");
6502 let ages: Vec<Option<Value>> = (0..rs.len()).map(|i| rs.get(i, "age").cloned()).collect();
6504 assert_eq!(ages[0], Some(i(35)), "oldest person first");
6505 assert_eq!(ages[1], Some(i(30)), "second oldest");
6506 }
6507
6508 #[test]
6510 fn chained_with_stages() {
6511 let fx = city_graph();
6512 let view = fx.view();
6513 let params = BTreeMap::new();
6514 let rs = run(&view,
6516 "MATCH (p:Person) WITH p.city AS city, p.age AS age WITH city, age WHERE age > 25 RETURN city, age",
6517 ¶ms).expect("chained WITH must succeed");
6518 assert_eq!(rs.len(), 3, "3 people with age > 25");
6520 }
6521
6522 #[test]
6524 fn with_then_match_reentry() {
6525 let mut fx = Fx::new();
6526 let alice = fx.add("Person", "alice", vec![]);
6527 let bob = fx.add("Person", "bob", vec![]);
6528 let corp = fx.add("Company", "acme", vec![]);
6529 fx.edge("WORKS_AT", alice, corp, vec![("years", i(5))]);
6530 fx.edge("WORKS_AT", bob, corp, vec![("years", i(3))]);
6531 let view = fx.view();
6532 let params = BTreeMap::new();
6533 let rs = run(&view,
6535 "MATCH (p:Person)-[r:WORKS_AT]->(c:Company) WITH p, c MATCH (c)-[r2:WORKS_AT]-(colleague:Person) RETURN p, colleague",
6536 ¶ms).expect("WITH MATCH re-entry must succeed");
6537 assert!(rs.len() >= 2, "cross join via company: got {}", rs.len());
6539 }
6540
6541 #[test]
6543 fn unwind_literal_list_produces_rows() {
6544 let fx = city_graph();
6545 let view = fx.view();
6546 let params = BTreeMap::new();
6547 let rs = run(
6548 &view,
6549 "MATCH (p:Person) WHERE p.city = 'Boston' UNWIND [1, 2, 3] AS x RETURN p, x",
6550 ¶ms,
6551 )
6552 .expect("UNWIND literal must succeed");
6553 assert_eq!(rs.len(), 9, "UNWIND [1,2,3] × 3 Boston people = 9 rows");
6555 let xs: Vec<Option<Value>> = (0..rs.len())
6557 .map(|row_i| rs.get(row_i, "x").cloned())
6558 .collect();
6559 assert!(xs.contains(&Some(i(1))));
6560 assert!(xs.contains(&Some(i(2))));
6561 assert!(xs.contains(&Some(i(3))));
6562 }
6563
6564 #[test]
6566 fn unwind_list_property() {
6567 let mut fx = Fx::new();
6568 fx.add(
6569 "Tag",
6570 "post1",
6571 vec![("tags", Value::List(vec![s("rust"), s("graph")]))],
6572 );
6573 fx.add("Tag", "post2", vec![("tags", Value::List(vec![s("db")]))]);
6574 let view = fx.view();
6575 let params = BTreeMap::new();
6576 let rs = run(
6577 &view,
6578 "MATCH (p:Tag) UNWIND p.tags AS tag RETURN p, tag",
6579 ¶ms,
6580 )
6581 .expect("UNWIND property must succeed");
6582 assert_eq!(rs.len(), 3, "2+1 tag elements");
6583 }
6584
6585 #[test]
6587 fn unwind_empty_list_yields_zero_rows() {
6588 let fx = city_graph();
6589 let view = fx.view();
6590 let params = BTreeMap::new();
6591 let rs = run(
6592 &view,
6593 "MATCH (p:Person) WHERE p.city = 'Boston' UNWIND [] AS x RETURN x",
6594 ¶ms,
6595 )
6596 .expect("UNWIND [] must succeed with 0 rows");
6597 assert_eq!(rs.len(), 0, "UNWIND [] should produce 0 rows");
6598 }
6599
6600 #[test]
6602 fn unwind_non_list_is_named_error() {
6603 let fx = city_graph();
6604 let view = fx.view();
6605 let params = BTreeMap::new();
6606 let err = run(
6608 &view,
6609 "MATCH (p:Person) WHERE p.city = 'Boston' UNWIND p.city AS x RETURN x",
6610 ¶ms,
6611 )
6612 .expect_err("UNWIND non-list must error");
6613 assert!(
6614 err.contains("UNWIND") && err.contains("list"),
6615 "error must mention UNWIND and list: {err}"
6616 );
6617 }
6618
6619 #[test]
6621 fn unwind_cross_product_trips_budget() {
6622 let mut fx = Fx::new();
6623 for i in 0..10 {
6625 fx.add("N", &format!("n{i}"), vec![]);
6626 }
6627 let view = fx.view();
6628 let params = BTreeMap::new();
6629 let err = super::with_max_intermediate_rows(5, || {
6630 run(
6631 &view,
6632 "MATCH (n:N) UNWIND [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] AS x RETURN n, x",
6633 ¶ms,
6634 )
6635 })
6636 .expect_err("must hit budget");
6637 assert!(
6638 err.contains("intermediate result exceeds"),
6639 "error must mention intermediate result limit: {err}"
6640 );
6641 }
6642
6643 #[test]
6646 fn routing_with_plan_uses_staged_path() {
6647 use crate::cypher::plan::row_bound;
6648 let ops_with = compile("MATCH (a) WITH a RETURN a");
6649 assert_eq!(
6650 row_bound(&ops_with),
6651 None,
6652 "WITH plan must have row_bound=None"
6653 );
6654
6655 let ops_unwind = compile("MATCH (a) UNWIND [1, 2] AS x RETURN a");
6656 assert_eq!(
6657 row_bound(&ops_unwind),
6658 None,
6659 "UNWIND plan must have row_bound=None"
6660 );
6661
6662 let ops_plain = compile("MATCH (a) RETURN a LIMIT 5");
6664 assert!(
6665 row_bound(&ops_plain).is_some(),
6666 "plain LIMIT plan must have a row bound"
6667 );
6668 }
6669
6670 #[test]
6673 fn unwind_null_property_yields_zero_rows_for_that_node() {
6674 let mut fx = Fx::new();
6675 fx.add(
6677 "Post",
6678 "post1",
6679 vec![("tags", Value::List(vec![s("rust"), s("graph")]))],
6680 );
6681 fx.add("Post", "post2", vec![("other", Value::Str("hello".into()))]);
6682 let view = fx.view();
6683 let params = BTreeMap::new();
6684 let rs = run(
6685 &view,
6686 "MATCH (p:Post) UNWIND p.tags AS tag RETURN tag",
6687 ¶ms,
6688 )
6689 .expect("UNWIND null property must succeed (not error)");
6690 assert_eq!(
6692 rs.len(),
6693 2,
6694 "null property must yield 0 rows; list property yields N rows"
6695 );
6696 let tag0 = rs.get(0, "tag");
6697 let tag1 = rs.get(1, "tag");
6698 let tags = [tag0, tag1];
6699 assert!(tags.contains(&Some(&s("rust"))), "rust tag present");
6700 assert!(tags.contains(&Some(&s("graph"))), "graph tag present");
6701 }
6702
6703 #[test]
6705 fn where_before_unwind_filters_nodes() {
6706 let mut fx = Fx::new();
6707 fx.add(
6708 "P",
6709 "a",
6710 vec![
6711 ("group", Value::Str("keep".into())),
6712 ("items", Value::List(vec![Value::Int(1), Value::Int(2)])),
6713 ],
6714 );
6715 fx.add(
6716 "P",
6717 "b",
6718 vec![
6719 ("group", Value::Str("drop".into())),
6720 ("items", Value::List(vec![Value::Int(3), Value::Int(4)])),
6721 ],
6722 );
6723 let view = fx.view();
6724 let params = BTreeMap::new();
6725 let rs = run(
6727 &view,
6728 "MATCH (n:P) WHERE n.group = 'keep' UNWIND n.items AS x RETURN x",
6729 ¶ms,
6730 )
6731 .expect("WHERE before UNWIND must succeed");
6732 assert_eq!(rs.len(), 2, "only matching node expands; 2 items");
6733 }
6734
6735 #[test]
6737 fn where_after_unwind_filters_expanded_rows() {
6738 let mut fx = Fx::new();
6739 fx.add(
6740 "N",
6741 "n1",
6742 vec![(
6743 "vals",
6744 Value::List(vec![Value::Int(1), Value::Int(3), Value::Int(5)]),
6745 )],
6746 );
6747 let view = fx.view();
6748 let params = BTreeMap::new();
6749 let rs = run(
6751 &view,
6752 "MATCH (n:N) UNWIND n.vals AS x WHERE x > 2 RETURN x",
6753 ¶ms,
6754 )
6755 .expect("WHERE after UNWIND must succeed");
6756 assert_eq!(rs.len(), 2, "x=3 and x=5 pass; x=1 filtered out");
6757 let v0 = rs.get(0, "x").cloned();
6758 let v1 = rs.get(1, "x").cloned();
6759 let vals = [v0, v1];
6760 assert!(vals.contains(&Some(Value::Int(3))), "x=3 present");
6761 assert!(vals.contains(&Some(Value::Int(5))), "x=5 present");
6762 }
6763
6764 #[test]
6766 fn aggregate_with_order_by_unknown_alias_is_named_error() {
6767 use crate::cypher::{lex, parser::parse, plan::plan};
6768 let src = "MATCH (p:Person) WITH p.city AS city, COUNT(*) AS cnt ORDER BY cntt DESC RETURN city, cnt";
6770 let plan_result = plan(&parse(&lex(src).expect("lex")).expect("parse"));
6771 let err = plan_result
6772 .expect_err("typo'd ORDER BY alias in aggregate WITH must be a named plan error");
6773 assert!(
6774 err.contains("cntt") || err.contains("unbound"),
6775 "error must mention the unknown alias: {err}"
6776 );
6777 }
6778
6779 #[test]
6781 fn non_aggregate_with_order_by_unknown_alias_is_named_error() {
6782 use crate::cypher::{lex, parser::parse, plan::plan};
6783 let src = "MATCH (p:Person) WITH p, p.age AS age ORDER BY nope DESC RETURN p.city AS city";
6784 let plan_result = plan(&parse(&lex(src).expect("lex")).expect("parse"));
6785 let err = plan_result
6786 .expect_err("typo'd ORDER BY alias in non-aggregate WITH must be a named plan error");
6787 assert!(
6788 err.contains("nope") || err.contains("unbound"),
6789 "error must mention the unknown alias: {err}"
6790 );
6791 }
6792
6793 #[test]
6797 fn post_unwind_where_references_pre_match_variable() {
6798 let mut fx = Fx::new();
6799 fx.add(
6801 "N",
6802 "n1",
6803 vec![
6804 ("threshold", Value::Int(3)),
6805 (
6806 "xs",
6807 Value::List(vec![
6808 Value::Int(1),
6809 Value::Int(2),
6810 Value::Int(3),
6811 Value::Int(4),
6812 Value::Int(5),
6813 ]),
6814 ),
6815 ],
6816 );
6817 fx.add(
6819 "N",
6820 "n2",
6821 vec![
6822 ("threshold", Value::Int(10)),
6823 ("xs", Value::List(vec![Value::Int(1), Value::Int(2)])),
6824 ],
6825 );
6826 let view = fx.view();
6827 let params = BTreeMap::new();
6828 let rs = run(
6829 &view,
6830 "MATCH (n:N) UNWIND n.xs AS x WHERE x > n.threshold RETURN x",
6831 ¶ms,
6832 )
6833 .expect("post-UNWIND WHERE referencing MATCH variable must succeed");
6834 assert_eq!(rs.len(), 2, "exactly 2 rows: x=4 and x=5 from n1");
6836 let v0 = rs.get(0, "x").cloned();
6837 let v1 = rs.get(1, "x").cloned();
6838 let got = [v0, v1];
6839 assert!(got.contains(&Some(Value::Int(4))), "x=4 present");
6840 assert!(got.contains(&Some(Value::Int(5))), "x=5 present");
6841 }
6842
6843 #[test]
6847 fn with_stage_aggregate_order_by_descending() {
6848 let fx = city_graph();
6851 let view = fx.view();
6852 let params = BTreeMap::new();
6853 let rs = run(
6854 &view,
6855 "MATCH (p:Person) WITH p.city AS city, COUNT(*) AS cnt ORDER BY cnt DESC RETURN city, cnt",
6856 ¶ms,
6857 )
6858 .expect("aggregate WITH ORDER BY must succeed");
6859 assert_eq!(rs.len(), 2, "two city groups");
6860 assert_eq!(
6862 rs.get(0, "cnt"),
6863 Some(&Value::Int(3)),
6864 "first row must be the group with cnt=3 (Boston)"
6865 );
6866 assert_eq!(
6867 rs.get(1, "cnt"),
6868 Some(&Value::Int(2)),
6869 "second row must be the group with cnt=2 (Austin)"
6870 );
6871 }
6872
6873 #[test]
6877 fn unwind_then_with_composition() {
6878 let mut fx = Fx::new();
6879 fx.add(
6880 "Doc",
6881 "d1",
6882 vec![(
6883 "scores",
6884 Value::List(vec![Value::Int(10), Value::Int(20), Value::Int(30)]),
6885 )],
6886 );
6887 fx.add(
6888 "Doc",
6889 "d2",
6890 vec![("scores", Value::List(vec![Value::Int(5), Value::Int(15)]))],
6891 );
6892 let view = fx.view();
6893 let params = BTreeMap::new();
6894
6895 let rs = run(
6897 &view,
6898 "MATCH (n:Doc) UNWIND n.scores AS x WITH x RETURN x",
6899 ¶ms,
6900 )
6901 .expect("UNWIND then WITH pass-through must succeed");
6902 assert_eq!(rs.len(), 5, "3 + 2 = 5 expanded rows carried through WITH");
6903
6904 let rs2 = run(
6906 &view,
6907 "MATCH (n:Doc) UNWIND n.scores AS x WITH COUNT(*) AS total RETURN total",
6908 ¶ms,
6909 )
6910 .expect("UNWIND then aggregate WITH must succeed");
6911 assert_eq!(rs2.len(), 1, "single aggregate row");
6912 assert_eq!(
6913 rs2.get(0, "total"),
6914 Some(&Value::Int(5)),
6915 "total must be 5 (3+2 expanded rows)"
6916 );
6917 }
6918
6919 #[test]
6925 fn binarith_div_min_over_neg1_does_not_panic() {
6926 let fx = Fx::new();
6927 let view = fx.view();
6928 let vars = VarTable { names: vec![] };
6929 let row: Row = vec![];
6930 let params = BTreeMap::new();
6931
6932 let operand = Operand::BinArith {
6934 op: ArithOp::Div,
6935 left: Box::new(Operand::Lit(Value::Int(i64::MIN))),
6936 right: Box::new(Operand::Lit(Value::Int(-1))),
6937 };
6938
6939 let result = resolve_operand(&view, &vars, &row, &operand, &Params(¶ms));
6940 assert!(
6941 result.is_ok(),
6942 "overflow division must not return Err: {result:?}"
6943 );
6944 assert_eq!(
6945 result.unwrap(),
6946 Some(Value::Int(i64::MAX)),
6947 "i64::MIN / -1 must saturate to i64::MAX, not panic"
6948 );
6949 }
6950
6951 #[test]
6956 fn binarith_missing_param_caught_at_preflight() {
6957 let fx = Fx::new();
6958 let view = fx.view();
6959 let err = run(
6964 &view,
6965 "MATCH (n:X) RETURN abs($missing - 1)",
6966 &BTreeMap::new(),
6967 )
6968 .expect_err("missing param inside BinArith must be caught at preflight");
6969 assert!(
6970 err.contains("missing"),
6971 "error must name the param 'missing', got: {err}"
6972 );
6973 }
6974
6975 #[test]
6979 fn is_null_filters_absent_prop() {
6980 let mut fx = Fx::new();
6981 fx.add("Person", "alice", vec![("age", Value::Int(30))]);
6982 fx.add("Person", "bob", vec![]); let view = fx.view();
6984 let rs = run(
6985 &view,
6986 "MATCH (n:Person) WHERE n.age IS NULL RETURN n",
6987 &BTreeMap::new(),
6988 )
6989 .unwrap();
6990 assert_eq!(rs.len(), 1, "only bob lacks age");
6991 assert_eq!(rs.get(0, "n"), Some(&s("bob")));
6992 }
6993
6994 #[test]
6996 fn is_not_null_filters_present_prop() {
6997 let mut fx = Fx::new();
6998 fx.add("Person", "alice", vec![("age", Value::Int(30))]);
6999 fx.add("Person", "bob", vec![]); let view = fx.view();
7001 let rs = run(
7002 &view,
7003 "MATCH (n:Person) WHERE n.age IS NOT NULL RETURN n",
7004 &BTreeMap::new(),
7005 )
7006 .unwrap();
7007 assert_eq!(rs.len(), 1, "only alice has age");
7008 assert_eq!(rs.get(0, "n"), Some(&s("alice")));
7009 }
7010
7011 #[test]
7014 fn optional_match_is_null_anti_join() {
7015 let mut fx = Fx::new();
7016 let alice = fx.add("Person", "alice", vec![]);
7017 let bob = fx.add("Person", "bob", vec![]);
7018 let carol = fx.add("Person", "carol", vec![]);
7019 fx.edge("KNOWS", alice, carol, vec![]); fx.edge("KNOWS", carol, bob, vec![]); let view = fx.view();
7023 let rs = run(
7024 &view,
7025 "MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b) WITH a, b WHERE b IS NULL RETURN a",
7026 &BTreeMap::new(),
7027 )
7028 .unwrap();
7029 assert_eq!(rs.len(), 1, "only bob has no outgoing KNOWS edge");
7030 assert_eq!(rs.get(0, "a"), Some(&s("bob")));
7031 }
7032
7033 #[test]
7035 fn is_null_combined_with_and_exec() {
7036 let mut fx = Fx::new();
7037 fx.add("N", "a", vec![("x", Value::Int(1))]);
7038 fx.add("N", "b", vec![("x", Value::Int(2)), ("y", Value::Int(9))]);
7039 fx.add("N", "c", vec![]); let view = fx.view();
7041 let rs = run(
7043 &view,
7044 "MATCH (n:N) WHERE n.y IS NULL AND n.x IS NOT NULL RETURN n",
7045 &BTreeMap::new(),
7046 )
7047 .unwrap();
7048 assert_eq!(rs.len(), 1);
7049 assert_eq!(rs.get(0, "n"), Some(&s("a")));
7050 }
7051
7052 #[test]
7056 fn arithmetic_add_in_return() {
7057 let mut fx = Fx::new();
7058 fx.add("Person", "alice", vec![("age", Value::Int(29))]);
7059 let view = fx.view();
7060 let rs = run(
7061 &view,
7062 "MATCH (n:Person) RETURN n.age + 1 AS adjusted",
7063 &BTreeMap::new(),
7064 )
7065 .unwrap();
7066 assert_eq!(rs.len(), 1);
7067 assert_eq!(rs.get(0, "adjusted"), Some(&Value::Int(30)));
7068 }
7069
7070 #[test]
7072 fn arithmetic_precedence_parens_pin() {
7073 let mut fx = Fx::new();
7074 fx.add("N", "x", vec![]);
7075 let view = fx.view();
7076 let rs = run(
7077 &view,
7078 "MATCH (n:N) RETURN (1 + 2) * 3 AS r",
7079 &BTreeMap::new(),
7080 )
7081 .unwrap();
7082 assert_eq!(rs.len(), 1);
7083 assert_eq!(rs.get(0, "r"), Some(&Value::Int(9)));
7084 }
7085
7086 #[test]
7088 fn arithmetic_precedence_mul_over_add_pin() {
7089 let mut fx = Fx::new();
7090 fx.add("N", "x", vec![]);
7091 let view = fx.view();
7092 let rs = run(&view, "MATCH (n:N) RETURN 1 + 2 * 3 AS r", &BTreeMap::new()).unwrap();
7093 assert_eq!(rs.len(), 1);
7094 assert_eq!(rs.get(0, "r"), Some(&Value::Int(7)));
7095 }
7096
7097 #[test]
7099 fn arithmetic_div_by_zero_returns_error() {
7100 let mut fx = Fx::new();
7101 fx.add("N", "x", vec![]);
7102 let view = fx.view();
7103 let err = run(
7104 &view,
7105 "MATCH (n:N) WHERE 1 / 0 > 0 RETURN n",
7106 &BTreeMap::new(),
7107 )
7108 .expect_err("division by zero must error");
7109 assert!(
7110 err.contains("division by zero"),
7111 "error must mention division by zero, got: {err}"
7112 );
7113 }
7114
7115 #[test]
7117 fn arithmetic_null_propagates() {
7118 let mut fx = Fx::new();
7119 fx.add("N", "x", vec![]); let view = fx.view();
7121 let rs = run(
7123 &view,
7124 "MATCH (n:N) WHERE n.val + 1 > 0 RETURN n",
7125 &BTreeMap::new(),
7126 )
7127 .unwrap();
7128 assert_eq!(rs.len(), 0, "null arithmetic must not match");
7129 }
7130
7131 #[test]
7133 fn case_when_expression_in_return() {
7134 let mut fx = Fx::new();
7135 fx.add("N", "a", vec![("id", s("a")), ("age", i(20))]);
7136 fx.add("N", "b", vec![("id", s("b")), ("age", i(65))]);
7137 let v = fx.view();
7138 let rs = run(
7139 &v,
7140 "MATCH (n:N) RETURN n.id AS id, \
7141 CASE WHEN n.age >= 65 THEN 'senior' ELSE 'other' END AS band",
7142 &BTreeMap::new(),
7143 )
7144 .unwrap();
7145 let band_of = |who: &str| {
7146 (0..rs.len())
7147 .find(|&i| rs.get(i, "id") == Some(&s(who)))
7148 .and_then(|i| rs.get(i, "band").cloned())
7149 };
7150 assert_eq!(band_of("a"), Some(s("other")));
7151 assert_eq!(band_of("b"), Some(s("senior")));
7152 }
7153
7154 #[test]
7155 fn case_when_no_else_yields_null() {
7156 let mut fx = Fx::new();
7157 fx.add("N", "a", vec![("age", i(20))]);
7158 let v = fx.view();
7159 let rs = run(
7160 &v,
7161 "MATCH (n:N) RETURN CASE WHEN n.age >= 65 THEN 'senior' END AS band",
7162 &BTreeMap::new(),
7163 )
7164 .unwrap();
7165 assert_eq!(rs.get(0, "band"), None);
7166 }
7167
7168 #[test]
7169 fn multi_relationship_type_pattern_matches_either() {
7170 let mut fx = Fx::new();
7171 let a = fx.add("N", "a", vec![("id", s("a"))]);
7172 let b = fx.add("N", "b", vec![("id", s("b"))]);
7173 let c = fx.add("N", "c", vec![("id", s("c"))]);
7174 let d = fx.add("N", "d", vec![("id", s("d"))]);
7175 fx.edge("KNOWS", a, b, vec![]);
7176 fx.edge("LIKES", a, c, vec![]);
7177 fx.edge("HATES", a, d, vec![]);
7178 let v = fx.view();
7179 let rs = run(
7181 &v,
7182 "MATCH (a:N {id: 'a'})-[r:KNOWS|:LIKES]->(x) RETURN x",
7183 &BTreeMap::new(),
7184 )
7185 .unwrap();
7186 assert_eq!(rs.len(), 2, "KNOWS|LIKES reaches exactly b and c");
7187 }
7188
7189 #[test]
7190 fn collect_grouped_gathers_values_per_group() {
7191 let mut fx = Fx::new();
7192 fx.add("P", "a", vec![("city", s("austin")), ("name", s("Ann"))]);
7193 fx.add("P", "b", vec![("city", s("austin")), ("name", s("Bob"))]);
7194 fx.add("P", "c", vec![("city", s("boston")), ("name", s("Cy"))]);
7195 let v = fx.view();
7196 let rs = run(
7197 &v,
7198 "MATCH (n:P) RETURN n.city AS city, collect(n.name) AS names",
7199 &BTreeMap::new(),
7200 )
7201 .unwrap();
7202 assert_eq!(rs.len(), 2, "two city groups");
7203 let austin = (0..rs.len())
7205 .find(|&i| rs.get(i, "city") == Some(&s("austin")))
7206 .expect("austin group present");
7207 assert_eq!(
7208 rs.get(austin, "names"),
7209 Some(&Value::List(vec![s("Ann"), s("Bob")]))
7210 );
7211 }
7212
7213 #[test]
7214 fn collect_ungrouped_gathers_all_into_one_list() {
7215 let mut fx = Fx::new();
7216 fx.add("P", "a", vec![("name", s("Ann"))]);
7217 fx.add("P", "b", vec![("name", s("Bob"))]);
7218 let v = fx.view();
7219 let rs = run(
7220 &v,
7221 "MATCH (n:P) RETURN collect(n.name) AS names",
7222 &BTreeMap::new(),
7223 )
7224 .unwrap();
7225 assert_eq!(rs.len(), 1);
7226 assert_eq!(
7227 rs.get(0, "names"),
7228 Some(&Value::List(vec![s("Ann"), s("Bob")]))
7229 );
7230 }
7231
7232 #[test]
7233 fn string_predicate_functions_in_where() {
7234 let mut fx = Fx::new();
7235 fx.add("N", "a", vec![("email", s("alice@acme.com"))]);
7236 fx.add("N", "b", vec![("email", s("bob@other.org"))]);
7237 let v = fx.view();
7238 let p = BTreeMap::new();
7239 assert_eq!(
7240 run(
7241 &v,
7242 "MATCH (n:N) WHERE endsWith(n.email, '.com') RETURN n",
7243 &p
7244 )
7245 .unwrap()
7246 .len(),
7247 1
7248 );
7249 assert_eq!(
7250 run(
7251 &v,
7252 "MATCH (n:N) WHERE startsWith(n.email, 'bob') RETURN n",
7253 &p
7254 )
7255 .unwrap()
7256 .len(),
7257 1
7258 );
7259 assert_eq!(
7260 run(
7261 &v,
7262 "MATCH (n:N) WHERE contains(n.email, 'acme') RETURN n",
7263 &p
7264 )
7265 .unwrap()
7266 .len(),
7267 1
7268 );
7269 }
7270
7271 #[test]
7272 fn coercion_functions_in_return() {
7273 let mut fx = Fx::new();
7274 fx.add("N", "a", vec![("s", s("42")), ("n", i(7)), ("g", f(3.9))]);
7275 let v = fx.view();
7276 let rs = run(
7277 &v,
7278 "MATCH (n:N) RETURN toInteger(n.s) AS ti, toFloat(n.n) AS tf, toString(n.g) AS ts",
7279 &BTreeMap::new(),
7280 )
7281 .unwrap();
7282 assert_eq!(rs.get(0, "ti"), Some(&Value::Int(42)));
7283 assert_eq!(rs.get(0, "tf"), Some(&Value::Float(7.0)));
7284 assert_eq!(rs.get(0, "ts"), Some(&Value::Str("3.9".into())));
7285 }
7286
7287 #[test]
7288 fn to_integer_unparseable_string_is_null() {
7289 let mut fx = Fx::new();
7290 fx.add("N", "a", vec![("s", s("not-a-number"))]);
7291 let v = fx.view();
7292 let rs = run(
7293 &v,
7294 "MATCH (n:N) RETURN toInteger(n.s) AS ti",
7295 &BTreeMap::new(),
7296 )
7297 .unwrap();
7298 assert_eq!(rs.get(0, "ti"), None);
7299 }
7300
7301 #[test]
7302 fn index_scan_uses_index_and_matches_fallback() {
7303 use core_storage::property_index::PropertyIndex;
7304 use std::sync::atomic::Ordering;
7305
7306 let mut fx = Fx::new();
7307 let a = fx.add("Person", "a", vec![("city", s("austin"))]);
7308 let _b = fx.add("Person", "b", vec![("city", s("boston"))]);
7309 let c = fx.add("Person", "c", vec![("city", s("austin"))]);
7310
7311 let mut pi = PropertyIndex::new();
7312 pi.enable("Person", "city");
7313 pi.set("Person", "city", a, &s("austin"));
7314 pi.set("Person", "city", 1, &s("boston"));
7315 pi.set("Person", "city", c, &s("austin"));
7316
7317 let q = "MATCH (n:Person {city: 'austin'}) RETURN n";
7318
7319 let before = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7321 let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7322 let after = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7323 assert!(after > before, "IndexScan must take the indexed path");
7324 assert_eq!(indexed.len(), 2);
7325
7326 let before2 = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7329 let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7330 let after2 = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7331 assert_eq!(after2, before2, "fallback must not touch the index counter");
7332 assert_eq!(
7333 fallback.len(),
7334 indexed.len(),
7335 "fallback matches indexed result"
7336 );
7337 }
7338
7339 #[test]
7340 fn arithmetic_in_where_comparison() {
7341 let mut fx = Fx::new();
7342 fx.add("Person", "alice", vec![("age", Value::Int(5))]); fx.add("Person", "bob", vec![("age", Value::Int(4))]); let view = fx.view();
7345 let rs = run(
7346 &view,
7347 "MATCH (n:Person) WHERE n.age + 1 > 5 RETURN n",
7348 &BTreeMap::new(),
7349 )
7350 .unwrap();
7351 assert_eq!(rs.len(), 1);
7352 assert_eq!(rs.get(0, "n"), Some(&s("alice")));
7353 }
7354
7355 #[test]
7359 fn where_fold_indexed_matches_fallback() {
7360 use core_storage::property_index::PropertyIndex;
7361 use std::sync::atomic::Ordering;
7362
7363 let mut fx = Fx::new();
7364 let a = fx.add("Person", "alice", vec![("city", s("austin"))]);
7365 let b = fx.add("Person", "bob", vec![("city", s("boston"))]);
7366 let c = fx.add("Person", "carol", vec![("city", s("austin"))]);
7367
7368 let mut pi = PropertyIndex::new();
7369 pi.enable("Person", "city");
7370 pi.set("Person", "city", a, &s("austin"));
7371 pi.set("Person", "city", b, &s("boston"));
7372 pi.set("Person", "city", c, &s("austin"));
7373
7374 let q = "MATCH (n:Person) WHERE n.city = 'austin' RETURN n";
7375
7376 let before = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7378 let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7379 let after = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7380 assert!(
7381 after > before,
7382 "WHERE equality fold must take the indexed path"
7383 );
7384 assert_eq!(indexed.len(), 2);
7385
7386 let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7388 assert_eq!(
7389 rows_of(&fallback),
7390 rows_of(&indexed),
7391 "fallback must return identical rows, not just the same count"
7392 );
7393 }
7394
7395 #[test]
7397 fn where_fold_miss_returns_empty() {
7398 let mut fx = Fx::new();
7399 fx.add("Person", "alice", vec![("city", s("austin"))]);
7400 let q = "MATCH (n:Person) WHERE n.city = 'berlin' RETURN n";
7401 let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7402 assert_eq!(rs.len(), 0);
7403 }
7404
7405 #[test]
7407 fn where_fold_param_uses_index() {
7408 use core_storage::property_index::PropertyIndex;
7409 use std::sync::atomic::Ordering;
7410
7411 let mut fx = Fx::new();
7412 let a = fx.add("Person", "alice", vec![("city", s("austin"))]);
7413 let b = fx.add("Person", "bob", vec![("city", s("boston"))]);
7414
7415 let mut pi = PropertyIndex::new();
7416 pi.enable("Person", "city");
7417 pi.set("Person", "city", a, &s("austin"));
7418 pi.set("Person", "city", b, &s("boston"));
7419
7420 let q = "MATCH (n:Person) WHERE n.city = $c RETURN n";
7421 let mut params = BTreeMap::new();
7422 params.insert("c".to_string(), s("austin"));
7423
7424 let before = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7425 let rs = run(&fx.view_indexed(&pi), q, ¶ms).unwrap();
7426 let after = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7427 assert!(after > before, "$param WHERE equality must use index");
7428 assert_eq!(rs.len(), 1);
7429 }
7430
7431 #[test]
7433 fn where_fold_residual_filter_applied() {
7434 let mut fx = Fx::new();
7435 fx.add(
7436 "Person",
7437 "young-austin",
7438 vec![("city", s("austin")), ("age", Value::Int(20))],
7439 );
7440 fx.add(
7441 "Person",
7442 "old-austin",
7443 vec![("city", s("austin")), ("age", Value::Int(40))],
7444 );
7445 fx.add(
7446 "Person",
7447 "boston",
7448 vec![("city", s("boston")), ("age", Value::Int(20))],
7449 );
7450
7451 let q = "MATCH (n:Person) WHERE n.city = 'austin' AND n.age > 30 RETURN n";
7452 let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7453 assert_eq!(rs.len(), 1, "only old-austin should match city+age filter");
7454 assert_eq!(rs.get(0, "n"), Some(&s("old-austin")));
7455 }
7456
7457 #[test]
7463 fn where_fold_unindexed_field_fallback() {
7464 let mut fx = Fx::new();
7465 fx.add("Person", "a", vec![("notindexed", s("x"))]);
7466 fx.add("Person", "b", vec![("notindexed", s("y"))]);
7467 let rs = run(
7468 &fx.view(),
7469 "MATCH (n:Person) WHERE n.notindexed = 'x' RETURN n",
7470 &BTreeMap::new(),
7471 )
7472 .unwrap();
7473 assert_eq!(rs.len(), 1);
7474 assert_eq!(rs.get(0, "n"), Some(&s("a")));
7475 }
7476
7477 #[test]
7481 fn index_intersect_both_indexed_fires() {
7482 use core_storage::property_index::PropertyIndex;
7483 use std::sync::atomic::Ordering;
7484
7485 let mut fx = Fx::new();
7486 let a = fx.add(
7488 "Person",
7489 "alice",
7490 vec![("city", s("austin")), ("age", Value::Int(30))],
7491 );
7492 let b = fx.add(
7494 "Person",
7495 "bob",
7496 vec![("city", s("austin")), ("age", Value::Int(25))],
7497 );
7498 let c = fx.add(
7500 "Person",
7501 "carol",
7502 vec![("city", s("boston")), ("age", Value::Int(30))],
7503 );
7504
7505 let mut pi = PropertyIndex::new();
7506 pi.enable("Person", "city");
7507 pi.enable("Person", "age");
7508 pi.set("Person", "city", a, &s("austin"));
7509 pi.set("Person", "city", b, &s("austin"));
7510 pi.set("Person", "city", c, &s("boston"));
7511 pi.set("Person", "age", a, &Value::Int(30));
7512 pi.set("Person", "age", b, &Value::Int(25));
7513 pi.set("Person", "age", c, &Value::Int(30));
7514
7515 let q = "MATCH (n:Person) WHERE n.city = 'austin' AND n.age = 30 RETURN n";
7516
7517 let before = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7518 let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7519 let after = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7520 assert!(
7521 after > before,
7522 "IndexIntersect must advance counter on indexed path"
7523 );
7524 assert_eq!(indexed.len(), 1);
7525 assert_eq!(indexed.get(0, "n"), Some(&s("alice")));
7526
7527 let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7529 assert_eq!(
7530 rows_of(&fallback),
7531 rows_of(&indexed),
7532 "fallback must return identical rows"
7533 );
7534 }
7535
7536 #[test]
7538 fn index_intersect_one_indexed_one_not() {
7539 use core_storage::property_index::PropertyIndex;
7540 use std::sync::atomic::Ordering;
7541
7542 let mut fx = Fx::new();
7543 let a = fx.add(
7544 "Person",
7545 "alice",
7546 vec![("city", s("austin")), ("role", s("eng"))],
7547 );
7548 let b = fx.add(
7549 "Person",
7550 "bob",
7551 vec![("city", s("austin")), ("role", s("mgr"))],
7552 );
7553 let _c = fx.add(
7554 "Person",
7555 "carol",
7556 vec![("city", s("boston")), ("role", s("eng"))],
7557 );
7558
7559 let mut pi = PropertyIndex::new();
7561 pi.enable("Person", "city");
7562 pi.set("Person", "city", a, &s("austin"));
7563 pi.set("Person", "city", b, &s("austin"));
7564
7565 let q = "MATCH (n:Person) WHERE n.city = 'austin' AND n.role = 'eng' RETURN n";
7566
7567 let before = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7568 let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7569 let after = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7570 assert!(
7571 after > before,
7572 "IndexIntersect must fire when at least one field is indexed"
7573 );
7574 assert_eq!(indexed.len(), 1);
7575 assert_eq!(indexed.get(0, "n"), Some(&s("alice")));
7576
7577 let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7578 assert_eq!(
7579 rows_of(&fallback),
7580 rows_of(&indexed),
7581 "fallback must return identical rows"
7582 );
7583 }
7584
7585 #[test]
7590 fn index_intersect_no_indexed_fallback() {
7591 let mut fx = Fx::new();
7592 fx.add("Person", "alice", vec![("x", s("1")), ("y", s("a"))]);
7593 fx.add("Person", "bob", vec![("x", s("1")), ("y", s("b"))]);
7594 fx.add("Person", "carol", vec![("x", s("2")), ("y", s("a"))]);
7595
7596 let q = "MATCH (n:Person) WHERE n.x = '1' AND n.y = 'a' RETURN n";
7597 let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7598 assert_eq!(rs.len(), 1);
7599 assert_eq!(rs.get(0, "n"), Some(&s("alice")));
7600 }
7601
7602 #[test]
7604 fn index_intersect_empty_intersection() {
7605 use core_storage::property_index::PropertyIndex;
7606
7607 let mut fx = Fx::new();
7608 let a = fx.add(
7609 "Person",
7610 "alice",
7611 vec![("city", s("austin")), ("age", Value::Int(30))],
7612 );
7613 let b = fx.add(
7614 "Person",
7615 "bob",
7616 vec![("city", s("boston")), ("age", Value::Int(25))],
7617 );
7618
7619 let mut pi = PropertyIndex::new();
7620 pi.enable("Person", "city");
7621 pi.enable("Person", "age");
7622 pi.set("Person", "city", a, &s("austin"));
7623 pi.set("Person", "city", b, &s("boston"));
7624 pi.set("Person", "age", a, &Value::Int(30));
7625 pi.set("Person", "age", b, &Value::Int(25));
7626
7627 let q = "MATCH (n:Person) WHERE n.city = 'boston' AND n.age = 30 RETURN n";
7629 let rs = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7630 assert_eq!(rs.len(), 0);
7631 }
7632
7633 #[test]
7635 fn index_intersect_with_params() {
7636 use core_storage::property_index::PropertyIndex;
7637 use std::sync::atomic::Ordering;
7638
7639 let mut fx = Fx::new();
7640 let a = fx.add(
7641 "Person",
7642 "alice",
7643 vec![("city", s("austin")), ("age", Value::Int(30))],
7644 );
7645 let b = fx.add(
7646 "Person",
7647 "bob",
7648 vec![("city", s("boston")), ("age", Value::Int(30))],
7649 );
7650
7651 let mut pi = PropertyIndex::new();
7652 pi.enable("Person", "city");
7653 pi.enable("Person", "age");
7654 pi.set("Person", "city", a, &s("austin"));
7655 pi.set("Person", "city", b, &s("boston"));
7656 pi.set("Person", "age", a, &Value::Int(30));
7657 pi.set("Person", "age", b, &Value::Int(30));
7658
7659 let q = "MATCH (n:Person) WHERE n.city = $c AND n.age = $a RETURN n";
7660 let mut params = BTreeMap::new();
7661 params.insert("c".to_string(), s("austin"));
7662 params.insert("a".to_string(), Value::Int(30));
7663
7664 let before = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7665 let indexed = run(&fx.view_indexed(&pi), q, ¶ms).unwrap();
7666 let after = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7667 assert!(after > before, "$param intersect must fire indexed path");
7668 assert_eq!(indexed.len(), 1);
7669 assert_eq!(indexed.get(0, "n"), Some(&s("alice")));
7670
7671 let fallback = run(&fx.view(), q, ¶ms).unwrap();
7672 assert_eq!(
7673 rows_of(&fallback),
7674 rows_of(&indexed),
7675 "fallback must return identical rows"
7676 );
7677 }
7678
7679 #[test]
7681 fn index_intersect_three_fields() {
7682 use core_storage::property_index::PropertyIndex;
7683
7684 let mut fx = Fx::new();
7685 let a = fx.add(
7687 "Person",
7688 "alice",
7689 vec![
7690 ("city", s("austin")),
7691 ("age", Value::Int(30)),
7692 ("role", s("eng")),
7693 ],
7694 );
7695 let b = fx.add(
7697 "Person",
7698 "bob",
7699 vec![
7700 ("city", s("austin")),
7701 ("age", Value::Int(30)),
7702 ("role", s("mgr")),
7703 ],
7704 );
7705
7706 let mut pi = PropertyIndex::new();
7707 pi.enable("Person", "city");
7708 pi.enable("Person", "age");
7709 pi.set("Person", "city", a, &s("austin"));
7711 pi.set("Person", "city", b, &s("austin"));
7712 pi.set("Person", "age", a, &Value::Int(30));
7713 pi.set("Person", "age", b, &Value::Int(30));
7714
7715 let q =
7716 "MATCH (n:Person) WHERE n.city = 'austin' AND n.age = 30 AND n.role = 'eng' RETURN n";
7717 let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7718 assert_eq!(indexed.len(), 1);
7719 assert_eq!(indexed.get(0, "n"), Some(&s("alice")));
7720
7721 let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7722 assert_eq!(
7723 rows_of(&fallback),
7724 rows_of(&indexed),
7725 "fallback must return identical rows"
7726 );
7727 }
7728}