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
34pub struct Params<'a>(pub &'a BTreeMap<String, Value>);
37
38#[derive(Clone, Debug)]
39enum Cell {
40 Node(u32),
41 Rel(EdgeRef),
42 Path(u8),
45 Scalar(Value),
47}
48
49type Row = Vec<Option<Cell>>;
52
53struct VarTable {
54 names: Vec<String>,
55}
56
57impl VarTable {
58 fn intern(&mut self, name: &str) -> usize {
59 if let Some(i) = self.names.iter().position(|n| n == name) {
60 return i;
61 }
62 self.names.push(name.to_string());
63 self.names.len() - 1
64 }
65
66 fn slot(&self, name: &str) -> Option<usize> {
67 self.names.iter().position(|n| n == name)
68 }
69}
70
71struct Projected {
72 columns: Vec<String>,
73 rows: Vec<Vec<Option<Value>>>,
74}
75
76const MAX_INTERMEDIATE_ROWS: usize = 1_000_000;
79
80const MAX_GROUPS: usize = 1_000_000;
82
83type GroupKey = Vec<Option<ValueKey>>;
86type GroupEntry = (Vec<Option<Value>>, Vec<AggAcc>);
91
92#[cfg(test)]
93thread_local! {
94 static TEST_MAX_INTERMEDIATE_ROWS: std::cell::Cell<Option<usize>> =
95 const { std::cell::Cell::new(None) };
96 static TEST_EXPAND_PRODUCED: std::cell::Cell<Option<usize>> =
99 const { std::cell::Cell::new(None) };
100 static TEST_MAX_GROUPS: std::cell::Cell<Option<usize>> =
102 const { std::cell::Cell::new(None) };
103}
104
105fn max_intermediate_rows() -> usize {
106 #[cfg(test)]
107 {
108 TEST_MAX_INTERMEDIATE_ROWS
109 .with(|c| c.get())
110 .unwrap_or(MAX_INTERMEDIATE_ROWS)
111 }
112 #[cfg(not(test))]
113 {
114 MAX_INTERMEDIATE_ROWS
115 }
116}
117
118fn max_groups() -> usize {
119 #[cfg(test)]
120 {
121 TEST_MAX_GROUPS.with(|c| c.get()).unwrap_or(MAX_GROUPS)
122 }
123 #[cfg(not(test))]
124 {
125 MAX_GROUPS
126 }
127}
128
129#[cfg(test)]
132pub(crate) fn with_max_groups<R>(cap: usize, f: impl FnOnce() -> R) -> R {
133 TEST_MAX_GROUPS.with(|c| {
134 let prev = c.replace(Some(cap));
135 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
136 c.set(prev);
137 match result {
138 Ok(v) => v,
139 Err(p) => std::panic::resume_unwind(p),
140 }
141 })
142}
143
144#[cfg(test)]
148pub(crate) fn with_max_intermediate_rows<R>(cap: usize, f: impl FnOnce() -> R) -> R {
149 TEST_MAX_INTERMEDIATE_ROWS.with(|c| {
150 let prev = c.replace(Some(cap));
151 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
152 c.set(prev);
153 match result {
154 Ok(v) => v,
155 Err(p) => std::panic::resume_unwind(p),
156 }
157 })
158}
159
160#[cfg(test)]
162fn record_expand_row() {
163 TEST_EXPAND_PRODUCED.with(|c| {
164 if let Some(prev) = c.get() {
165 c.set(Some(prev + 1));
166 }
167 });
168}
169
170#[cfg(test)]
176pub(crate) fn with_expand_counter<R>(f: impl FnOnce() -> R) -> (R, usize) {
177 TEST_EXPAND_PRODUCED.with(|c| c.set(Some(0)));
178 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
179 let count = TEST_EXPAND_PRODUCED.with(|c| c.get().unwrap_or(0));
180 TEST_EXPAND_PRODUCED.with(|c| c.set(None));
181 match result {
182 Ok(v) => (v, count),
183 Err(p) => std::panic::resume_unwind(p),
184 }
185}
186
187fn row_cap_err(cap: usize) -> String {
188 format!(
189 "intermediate result exceeds {cap} rows; add a LIMIT or constrain patterns with shared variables"
190 )
191}
192
193fn group_cap_err() -> String {
194 format!(
195 "group count exceeds {} distinct keys; add a WHERE clause or constrain the grouping key",
196 max_groups()
197 )
198}
199
200fn group_key_normalize(v: &Value) -> Option<ValueKey> {
211 match v {
212 Value::Int(n) => Some(ValueKey::FloatBits((*n as f64).to_bits())),
213 Value::Float(f) => Some(ValueKey::FloatBits(f.to_bits())),
214 _ => ValueKey::from_value(v),
215 }
216}
217
218pub fn execute(view: &GraphView, plan: &[PlanOp], params: &Params) -> Result<ResultSet, String> {
230 use crate::cypher::plan::row_bound;
231 execute_inner(view, plan, params, row_bound(plan))
232}
233
234pub fn execute_union(
239 view: &GraphView,
240 union: &crate::cypher::parser::UnionQuery,
241 params: &Params,
242) -> Result<ResultSet, String> {
243 let mut acc: Option<ResultSet> = None;
244 for (i, part) in union.parts.iter().enumerate() {
245 let ops = crate::cypher::plan::plan(part)?;
246 let rs = execute(view, &ops, params)?;
247 acc = Some(match acc {
248 None => rs,
249 Some(prev) => {
250 if prev.columns() != rs.columns() {
251 return Err(format!(
252 "UNION requires matching column names across all parts; \
253 got {:?} then {:?}",
254 prev.columns(),
255 rs.columns()
256 ));
257 }
258 let all = union.all_flags.get(i - 1).copied().unwrap_or(false);
260 combine_result_sets(prev, rs, all)
261 }
262 });
263 }
264 Ok(acc.expect("UNION query has at least one part"))
266}
267
268fn combine_result_sets(mut acc: ResultSet, other: ResultSet, all: bool) -> ResultSet {
269 for i in 0..other.len() {
270 acc.push_row(other.row(i).to_vec());
271 }
272 if !all {
273 let mut seen: Vec<Vec<Option<Value>>> = Vec::new();
275 for i in 0..acc.len() {
276 let r = acc.row(i).to_vec();
277 if !seen.contains(&r) {
278 seen.push(r);
279 }
280 }
281 let mut deduped = ResultSet::new(acc.columns().to_vec());
282 for r in seen {
283 deduped.push_row(r);
284 }
285 return deduped;
286 }
287 acc
288}
289
290#[cfg(test)]
295pub(crate) fn execute_unbounded(
296 view: &GraphView,
297 plan: &[PlanOp],
298 params: &Params,
299) -> Result<ResultSet, String> {
300 execute_inner(view, plan, params, None)
301}
302
303fn execute_inner(
304 view: &GraphView,
305 plan: &[PlanOp],
306 params: &Params,
307 row_bound: Option<usize>,
308) -> Result<ResultSet, String> {
309 check_params(plan, params)?;
310
311 let has_var_expand = plan
317 .iter()
318 .any(|op| matches!(op, PlanOp::VarExpand { .. } | PlanOp::ShortestPath { .. }));
319
320 let is_pipeline = plan.iter().any(|op| {
326 matches!(
327 op,
328 PlanOp::With { .. } | PlanOp::Unwind { .. } | PlanOp::LeftOuterApply { .. }
329 )
330 }) || {
331 let mut saw_gagg = false;
332 plan.iter().any(|op| {
333 if matches!(op, PlanOp::GroupAggregate { .. }) {
334 saw_gagg = true;
335 false
336 } else {
337 saw_gagg && matches!(op, PlanOp::Filter { .. })
338 }
339 })
340 };
341
342 if !has_var_expand
346 && !is_pipeline
347 && plan
348 .iter()
349 .any(|op| matches!(op, PlanOp::GroupAggregate { .. }))
350 {
351 return execute_group_aggregate(view, plan, params);
352 }
353
354 if !has_var_expand
358 && !is_pipeline
359 && plan.iter().any(|op| matches!(op, PlanOp::Aggregate { .. }))
360 {
361 return execute_aggregate(view, plan, params);
362 }
363
364 if let Some(bound) = row_bound {
368 return execute_pull(view, plan, params, bound);
369 }
370
371 let vars = collect_vars(plan);
373 let mut rows: Vec<Row> = vec![vec![None; vars.names.len()]];
374 let mut projected: Option<Projected> = None;
375 let mut pipeline_group_columns: Option<Vec<String>> = None;
378
379 for op in plan {
380 match op {
381 PlanOp::ScanLabel { var, label } => {
382 rows = scan_label(view, &vars, &rows, var, label.as_deref())?;
383 }
384 PlanOp::ScanKey { var, key, label } => {
385 rows = scan_key(view, &vars, &rows, var, key, label.as_deref(), params)?;
386 }
387 PlanOp::IndexScan {
388 var,
389 label,
390 field,
391 value,
392 } => {
393 rows = scan_index(
394 view,
395 &vars,
396 &rows,
397 var,
398 label.as_deref(),
399 field,
400 value,
401 params,
402 )?;
403 }
404 PlanOp::LookupProps { var, props } => {
405 rows = retain_node(view, &vars, &rows, var, None, props, params)?;
406 }
407 PlanOp::JoinBound { var, label, props } => {
408 rows = retain_node(view, &vars, &rows, var, label.as_deref(), props, params)?;
409 }
410 PlanOp::Expand { .. } => {
411 rows = exec_expand(view, &vars, &rows, op, params)?;
412 }
413 PlanOp::VarExpand {
414 from,
415 rel_var,
416 etypes,
417 dir,
418 to,
419 min,
420 max,
421 } => {
422 rows = exec_var_expand(
423 view, &vars, &rows, from, rel_var, etypes, *dir, to, *min, *max,
424 )?;
425 }
426 PlanOp::ShortestPath {
427 from,
428 rel_var,
429 etypes,
430 dir,
431 to,
432 max_hops,
433 } => {
434 rows = exec_shortest_path(
435 view, &vars, &rows, from, rel_var, etypes, *dir, to, *max_hops,
436 )?;
437 }
438 PlanOp::Filter { expr } => {
439 rows = exec_filter(view, &vars, &rows, expr, params)?;
440 }
441 PlanOp::Project { items } => {
442 projected = Some(exec_project(view, &vars, &rows, items, params)?);
443 }
444 PlanOp::Distinct => {
445 if let Some(table) = projected.as_mut() {
446 exec_distinct(table)?;
447 } else {
448 return Err("DISTINCT requires a Project".into());
449 }
450 }
451 PlanOp::OrderBy { items } => {
452 if let Some(table) = projected.as_mut() {
453 exec_order_by(table, items)?;
454 } else {
455 exec_order_by_rows(&vars, &mut rows, items, view);
457 }
458 }
459 PlanOp::Skip(ls) => {
460 let n = resolve_ls(ls, params)?;
461 if let Some(table) = projected.as_mut() {
462 apply_skip(&mut table.rows, n);
463 } else {
464 apply_skip(&mut rows, n);
465 }
466 }
467 PlanOp::Limit(ls) => {
468 let n = resolve_ls(ls, params)?;
469 if let Some(table) = projected.as_mut() {
470 apply_limit(&mut table.rows, n);
471 } else {
472 apply_limit(&mut rows, n);
473 }
474 }
475 PlanOp::GroupAggregate { keys, aggs } => {
480 let mut grp_groups: HashMap<GroupKey, GroupEntry> = HashMap::new();
481 let mut grp_key_order: Vec<GroupKey> = Vec::new();
482 for row in &rows {
483 let mut gk: GroupKey = Vec::with_capacity(keys.len());
484 let mut display_vals: Vec<Option<Value>> = Vec::with_capacity(keys.len());
485 for (_, item) in keys {
486 let val = project_item(view, &vars, row, item, params)?;
487 gk.push(val.as_ref().and_then(group_key_normalize));
488 display_vals.push(val);
489 }
490 if !grp_groups.contains_key(&gk) {
491 if grp_groups.len() >= max_groups() {
492 return Err(group_cap_err());
493 }
494 grp_key_order.push(gk.clone());
495 grp_groups.insert(
496 gk.clone(),
497 (
498 display_vals,
499 aggs.iter().map(|(f, _, _)| AggAcc::new(f)).collect(),
500 ),
501 );
502 }
503 let (_, accs) = grp_groups.get_mut(&gk).unwrap();
504 for (acc, (func, arg, _)) in accs.iter_mut().zip(aggs.iter()) {
505 update_acc(view, &vars, row, func, arg, acc)?;
506 }
507 }
508 if keys.is_empty() && grp_key_order.is_empty() {
511 let empty_key: GroupKey = vec![];
512 grp_key_order.push(empty_key.clone());
513 grp_groups.insert(
514 empty_key,
515 (
516 vec![],
517 aggs.iter().map(|(f, _, _)| AggAcc::new(f)).collect(),
518 ),
519 );
520 }
521 if is_pipeline {
522 rows = group_result_to_rows(keys, aggs, grp_key_order, &mut grp_groups, &vars);
525 projected = None;
526 let mut cols: Vec<String> = keys.iter().map(|(c, _)| c.clone()).collect();
529 cols.extend(aggs.iter().map(|(_, _, c)| c.clone()));
530 pipeline_group_columns = Some(cols);
531 } else {
532 projected = Some(build_group_projected(
533 keys,
534 aggs,
535 grp_key_order,
536 &mut grp_groups,
537 ));
538 pipeline_group_columns = None;
539 }
540 }
541 PlanOp::With {
544 items,
545 where_expr,
546 order_by,
547 skip,
548 limit,
549 } => {
550 let row_len = vars.names.len();
554 let mut new_rows: Vec<Row> = Vec::with_capacity(rows.len());
555 for row in &rows {
556 let mut new_row: Row = vec![None; row_len];
557 for item in items {
558 let col = column_name(item);
559 let Some(dst_slot) = vars.slot(&col) else {
560 continue;
561 };
562 match &item.value {
563 RetVal::Var(v) => {
564 if let Some(src_slot) = vars.slot(v) {
566 new_row[dst_slot] = row.get(src_slot).cloned().flatten();
567 }
568 }
569 RetVal::Prop { var, field } => {
570 let val = resolve_prop(view, &vars, row, var, field)?;
571 new_row[dst_slot] = val.map(Cell::Scalar);
572 }
573 RetVal::Agg { .. } => {} RetVal::FuncCall { name, args } => {
575 let val = eval_func(name, args, view, &vars, row, params)?;
576 new_row[dst_slot] = val.map(Cell::Scalar);
577 }
578 RetVal::ScalarExpr(op) => {
579 let val = resolve_operand(view, &vars, row, op, params)?;
580 new_row[dst_slot] = val.map(Cell::Scalar);
581 }
582 }
583 }
584 new_rows.push(new_row);
585 }
586 rows = new_rows;
587 if let Some(expr) = where_expr {
589 rows = exec_filter(view, &vars, &rows, expr, params)?;
590 }
591 if !order_by.is_empty() {
593 exec_order_by_rows(&vars, &mut rows, order_by, view);
594 }
595 if let Some(ls) = skip {
596 let n = resolve_ls(ls, params)?;
597 apply_skip(&mut rows, n);
598 }
599 if let Some(ls) = limit {
600 let n = resolve_ls(ls, params)?;
601 apply_limit(&mut rows, n);
602 }
603 }
604 PlanOp::Unwind { expr, alias } => {
606 let alias_slot = vars
607 .slot(alias)
608 .ok_or_else(|| format!("UNWIND alias `{alias}` not in VarTable"))?;
609 let cap = max_intermediate_rows();
610 let mut new_rows: Vec<Row> = Vec::new();
611 for row in &rows {
612 let list_val: Option<Value> = match expr {
613 UnwindExpr::Lit(vals) => Some(Value::List(vals.clone())),
614 UnwindExpr::Prop { var, field } => {
615 resolve_prop(view, &vars, row, var, field)?
616 }
617 UnwindExpr::Var(name) => {
618 let slot = vars
619 .slot(name)
620 .ok_or_else(|| format!("UNWIND variable `{name}` is not bound"))?;
621 match row.get(slot).and_then(|c| c.as_ref()) {
622 Some(Cell::Scalar(v)) => Some(v.clone()),
623 Some(Cell::Node(_) | Cell::Rel(_) | Cell::Path(_)) => {
624 return Err(format!(
625 "UNWIND requires a list; `{name}` is bound to a graph element"
626 ));
627 }
628 None => None,
629 }
630 }
631 };
632 match list_val {
633 None => {} Some(Value::List(items_list)) => {
635 for item_val in items_list {
637 if new_rows.len() >= cap {
638 return Err(row_cap_err(cap));
639 }
640 let mut new_row = row.clone();
641 new_row[alias_slot] = Some(Cell::Scalar(item_val));
642 new_rows.push(new_row);
643 }
644 }
645 Some(other) => {
646 let type_name = match &other {
647 Value::Int(_) => "Int",
648 Value::Float(_) => "Float",
649 Value::Str(_) => "Str",
650 Value::Bool(_) => "Bool",
651 Value::List(_) => unreachable!(),
652 Value::Map(_) => "Map",
653 };
654 return Err(format!(
655 "UNWIND requires a list; got {type_name} value for `{alias}`"
656 ));
657 }
658 }
659 }
660 rows = new_rows;
661 }
662 PlanOp::Aggregate { func, arg, column } => {
667 let ctx = AggStreamCtx {
668 view,
669 vars: &vars,
670 params,
671 func,
672 arg,
673 };
674 let mut acc = AggAcc::new(func);
675 for row in &rows {
676 agg_stream(&ctx, &[], row, &mut acc)?;
678 }
679 let value = acc.finish();
680 let mut rs = ResultSet::new(vec![column.clone()]);
681 rs.push_row(vec![value]);
682 return Ok(rs);
683 }
684 PlanOp::LeftOuterApply {
690 inner,
691 optional_vars,
692 } => {
693 let cap = max_intermediate_rows();
694 let mut new_rows: Vec<Row> = Vec::new();
695 for outer_row in &rows {
696 let inner_seed: Vec<Row> = vec![outer_row.clone()];
698 let inner_result =
701 exec_left_outer_inner(view, &vars, inner_seed, inner, params)?;
702 if inner_result.is_empty() {
703 let mut null_row = outer_row.clone();
705 for v in optional_vars {
706 if let Some(slot) = vars.slot(v) {
707 null_row[slot] = None;
708 }
709 }
710 if new_rows.len() >= cap {
711 return Err(row_cap_err(cap));
712 }
713 new_rows.push(null_row);
714 } else {
715 for r in inner_result {
716 if new_rows.len() >= cap {
717 return Err(row_cap_err(cap));
718 }
719 new_rows.push(r);
720 }
721 }
722 }
723 rows = new_rows;
724 }
725 }
726 }
727
728 Ok(match projected {
729 Some(table) => finish(table),
730 None => {
731 if let Some(cols) = pipeline_group_columns {
734 let mut rs = ResultSet::new(cols.clone());
735 for row in rows {
736 let vals: Vec<Option<Value>> = cols
737 .iter()
738 .map(|col| {
739 vars.slot(col)
740 .and_then(|s| row.get(s))
741 .and_then(|c| c.as_ref())
742 .and_then(|c| match c {
743 Cell::Scalar(v) => Some(v.clone()),
744 Cell::Node(id) => {
745 view.ids.key_of(*id).map(|k| Value::Str(k.to_owned()))
746 }
747 Cell::Path(h) => Some(Value::Int(*h as i64)),
748 Cell::Rel(_) => None,
749 })
750 })
751 .collect();
752 rs.push_row(vals);
753 }
754 rs
755 } else {
756 ResultSet::new(vec![])
757 }
758 }
759 })
760}
761
762fn exec_left_outer_inner(
771 view: &GraphView,
772 vars: &VarTable,
773 mut rows: Vec<Row>,
774 inner: &[PlanOp],
775 params: &Params,
776) -> Result<Vec<Row>, String> {
777 for op in inner {
778 match op {
779 PlanOp::ScanLabel { var, label } => {
780 rows = scan_label(view, vars, &rows, var, label.as_deref())?;
781 }
782 PlanOp::ScanKey { var, key, label } => {
783 rows = scan_key(view, vars, &rows, var, key, label.as_deref(), params)?;
784 }
785 PlanOp::LookupProps { var, props } => {
786 rows = retain_node(view, vars, &rows, var, None, props, params)?;
787 }
788 PlanOp::JoinBound { var, label, props } => {
789 rows = retain_node(view, vars, &rows, var, label.as_deref(), props, params)?;
790 }
791 PlanOp::Expand { .. } => {
792 rows = exec_expand(view, vars, &rows, op, params)?;
793 }
794 PlanOp::Filter { expr } => {
795 rows = exec_filter(view, vars, &rows, expr, params)?;
796 }
797 other => {
798 return Err(format!(
799 "unsupported op inside OPTIONAL MATCH inner plan: {other:?}"
800 ));
801 }
802 }
803 }
804 Ok(rows)
805}
806
807fn finish(table: Projected) -> ResultSet {
808 let mut rs = ResultSet::new(table.columns);
809 for row in table.rows {
810 rs.push_row(row);
811 }
812 rs
813}
814
815fn check_params(plan: &[PlanOp], params: &Params) -> Result<(), String> {
816 let mut names = Vec::new();
817 let mut seen = BTreeSet::new();
818 collect_params_from_ops(plan, &mut names, &mut seen)?;
819 for name in names {
820 if !params.0.contains_key(&name) {
821 return Err(format!("missing parameter `{name}`"));
822 }
823 }
824 Ok(())
825}
826
827fn collect_params_from_ops(
828 plan: &[PlanOp],
829 names: &mut Vec<String>,
830 seen: &mut BTreeSet<String>,
831) -> Result<(), String> {
832 for op in plan {
833 match op {
834 PlanOp::ScanKey { key, .. } => collect_operand(key, names, seen),
835 PlanOp::LookupProps { props, .. }
836 | PlanOp::JoinBound { props, .. }
837 | PlanOp::Expand {
838 to_props: props, ..
839 } => {
840 for (_, operand) in props {
841 collect_operand(operand, names, seen);
842 }
843 }
844 PlanOp::Filter { expr } => collect_expr(expr, names, seen, 0)?,
845 PlanOp::LeftOuterApply { inner, .. } => {
846 collect_params_from_ops(inner, names, seen)?;
847 }
848 PlanOp::Project { items } => {
851 for item in items {
852 collect_ret_item_params(item, names, seen);
853 }
854 }
855 PlanOp::With {
856 items, where_expr, ..
857 } => {
858 for item in items {
859 collect_ret_item_params(item, names, seen);
860 }
861 if let Some(expr) = where_expr {
862 let _ = collect_expr(expr, names, seen, 0);
863 }
864 }
865 PlanOp::GroupAggregate { keys, aggs } => {
866 for (_, item) in keys {
867 collect_ret_item_params(item, names, seen);
868 }
869 for (_, arg, _) in aggs {
870 match arg {
871 AggArg::Star => {}
872 AggArg::Var(_) => {}
873 AggArg::Prop { .. } => {}
874 }
875 }
876 }
877 PlanOp::Skip(LimitSkip::Param(n)) | PlanOp::Limit(LimitSkip::Param(n))
880 if seen.insert(n.clone()) =>
881 {
882 names.push(n.clone());
883 }
884 _ => {}
885 }
886 }
887 Ok(())
888}
889
890fn collect_ret_item_params(item: &RetItem, names: &mut Vec<String>, seen: &mut BTreeSet<String>) {
892 match &item.value {
893 RetVal::FuncCall { args, .. } => {
894 for arg in args {
895 collect_operand(arg, names, seen);
896 }
897 }
898 RetVal::ScalarExpr(op) => {
899 collect_operand(op, names, seen);
900 }
901 RetVal::Prop { .. } | RetVal::Var(_) | RetVal::Agg { .. } => {}
902 }
903}
904
905fn collect_operand(op: &Operand, names: &mut Vec<String>, seen: &mut BTreeSet<String>) {
906 match op {
907 Operand::Param(n) => {
908 if seen.insert(n.clone()) {
909 names.push(n.clone());
910 }
911 }
912 Operand::FuncCall { args, .. } => {
913 for arg in args {
914 collect_operand(arg, names, seen);
915 }
916 }
917 Operand::BinArith { left, right, .. } => {
918 collect_operand(left, names, seen);
919 collect_operand(right, names, seen);
920 }
921 _ => {}
922 }
923}
924
925fn collect_expr(
926 expr: &Expr,
927 names: &mut Vec<String>,
928 seen: &mut BTreeSet<String>,
929 depth: u32,
930) -> Result<(), String> {
931 if depth > 256 {
932 return Err("expression nesting too deep".into());
933 }
934 match expr {
935 Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => {
936 collect_expr(lhs, names, seen, depth + 1)?;
937 collect_expr(rhs, names, seen, depth + 1)
938 }
939 Expr::Not(inner) => collect_expr(inner, names, seen, depth + 1),
940 Expr::Cmp { lhs, rhs, .. } => {
941 collect_operand(lhs, names, seen);
942 collect_operand(rhs, names, seen);
943 Ok(())
944 }
945 Expr::Truthy(op) => {
946 collect_operand(op, names, seen);
947 Ok(())
948 }
949 Expr::IsNull(op) | Expr::IsNotNull(op) => {
950 collect_operand(op, names, seen);
951 Ok(())
952 }
953 Expr::In { expr, list } => {
954 collect_operand(expr, names, seen);
955 for item in list {
956 collect_operand(item, names, seen);
957 }
958 Ok(())
959 }
960 }
961}
962
963fn collect_vars(plan: &[PlanOp]) -> VarTable {
964 let mut vars = VarTable { names: Vec::new() };
965 for op in plan {
966 match op {
967 PlanOp::ScanLabel { var, .. } => {
968 vars.intern(var);
969 }
970 PlanOp::ScanKey { var, key, .. } => {
971 vars.intern(var);
972 intern_operand(&mut vars, key);
973 }
974 PlanOp::LookupProps { var, props } | PlanOp::JoinBound { var, props, .. } => {
975 vars.intern(var);
976 for (_, operand) in props {
977 intern_operand(&mut vars, operand);
978 }
979 }
980 PlanOp::Expand {
981 from,
982 rel_var,
983 to,
984 to_props,
985 ..
986 } => {
987 vars.intern(from);
988 vars.intern(to);
989 if let Some(r) = rel_var {
990 vars.intern(r);
991 }
992 for (_, operand) in to_props {
993 intern_operand(&mut vars, operand);
994 }
995 }
996 PlanOp::VarExpand {
997 from, rel_var, to, ..
998 } => {
999 vars.intern(from);
1000 vars.intern(to);
1001 if let Some(r) = rel_var {
1002 vars.intern(r);
1003 }
1004 }
1005 PlanOp::ShortestPath {
1006 from, rel_var, to, ..
1007 } => {
1008 vars.intern(from);
1009 vars.intern(to);
1010 if let Some(r) = rel_var {
1011 vars.intern(r);
1012 }
1013 }
1014 PlanOp::Filter { expr } => intern_expr(&mut vars, expr),
1015 PlanOp::Project { items } => {
1016 for item in items {
1017 match &item.value {
1018 RetVal::Var(name) | RetVal::Prop { var: name, .. } => {
1019 vars.intern(name);
1020 }
1021 RetVal::Agg { .. } => {} RetVal::FuncCall { args, .. } => {
1023 for arg in args {
1024 intern_operand(&mut vars, arg);
1025 }
1026 }
1027 RetVal::ScalarExpr(op) => {
1028 intern_operand(&mut vars, op);
1029 }
1030 }
1031 }
1032 }
1033 PlanOp::Aggregate { arg, .. } => match arg {
1034 AggArg::Star => {}
1035 AggArg::Var(v) => {
1036 vars.intern(v);
1037 }
1038 AggArg::Prop { var, .. } => {
1039 vars.intern(var);
1040 }
1041 },
1042 PlanOp::GroupAggregate { keys, aggs } => {
1043 for (col, item) in keys {
1045 match &item.value {
1046 RetVal::Var(name) | RetVal::Prop { var: name, .. } => {
1047 vars.intern(name);
1048 }
1049 RetVal::Agg { .. } => {}
1050 RetVal::FuncCall { args, .. } => {
1051 for arg in args {
1052 intern_operand(&mut vars, arg);
1053 }
1054 }
1055 RetVal::ScalarExpr(op) => {
1056 intern_operand(&mut vars, op);
1057 }
1058 }
1059 vars.intern(col);
1061 }
1062 for (_, arg, col) in aggs {
1063 match arg {
1064 AggArg::Star => {}
1065 AggArg::Var(v) => {
1066 vars.intern(v);
1067 }
1068 AggArg::Prop { var, .. } => {
1069 vars.intern(var);
1070 }
1071 }
1072 vars.intern(col);
1074 }
1075 }
1076 PlanOp::With {
1077 items,
1078 where_expr,
1079 order_by,
1080 ..
1081 } => {
1082 for item in items {
1083 match &item.value {
1084 RetVal::Var(name) | RetVal::Prop { var: name, .. } => {
1085 vars.intern(name);
1086 }
1087 RetVal::Agg { .. } => {}
1088 RetVal::FuncCall { args, .. } => {
1089 for arg in args {
1090 intern_operand(&mut vars, arg);
1091 }
1092 }
1093 RetVal::ScalarExpr(op) => {
1094 intern_operand(&mut vars, op);
1095 }
1096 }
1097 if let Some(alias) = &item.alias {
1099 vars.intern(alias);
1100 } else {
1101 match &item.value {
1102 RetVal::Var(name) => {
1103 vars.intern(name);
1104 }
1105 RetVal::Prop { var, field } => {
1106 let col = format!("{var}.{field}");
1107 vars.intern(&col);
1108 }
1109 RetVal::Agg { .. } => {}
1110 RetVal::ScalarExpr(_) => {
1111 vars.intern("<expr>");
1112 }
1113 RetVal::FuncCall { name, args } => {
1114 let arg_strs: Vec<String> = args
1116 .iter()
1117 .map(|a| match a {
1118 Operand::Var(v) => v.clone(),
1119 Operand::Prop { var, field } => format!("{var}.{field}"),
1120 Operand::Lit(_) => "<lit>".to_string(),
1121 Operand::Param(p) => format!("${p}"),
1122 Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
1123 Operand::BinArith { .. } => "<arith>".to_string(),
1124 Operand::Case { .. } => "<case>".to_string(),
1125 })
1126 .collect();
1127 let col = format!("{name}({})", arg_strs.join(", "));
1128 vars.intern(&col);
1129 }
1130 }
1131 }
1132 }
1133 if let Some(expr) = where_expr {
1134 intern_expr(&mut vars, expr);
1135 }
1136 for oi in order_by {
1137 match &oi.target {
1138 OrderTarget::Alias(name) | OrderTarget::Var(name) => {
1139 vars.intern(name);
1140 }
1141 OrderTarget::Prop { var, .. } => {
1142 vars.intern(var);
1143 }
1144 }
1145 }
1146 }
1147 PlanOp::Unwind { expr, alias } => {
1148 vars.intern(alias);
1149 match expr {
1150 UnwindExpr::Prop { var, .. } => {
1151 vars.intern(var);
1152 }
1153 UnwindExpr::Var(name) => {
1154 vars.intern(name);
1155 }
1156 UnwindExpr::Lit(_) => {}
1157 }
1158 }
1159 PlanOp::LeftOuterApply {
1160 inner,
1161 optional_vars,
1162 } => {
1163 for op in inner {
1165 match op {
1169 PlanOp::ScanLabel { var, .. } => {
1170 vars.intern(var);
1171 }
1172 PlanOp::ScanKey { var, key, .. } => {
1173 vars.intern(var);
1174 intern_operand(&mut vars, key);
1175 }
1176 PlanOp::Expand {
1177 from, rel_var, to, ..
1178 } => {
1179 vars.intern(from);
1180 vars.intern(to);
1181 if let Some(r) = rel_var {
1182 vars.intern(r);
1183 }
1184 }
1185 PlanOp::JoinBound { var, .. } | PlanOp::LookupProps { var, .. } => {
1186 vars.intern(var);
1187 }
1188 PlanOp::Filter { expr } => intern_expr(&mut vars, expr),
1189 _ => {}
1190 }
1191 }
1192 for v in optional_vars {
1193 vars.intern(v);
1194 }
1195 }
1196 _ => {}
1197 }
1198 }
1199 vars
1200}
1201
1202fn intern_operand(vars: &mut VarTable, operand: &Operand) {
1203 match operand {
1204 Operand::Prop { var, .. } | Operand::Var(var) => {
1205 vars.intern(var);
1206 }
1207 Operand::Lit(_) | Operand::Param(_) => {}
1208 Operand::BinArith { left, right, .. } => {
1209 intern_operand(vars, left);
1210 intern_operand(vars, right);
1211 }
1212 Operand::FuncCall { args, .. } => {
1213 for arg in args {
1214 intern_operand(vars, arg);
1215 }
1216 }
1217 Operand::Case { branches, default } => {
1218 for (cond, value) in branches {
1219 intern_expr(vars, cond);
1220 intern_operand(vars, value);
1221 }
1222 if let Some(d) = default {
1223 intern_operand(vars, d);
1224 }
1225 }
1226 }
1227}
1228
1229fn intern_expr(vars: &mut VarTable, expr: &Expr) {
1230 match expr {
1231 Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => {
1232 intern_expr(vars, lhs);
1233 intern_expr(vars, rhs);
1234 }
1235 Expr::Not(inner) => intern_expr(vars, inner),
1236 Expr::Cmp { lhs, rhs, .. } => {
1237 intern_operand(vars, lhs);
1238 intern_operand(vars, rhs);
1239 }
1240 Expr::Truthy(op) => intern_operand(vars, op),
1241 Expr::IsNull(op) | Expr::IsNotNull(op) => intern_operand(vars, op),
1242 Expr::In { expr, list } => {
1243 intern_operand(vars, expr);
1244 for item in list {
1245 intern_operand(vars, item);
1246 }
1247 }
1248 }
1249}
1250
1251fn scan_ids(view: &GraphView, label: Option<&str>) -> Vec<u32> {
1252 let ids = match label {
1253 Some(label) => view.nodes_with_label(label),
1254 None => (0..view.ids.len() as u32)
1256 .filter(|&id| view.label_of(id).is_some())
1257 .collect(),
1258 };
1259 if view.mask.is_some() {
1260 ids.into_iter().filter(|&id| view.visible(id)).collect()
1261 } else {
1262 ids
1263 }
1264}
1265
1266fn resolve_scan_key_id(
1269 view: &GraphView,
1270 vars: &VarTable,
1271 row: &Row,
1272 key: &Operand,
1273 label: Option<&str>,
1274 params: &Params,
1275) -> Result<Option<u32>, String> {
1276 #[cfg(test)]
1277 SCAN_KEY_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1278
1279 let Some(val) = resolve_operand(view, vars, row, key, params)? else {
1280 return Ok(None);
1281 };
1282 let Value::Str(s) = val else {
1283 return Ok(None);
1284 };
1285 let Some(id) = view.node_id(&s) else {
1286 return Ok(None);
1287 };
1288 if !view.visible(id) {
1289 return Ok(None);
1290 }
1291 if let Some(want) = label {
1292 match view.label_of(id) {
1293 Some(got) if got == want => {}
1294 _ => return Ok(None),
1295 }
1296 }
1297 Ok(Some(id))
1298}
1299
1300fn scan_key(
1301 view: &GraphView,
1302 vars: &VarTable,
1303 rows: &[Row],
1304 var: &str,
1305 key: &Operand,
1306 label: Option<&str>,
1307 params: &Params,
1308) -> Result<Vec<Row>, String> {
1309 let slot = vars
1310 .slot(var)
1311 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1312 let cap = max_intermediate_rows();
1313 let mut out = Vec::new();
1314 for row in rows {
1315 let Some(id) = resolve_scan_key_id(view, vars, row, key, label, params)? else {
1316 continue;
1317 };
1318 if out.len() >= cap {
1319 return Err(row_cap_err(cap));
1320 }
1321 let mut next = row.clone();
1322 next[slot] = Some(Cell::Node(id));
1323 out.push(next);
1324 }
1325 Ok(out)
1326}
1327
1328fn scan_label(
1329 view: &GraphView,
1330 vars: &VarTable,
1331 rows: &[Row],
1332 var: &str,
1333 label: Option<&str>,
1334) -> Result<Vec<Row>, String> {
1335 let ids = scan_ids(view, label);
1336 let slot = vars
1337 .slot(var)
1338 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1339 let cap = max_intermediate_rows();
1340 let mut out = Vec::with_capacity(rows.len().saturating_mul(ids.len()).min(cap));
1341 for row in rows {
1342 for &id in &ids {
1343 if out.len() >= cap {
1344 return Err(row_cap_err(cap));
1345 }
1346 let mut next = row.clone();
1347 next[slot] = Some(Cell::Node(id));
1348 out.push(next);
1349 }
1350 }
1351 Ok(out)
1352}
1353
1354#[allow(clippy::too_many_arguments)]
1362fn index_scan_ids(
1363 view: &GraphView,
1364 vars: &VarTable,
1365 row: &Row,
1366 label: Option<&str>,
1367 field: &str,
1368 value: &Operand,
1369 params: &Params,
1370) -> Result<Vec<u32>, String> {
1371 let resolved = resolve_operand(view, vars, row, value, params)?;
1372 if let (Some(label_str), Some(val)) = (label, resolved.as_ref()) {
1373 if let Some(ids) = view.nodes_with_prop(label_str, field, val) {
1374 #[cfg(test)]
1375 INDEX_SCAN_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1376 return Ok(ids);
1377 }
1378 }
1379 let props = [(field.to_string(), value.clone())];
1381 let mut out = Vec::new();
1382 for id in scan_ids(view, label) {
1383 if node_matches(view, vars, row, id, None, &props, params)? {
1384 out.push(id);
1385 }
1386 }
1387 Ok(out)
1388}
1389
1390#[allow(clippy::too_many_arguments)]
1393fn scan_index(
1394 view: &GraphView,
1395 vars: &VarTable,
1396 rows: &[Row],
1397 var: &str,
1398 label: Option<&str>,
1399 field: &str,
1400 value: &Operand,
1401 params: &Params,
1402) -> Result<Vec<Row>, String> {
1403 let Some(first) = rows.first() else {
1404 return Ok(Vec::new());
1405 };
1406 let ids = index_scan_ids(view, vars, first, label, field, value, params)?;
1407 let slot = vars
1408 .slot(var)
1409 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1410 let cap = max_intermediate_rows();
1411 let mut out = Vec::new();
1412 for row in rows {
1413 for &id in &ids {
1414 if out.len() >= cap {
1415 return Err(row_cap_err(cap));
1416 }
1417 let mut next = row.clone();
1418 next[slot] = Some(Cell::Node(id));
1419 out.push(next);
1420 }
1421 }
1422 Ok(out)
1423}
1424
1425fn require_cell<'a>(row: &'a Row, vars: &VarTable, var: &str) -> Result<&'a Cell, String> {
1426 let slot = vars
1427 .slot(var)
1428 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1429 row.get(slot)
1430 .and_then(|c| c.as_ref())
1431 .ok_or_else(|| format!("unbound variable `{var}`"))
1432}
1433
1434fn require_node(row: &Row, vars: &VarTable, var: &str) -> Result<u32, String> {
1435 match require_cell(row, vars, var)? {
1436 Cell::Node(id) => Ok(*id),
1437 Cell::Rel(_) => Err(format!("variable `{var}` is not a node")),
1438 Cell::Path(_) => Err(format!("variable `{var}` is a path, not a node")),
1439 Cell::Scalar(_) => Err(format!("variable `{var}` is a scalar value, not a node")),
1440 }
1441}
1442
1443const SCALAR_FUNCS: &[&str] = &[
1445 "toLower",
1446 "toUpper",
1447 "size",
1448 "coalesce",
1449 "type",
1450 "abs",
1451 "round",
1452 "textMatches",
1453 "contains",
1454 "startsWith",
1455 "endsWith",
1456 "toInteger",
1457 "toFloat",
1458 "toString",
1459];
1460
1461fn eval_string_predicate(
1464 name: &str,
1465 args: &[Operand],
1466 view: &GraphView,
1467 vars: &VarTable,
1468 row: &Row,
1469 params: &Params,
1470 f: impl Fn(&str, &str) -> bool,
1471) -> Result<Option<Value>, String> {
1472 if args.len() != 2 {
1473 return Err(format!(
1474 "{name}() requires exactly 2 arguments, got {}",
1475 args.len()
1476 ));
1477 }
1478 let a = resolve_operand(view, vars, row, &args[0], params)?;
1479 let b = resolve_operand(view, vars, row, &args[1], params)?;
1480 match (a, b) {
1481 (Some(Value::Str(s)), Some(Value::Str(sub))) => Ok(Some(Value::Bool(f(&s, &sub)))),
1482 (None, _) | (_, None) => Ok(None),
1483 _ => Ok(None),
1484 }
1485}
1486
1487fn eval_func(
1491 name: &str,
1492 args: &[Operand],
1493 view: &GraphView,
1494 vars: &VarTable,
1495 row: &Row,
1496 params: &Params,
1497) -> Result<Option<Value>, String> {
1498 let norm = name.to_ascii_lowercase();
1499 match norm.as_str() {
1500 "tolower" => {
1501 if args.len() != 1 {
1502 return Err(format!(
1503 "toLower() requires exactly 1 argument, got {}",
1504 args.len()
1505 ));
1506 }
1507 let v = resolve_operand(view, vars, row, &args[0], params)?;
1508 Ok(v.map(|val| match val {
1509 Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
1510 other => other, }))
1512 }
1513 "toupper" => {
1514 if args.len() != 1 {
1515 return Err(format!(
1516 "toUpper() requires exactly 1 argument, got {}",
1517 args.len()
1518 ));
1519 }
1520 let v = resolve_operand(view, vars, row, &args[0], params)?;
1521 Ok(v.map(|val| match val {
1522 Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
1523 other => other,
1524 }))
1525 }
1526 "size" => {
1527 if args.len() != 1 {
1528 return Err(format!(
1529 "size() requires exactly 1 argument, got {}",
1530 args.len()
1531 ));
1532 }
1533 let v = resolve_operand(view, vars, row, &args[0], params)?;
1534 match v {
1535 None => Ok(None), Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
1537 Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
1538 Some(_) => Ok(None), }
1540 }
1541 "coalesce" => {
1542 for arg in args {
1544 if let Some(v) = resolve_operand(view, vars, row, arg, params)? {
1545 return Ok(Some(v));
1546 }
1547 }
1548 Ok(None)
1549 }
1550 "type" => {
1551 if args.len() != 1 {
1552 return Err(format!(
1553 "type() requires exactly 1 argument, got {}",
1554 args.len()
1555 ));
1556 }
1557 let Operand::Var(var_name) = &args[0] else {
1559 return Err(
1560 "type() argument must be a relationship variable (e.g. type(r))".to_string(),
1561 );
1562 };
1563 let slot = vars
1564 .slot(var_name)
1565 .ok_or_else(|| format!("unbound variable `{var_name}` in type()"))?;
1566 match row.get(slot).and_then(|c| c.as_ref()) {
1567 Some(Cell::Rel(e)) => {
1568 let etype = view.syms.resolve(e.etype).unwrap_or("").to_owned();
1570 Ok(Some(Value::Str(etype)))
1571 }
1572 Some(Cell::Node(_)) => Err(format!(
1573 "type() argument `{var_name}` is a node, not a relationship"
1574 )),
1575 Some(Cell::Scalar(_) | Cell::Path(_)) => Err(format!(
1576 "type() argument `{var_name}` is not a relationship"
1577 )),
1578 None => Ok(None), }
1580 }
1581 "abs" => {
1582 if args.len() != 1 {
1583 return Err(format!(
1584 "abs() requires exactly 1 argument, got {}",
1585 args.len()
1586 ));
1587 }
1588 let v = resolve_operand(view, vars, row, &args[0], params)?;
1589 match v {
1590 None => Ok(None),
1591 Some(Value::Int(n)) => Ok(Some(Value::Int(n.abs()))),
1592 Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
1593 Some(_) => Ok(None), }
1595 }
1596 "round" => {
1597 if args.len() != 1 {
1598 return Err(format!(
1599 "round() requires exactly 1 argument, got {}",
1600 args.len()
1601 ));
1602 }
1603 let v = resolve_operand(view, vars, row, &args[0], params)?;
1604 match v {
1605 None => Ok(None),
1606 Some(Value::Int(n)) => Ok(Some(Value::Int(n))), Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
1608 Some(_) => Ok(None), }
1610 }
1611 "textmatches" => {
1612 if args.len() != 2 {
1624 return Err(format!(
1625 "textMatches() requires exactly 2 arguments (field_value, query), got {}",
1626 args.len()
1627 ));
1628 }
1629 let field_val = resolve_operand(view, vars, row, &args[0], params)?;
1630 let query_val = resolve_operand(view, vars, row, &args[1], params)?;
1631 match (field_val, query_val) {
1632 (None, _) | (_, None) => Ok(None),
1633 (Some(Value::Str(s)), Some(Value::Str(q))) => {
1634 Ok(Some(Value::Bool(core_storage::fulltext::eval_query_str(
1636 &s, &q,
1637 ))))
1638 }
1639 (Some(Value::List(items)), Some(Value::Str(q))) => Ok(Some(Value::Bool(
1640 core_storage::fulltext::eval_query_str_list(&items, &q),
1641 ))),
1642 _ => Ok(Some(Value::Bool(false))), }
1644 }
1645 "contains" => eval_string_predicate("contains", args, view, vars, row, params, |s, sub| {
1646 s.contains(sub)
1647 }),
1648 "startswith" => {
1649 eval_string_predicate("startsWith", args, view, vars, row, params, |s, p| {
1650 s.starts_with(p)
1651 })
1652 }
1653 "endswith" => eval_string_predicate("endsWith", args, view, vars, row, params, |s, p| {
1654 s.ends_with(p)
1655 }),
1656 "tointeger" => {
1657 if args.len() != 1 {
1658 return Err(format!(
1659 "toInteger() requires exactly 1 argument, got {}",
1660 args.len()
1661 ));
1662 }
1663 let v = resolve_operand(view, vars, row, &args[0], params)?;
1664 Ok(match v {
1665 None => None,
1666 Some(Value::Int(n)) => Some(Value::Int(n)),
1667 Some(Value::Float(f)) => Some(Value::Int(f.trunc() as i64)),
1668 Some(Value::Str(s)) => s
1671 .trim()
1672 .parse::<i64>()
1673 .ok()
1674 .or_else(|| s.trim().parse::<f64>().ok().map(|f| f.trunc() as i64))
1675 .map(Value::Int),
1676 Some(_) => None,
1677 })
1678 }
1679 "tofloat" => {
1680 if args.len() != 1 {
1681 return Err(format!(
1682 "toFloat() requires exactly 1 argument, got {}",
1683 args.len()
1684 ));
1685 }
1686 let v = resolve_operand(view, vars, row, &args[0], params)?;
1687 Ok(match v {
1688 None => None,
1689 Some(Value::Float(f)) => Some(Value::Float(f)),
1690 Some(Value::Int(n)) => Some(Value::Float(n as f64)),
1691 Some(Value::Str(s)) => s.trim().parse::<f64>().ok().map(Value::Float),
1692 Some(_) => None,
1693 })
1694 }
1695 "tostring" => {
1696 if args.len() != 1 {
1697 return Err(format!(
1698 "toString() requires exactly 1 argument, got {}",
1699 args.len()
1700 ));
1701 }
1702 let v = resolve_operand(view, vars, row, &args[0], params)?;
1703 Ok(match v {
1704 None => None,
1705 Some(Value::Str(s)) => Some(Value::Str(s)),
1706 Some(Value::Int(n)) => Some(Value::Str(n.to_string())),
1707 Some(Value::Float(f)) => Some(Value::Str(f.to_string())),
1708 Some(Value::Bool(b)) => Some(Value::Str(b.to_string())),
1709 Some(_) => None, })
1711 }
1712 _ => Err(format!(
1713 "unknown function `{name}`; supported: {}",
1714 SCALAR_FUNCS.join(", ")
1715 )),
1716 }
1717}
1718
1719fn resolve_operand(
1720 view: &GraphView,
1721 vars: &VarTable,
1722 row: &Row,
1723 operand: &Operand,
1724 params: &Params,
1725) -> Result<Option<Value>, String> {
1726 match operand {
1727 Operand::Lit(v) => Ok(Some(v.clone())),
1728 Operand::Param(name) => match params.0.get(name) {
1729 Some(v) => Ok(Some(v.clone())),
1730 None => Err(format!("missing parameter `{name}`")),
1731 },
1732 Operand::Prop { var, field } => resolve_prop(view, vars, row, var, field),
1733 Operand::Var(name) => {
1734 match vars
1736 .slot(name)
1737 .and_then(|s| row.get(s))
1738 .and_then(|c| c.as_ref())
1739 {
1740 Some(Cell::Scalar(v)) => Ok(Some(v.clone())),
1741 Some(Cell::Node(id)) => match view.ids.key_of(*id) {
1742 Some(key) => Ok(Some(Value::Str(key.to_owned()))),
1743 None => Ok(None),
1744 },
1745 Some(Cell::Path(hops)) => Ok(Some(Value::Int(*hops as i64))),
1746 Some(Cell::Rel(_)) => Err(format!("variable `{name}` is a relationship")),
1747 None => Ok(None),
1748 }
1749 }
1750 Operand::FuncCall { name, args } => eval_func(name, args, view, vars, row, params),
1751 Operand::Case { branches, default } => {
1752 for (cond, value) in branches {
1753 if eval_expr(view, vars, row, cond, params, 0)? {
1754 return resolve_operand(view, vars, row, value, params);
1755 }
1756 }
1757 match default {
1758 Some(d) => resolve_operand(view, vars, row, d, params),
1759 None => Ok(None),
1760 }
1761 }
1762 Operand::BinArith { op, left, right } => {
1763 use super::ast::ArithOp;
1764 let lv = resolve_operand(view, vars, row, left, params)?;
1765 let rv = resolve_operand(view, vars, row, right, params)?;
1766 match (lv, rv) {
1767 (None, _) | (_, None) => Ok(None), (Some(Value::Int(a)), Some(Value::Int(b))) => {
1769 let result = match op {
1770 ArithOp::Sub => a.saturating_sub(b),
1771 ArithOp::Mul => a.saturating_mul(b),
1772 ArithOp::Add => a.saturating_add(b),
1773 ArithOp::Div => {
1774 if b == 0 {
1775 return Err("division by zero".into());
1776 }
1777 a.checked_div(b).unwrap_or(i64::MAX)
1778 }
1779 };
1780 Ok(Some(Value::Int(result)))
1781 }
1782 (Some(lv), Some(rv)) => {
1783 let a = match &lv {
1784 Value::Float(f) => *f,
1785 Value::Int(i) => *i as f64,
1786 _ => return Err(format!("arithmetic operand must be numeric, got {lv:?}")),
1787 };
1788 let b = match &rv {
1789 Value::Float(f) => *f,
1790 Value::Int(i) => *i as f64,
1791 _ => return Err(format!("arithmetic operand must be numeric, got {rv:?}")),
1792 };
1793 let result = match op {
1794 ArithOp::Sub => a - b,
1795 ArithOp::Mul => a * b,
1796 ArithOp::Add => a + b,
1797 ArithOp::Div => {
1798 if b == 0.0 {
1799 return Err("division by zero".into());
1800 }
1801 a / b
1802 }
1803 };
1804 Ok(Some(Value::Float(result)))
1805 }
1806 }
1807 }
1808 }
1809}
1810
1811fn resolve_prop(
1812 view: &GraphView,
1813 vars: &VarTable,
1814 row: &Row,
1815 var: &str,
1816 field: &str,
1817) -> Result<Option<Value>, String> {
1818 let slot = vars
1821 .slot(var)
1822 .ok_or_else(|| format!("unbound variable `{var}`"))?;
1823 let cell = match row.get(slot).and_then(|c| c.as_ref()) {
1824 Some(c) => c,
1825 None => return Ok(None),
1827 };
1828 match cell {
1829 Cell::Node(id) => Ok(view.prop(*id, field).map(|vr| vr.into_value())),
1830 Cell::Rel(e) => Ok(view.edge_props.get(e.etype, e.src, e.dst, field)),
1831 Cell::Path(hops) => {
1833 if field == "length" {
1834 Ok(Some(Value::Int(*hops as i64)))
1835 } else {
1836 Ok(None)
1837 }
1838 }
1839 Cell::Scalar(_) => Ok(None),
1841 }
1842}
1843
1844fn node_matches(
1845 view: &GraphView,
1846 vars: &VarTable,
1847 row: &Row,
1848 id: u32,
1849 label: Option<&str>,
1850 props: &[(String, Operand)],
1851 params: &Params,
1852) -> Result<bool, String> {
1853 if let Some(want) = label {
1854 match view.label_of(id) {
1855 Some(got) if got == want => {}
1856 _ => return Ok(false),
1857 }
1858 }
1859 for (field, operand) in props {
1860 let Some(expected) = resolve_operand(view, vars, row, operand, params)? else {
1861 return Ok(false);
1862 };
1863 match view.prop(id, field) {
1864 Some(got) if values_equal(got.as_value(), &expected) => {}
1865 _ => return Ok(false),
1866 }
1867 }
1868 Ok(true)
1869}
1870
1871fn retain_node(
1872 view: &GraphView,
1873 vars: &VarTable,
1874 rows: &[Row],
1875 var: &str,
1876 label: Option<&str>,
1877 props: &[(String, Operand)],
1878 params: &Params,
1879) -> Result<Vec<Row>, String> {
1880 let mut out = Vec::with_capacity(rows.len());
1881 for row in rows {
1882 let id = require_node(row, vars, var)?;
1883 if node_matches(view, vars, row, id, label, props, params)? {
1884 out.push(row.clone());
1885 }
1886 }
1887 Ok(out)
1888}
1889
1890fn map_dir(dir: RelDir) -> Dir {
1891 match dir {
1892 RelDir::Right => Dir::Out,
1893 RelDir::Left => Dir::In,
1894 RelDir::Undirected => Dir::Both,
1895 }
1896}
1897
1898fn neighbor(from: u32, e: &EdgeRef, dir: RelDir) -> u32 {
1899 match dir {
1900 RelDir::Right => e.dst,
1901 RelDir::Left => e.src,
1902 RelDir::Undirected => {
1903 if e.src == from {
1904 e.dst
1905 } else {
1906 e.src
1907 }
1908 }
1909 }
1910}
1911
1912fn row_has_edge(row: &Row, e: &EdgeRef) -> bool {
1913 row.iter()
1914 .any(|c| matches!(c, Some(Cell::Rel(existing)) if existing == e))
1915}
1916
1917fn resolve_etypes(view: &GraphView, etypes: &[String]) -> Option<Vec<u32>> {
1918 if etypes.is_empty() {
1919 None } else {
1921 Some(etypes.iter().filter_map(|n| view.syms.get(n)).collect())
1924 }
1925}
1926
1927fn exec_expand(
1928 view: &GraphView,
1929 vars: &VarTable,
1930 rows: &[Row],
1931 op: &PlanOp,
1932 params: &Params,
1933) -> Result<Vec<Row>, String> {
1934 let PlanOp::Expand {
1935 from,
1936 rel_var,
1937 etypes,
1938 dir,
1939 to,
1940 to_label,
1941 to_props,
1942 } = op
1943 else {
1944 return Err("internal: expected Expand".into());
1945 };
1946 let etypes = resolve_etypes(view, etypes);
1947 let exp_dir = map_dir(*dir);
1948 let to_slot = vars
1949 .slot(to)
1950 .ok_or_else(|| format!("unbound variable `{to}`"))?;
1951 let rel_slot = rel_var.as_ref().and_then(|rv| vars.slot(rv));
1952 let cap = max_intermediate_rows();
1953 let mut out = Vec::with_capacity(rows.len().saturating_mul(2).min(cap));
1954 for row in rows {
1955 let from_id = require_node(row, vars, from)?;
1956 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
1957 Some(Cell::Node(id)) => Some(*id),
1958 Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
1959 return Err(format!("variable `{to}` is not a node"))
1960 }
1961 None => None,
1962 };
1963 for e in expand(view, from_id, etypes.as_deref(), exp_dir) {
1964 if row_has_edge(row, &e) {
1965 continue;
1966 }
1967 let nbr = neighbor(from_id, &e, *dir);
1968 if !view.visible(nbr) {
1969 continue;
1970 }
1971 if let Some(want) = bound_to {
1972 if nbr != want {
1973 continue;
1974 }
1975 }
1976 if !node_matches(view, vars, row, nbr, to_label.as_deref(), to_props, params)? {
1977 continue;
1978 }
1979 if out.len() >= cap {
1980 return Err(row_cap_err(cap));
1981 }
1982 let mut next = row.clone();
1983 if let Some(slot) = rel_slot {
1984 next[slot] = Some(Cell::Rel(e));
1985 }
1986 if bound_to.is_none() {
1987 next[to_slot] = Some(Cell::Node(nbr));
1988 }
1989 out.push(next);
1990 #[cfg(test)]
1991 record_expand_row();
1992 }
1993 }
1994 Ok(out)
1995}
1996
1997#[allow(clippy::too_many_arguments)]
2004fn exec_var_expand(
2005 view: &GraphView,
2006 vars: &VarTable,
2007 rows: &[Row],
2008 from: &str,
2009 rel_var: &Option<String>,
2010 etypes: &[String],
2011 dir: RelDir,
2012 to: &str,
2013 min: u8,
2014 max: u8,
2015) -> Result<Vec<Row>, String> {
2016 let etypes = resolve_etypes(view, etypes);
2017 let exp_dir = map_dir(dir);
2018 let to_slot = vars
2019 .slot(to)
2020 .ok_or_else(|| format!("unbound variable `{to}`"))?;
2021 let rel_slot = rel_var.as_ref().and_then(|rv| vars.slot(rv));
2022 let cap = max_intermediate_rows();
2023 let mut out: Vec<Row> = Vec::new();
2024
2025 for row in rows {
2026 let from_id = require_node(row, vars, from)?;
2027 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
2028 Some(Cell::Node(id)) => Some(*id),
2029 Some(_) => return Err(format!("variable `{to}` is not a node")),
2030 None => None,
2031 };
2032
2033 struct PathState {
2036 node: u32,
2037 edges: Vec<EdgeRef>,
2038 }
2039
2040 let mut frontier: Vec<PathState> = vec![PathState {
2041 node: from_id,
2042 edges: Vec::new(),
2043 }];
2044
2045 let mut frontier_count: usize = 0;
2050
2051 for depth in 1u8..=max {
2052 let mut next_frontier: Vec<PathState> = Vec::new();
2053 for state in &frontier {
2054 for e in expand(view, state.node, etypes.as_deref(), exp_dir) {
2055 if state.edges.contains(&e) {
2057 continue;
2058 }
2059 let nbr = neighbor(state.node, &e, dir);
2060 if !view.visible(nbr) {
2063 continue;
2064 }
2065
2066 if depth >= min {
2069 let dest_matches = match bound_to {
2070 Some(want) => nbr == want,
2071 None => true,
2072 };
2073 if dest_matches {
2074 if out.len() >= cap {
2075 return Err(row_cap_err(cap));
2076 }
2077 let mut next = row.clone();
2078 if let Some(slot) = rel_slot {
2079 next[slot] = Some(Cell::Path(depth));
2080 }
2081 next[to_slot] = Some(Cell::Node(nbr));
2082 out.push(next);
2083 }
2084 }
2085
2086 if depth < max {
2090 frontier_count += 1;
2091 if frontier_count >= cap {
2092 return Err(row_cap_err(cap));
2093 }
2094 let mut new_edges = state.edges.clone();
2095 new_edges.push(e);
2096 next_frontier.push(PathState {
2097 node: nbr,
2098 edges: new_edges,
2099 });
2100 }
2101 }
2102 }
2103 frontier = next_frontier;
2104 if frontier.is_empty() {
2105 break;
2106 }
2107 }
2108 }
2109 Ok(out)
2110}
2111
2112#[allow(clippy::too_many_arguments)]
2118fn exec_shortest_path(
2119 view: &GraphView,
2120 vars: &VarTable,
2121 rows: &[Row],
2122 from: &str,
2123 rel_var: &Option<String>,
2124 etypes: &[String],
2125 dir: RelDir,
2126 to: &str,
2127 max_hops: u8,
2128) -> Result<Vec<Row>, String> {
2129 let etypes = resolve_etypes(view, etypes);
2130 let exp_dir = map_dir(dir);
2131 let rel_slot = rel_var.as_ref().and_then(|rv| vars.slot(rv));
2132 let mut out: Vec<Row> = Vec::new();
2133
2134 for row in rows {
2135 let from_id = require_node(row, vars, from)?;
2136 let to_id = require_node(row, vars, to)?;
2137
2138 let mut visited = std::collections::BTreeSet::new();
2140 visited.insert(from_id);
2141 let mut frontier: Vec<u32> = vec![from_id];
2142
2143 'bfs: for depth in 1u8..=max_hops {
2144 let mut next_frontier: Vec<u32> = Vec::new();
2145 for &node in &frontier {
2146 for e in expand(view, node, etypes.as_deref(), exp_dir) {
2147 let nbr = neighbor(node, &e, dir);
2148 if !view.visible(nbr) {
2152 continue;
2153 }
2154 if nbr == to_id {
2155 let mut next = row.clone();
2157 if let Some(slot) = rel_slot {
2158 next[slot] = Some(Cell::Path(depth));
2159 }
2160 out.push(next);
2161 break 'bfs;
2162 }
2163 if !visited.contains(&nbr) {
2164 visited.insert(nbr);
2165 next_frontier.push(nbr);
2166 }
2167 }
2168 }
2169 frontier = next_frontier;
2170 if frontier.is_empty() {
2171 break;
2172 }
2173 }
2174 }
2175 Ok(out)
2176}
2177
2178fn exec_filter(
2179 view: &GraphView,
2180 vars: &VarTable,
2181 rows: &[Row],
2182 expr: &Expr,
2183 params: &Params,
2184) -> Result<Vec<Row>, String> {
2185 let mut out = Vec::with_capacity(rows.len());
2186 for row in rows {
2187 if eval_expr(view, vars, row, expr, params, 0)? {
2188 out.push(row.clone());
2189 }
2190 }
2191 Ok(out)
2192}
2193
2194struct PullCtx<'a> {
2210 view: &'a GraphView<'a>,
2211 vars: &'a VarTable,
2212 project_items: &'a [RetItem],
2213 params: &'a Params<'a>,
2214 bound: usize,
2215}
2216
2217fn execute_pull(
2218 view: &GraphView,
2219 plan: &[PlanOp],
2220 params: &Params,
2221 bound: usize,
2222) -> Result<ResultSet, String> {
2223 let proj_pos = match plan
2224 .iter()
2225 .position(|op| matches!(op, PlanOp::Project { .. }))
2226 {
2227 Some(p) => p,
2228 None => return Ok(ResultSet::new(vec![])),
2229 };
2230 let producers = &plan[..proj_pos];
2231 let project_items = match &plan[proj_pos] {
2232 PlanOp::Project { items } => items,
2233 _ => unreachable!(),
2234 };
2235 let columns: Vec<String> = project_items.iter().map(column_name).collect();
2236 let vars = collect_vars(plan);
2237 let ctx = PullCtx {
2238 view,
2239 vars: &vars,
2240 project_items,
2241 params,
2242 bound,
2243 };
2244 let mut initial_row: Row = vec![None; vars.names.len()];
2245 let mut result_rows: Vec<Vec<Option<Value>>> = Vec::with_capacity(bound);
2246 pull_rows(&ctx, producers, &mut initial_row, &mut result_rows)?;
2247 let skip_n = plan[proj_pos + 1..]
2250 .iter()
2251 .find_map(|op| match op {
2252 PlanOp::Skip(ls) => Some(resolve_ls(ls, params)),
2253 _ => None,
2254 })
2255 .transpose()?
2256 .unwrap_or(0);
2257 let skip_n = usize::try_from(skip_n).unwrap_or(usize::MAX);
2258 let mut rs = ResultSet::new(columns);
2259 for row in result_rows.into_iter().skip(skip_n) {
2260 rs.push_row(row);
2261 }
2262 Ok(rs)
2263}
2264
2265enum AggAcc {
2279 Count(u64),
2280 Sum { val: f64, has_value: bool },
2281 Avg { sum: f64, n: u64 },
2282 Min(Option<Value>),
2283 Max(Option<Value>),
2284 Collect(Vec<Value>),
2285}
2286
2287impl AggAcc {
2288 fn new(func: &AggFunc) -> Self {
2289 match func {
2290 AggFunc::Count => AggAcc::Count(0),
2291 AggFunc::Sum => AggAcc::Sum {
2292 val: 0.0,
2293 has_value: false,
2294 },
2295 AggFunc::Avg => AggAcc::Avg { sum: 0.0, n: 0 },
2296 AggFunc::Min => AggAcc::Min(None),
2297 AggFunc::Max => AggAcc::Max(None),
2298 AggFunc::Collect => AggAcc::Collect(Vec::new()),
2299 }
2300 }
2301
2302 fn finish(self) -> Option<Value> {
2303 match self {
2304 AggAcc::Count(n) => Some(Value::Int(i64::try_from(n).unwrap_or(i64::MAX))),
2309 AggAcc::Sum { val, has_value } => {
2310 if has_value {
2311 Some(Value::Float(val))
2312 } else {
2313 None
2314 }
2315 }
2316 AggAcc::Avg { sum, n } => {
2317 if n > 0 {
2318 Some(Value::Float(sum / n as f64))
2319 } else {
2320 None
2321 }
2322 }
2323 AggAcc::Min(v) => v,
2324 AggAcc::Max(v) => v,
2325 AggAcc::Collect(items) => Some(Value::List(items)),
2327 }
2328 }
2329}
2330
2331fn numeric_val(v: &Value) -> Option<f64> {
2334 match v {
2335 Value::Int(n) => Some(*n as f64),
2336 Value::Float(f) => Some(*f),
2337 _ => None,
2338 }
2339}
2340
2341struct AggStreamCtx<'a> {
2343 view: &'a GraphView<'a>,
2344 vars: &'a VarTable,
2345 params: &'a Params<'a>,
2346 func: &'a AggFunc,
2347 arg: &'a AggArg,
2348}
2349
2350fn execute_aggregate(
2355 view: &GraphView,
2356 plan: &[PlanOp],
2357 params: &Params,
2358) -> Result<ResultSet, String> {
2359 let agg_pos = match plan
2360 .iter()
2361 .position(|op| matches!(op, PlanOp::Aggregate { .. }))
2362 {
2363 Some(p) => p,
2364 None => return Ok(ResultSet::new(vec![])),
2365 };
2366 let producers = &plan[..agg_pos];
2367 let (func, arg, column) = match &plan[agg_pos] {
2368 PlanOp::Aggregate { func, arg, column } => (func, arg, column),
2369 _ => unreachable!(),
2370 };
2371 let vars = collect_vars(plan);
2372 let ctx = AggStreamCtx {
2373 view,
2374 vars: &vars,
2375 params,
2376 func,
2377 arg,
2378 };
2379 let initial_row: Row = vec![None; vars.names.len()];
2380 let mut acc = AggAcc::new(func);
2381 agg_stream(&ctx, producers, &initial_row, &mut acc)?;
2382
2383 let value = acc.finish();
2384 let mut rs = ResultSet::new(vec![column.clone()]);
2385 rs.push_row(vec![value]);
2386 Ok(rs)
2387}
2388
2389fn update_acc(
2395 view: &GraphView,
2396 vars: &VarTable,
2397 row: &Row,
2398 func: &AggFunc,
2399 arg: &AggArg,
2400 acc: &mut AggAcc,
2401) -> Result<(), String> {
2402 match (func, arg) {
2403 (AggFunc::Count, AggArg::Star) => {
2404 if let AggAcc::Count(n) = acc {
2405 *n += 1;
2406 }
2407 }
2408 (AggFunc::Count, AggArg::Var(v)) => {
2409 let slot = vars.slot(v);
2410 let is_bound = slot
2411 .and_then(|s| row.get(s))
2412 .and_then(|c| c.as_ref())
2413 .is_some();
2414 if is_bound {
2415 if let AggAcc::Count(n) = acc {
2416 *n += 1;
2417 }
2418 }
2419 }
2420 (AggFunc::Count, AggArg::Prop { var, field }) => {
2421 let val = resolve_prop(view, vars, row, var, field)?;
2422 if val.is_some() {
2423 if let AggAcc::Count(n) = acc {
2424 *n += 1;
2425 }
2426 }
2427 }
2428 (AggFunc::Sum, AggArg::Prop { var, field }) => {
2429 if let Some(v) = resolve_prop(view, vars, row, var, field)? {
2430 if let Some(num) = numeric_val(&v) {
2431 if let AggAcc::Sum { val, has_value } = acc {
2432 *val += num;
2433 *has_value = true;
2434 }
2435 }
2436 }
2437 }
2438 (AggFunc::Avg, AggArg::Prop { var, field }) => {
2439 if let Some(v) = resolve_prop(view, vars, row, var, field)? {
2440 if let Some(num) = numeric_val(&v) {
2441 if let AggAcc::Avg { sum, n } = acc {
2442 *sum += num;
2443 *n += 1;
2444 }
2445 }
2446 }
2447 }
2448 (AggFunc::Min, AggArg::Prop { var, field }) => {
2449 if let Some(v) = resolve_prop(view, vars, row, var, field)? {
2450 if numeric_val(&v).is_some() {
2451 if let AggAcc::Min(current) = acc {
2452 *current = Some(match current.take() {
2453 None => v,
2454 Some(prev) => {
2455 if cmp_optional(Some(&prev), Some(&v), false)
2456 == std::cmp::Ordering::Greater
2457 {
2458 v
2459 } else {
2460 prev
2461 }
2462 }
2463 });
2464 }
2465 }
2466 }
2467 }
2468 (AggFunc::Max, AggArg::Prop { var, field }) => {
2469 if let Some(v) = resolve_prop(view, vars, row, var, field)? {
2470 if numeric_val(&v).is_some() {
2471 if let AggAcc::Max(current) = acc {
2472 *current = Some(match current.take() {
2473 None => v,
2474 Some(prev) => {
2475 if cmp_optional(Some(&prev), Some(&v), true)
2476 == std::cmp::Ordering::Greater
2477 {
2478 v
2479 } else {
2480 prev
2481 }
2482 }
2483 });
2484 }
2485 }
2486 }
2487 }
2488 (AggFunc::Collect, AggArg::Var(v)) => {
2489 let val = vars
2492 .slot(v)
2493 .and_then(|s| row.get(s))
2494 .and_then(|c| c.as_ref())
2495 .and_then(|cell| match cell {
2496 Cell::Scalar(x) => Some(x.clone()),
2497 Cell::Node(id) => view.ids.key_of(*id).map(|k| Value::Str(k.to_owned())),
2498 Cell::Path(h) => Some(Value::Int(*h as i64)),
2499 Cell::Rel(_) => None,
2500 });
2501 if let Some(val) = val {
2502 if let AggAcc::Collect(items) = acc {
2503 items.push(val);
2504 }
2505 }
2506 }
2507 (AggFunc::Collect, AggArg::Prop { var, field }) => {
2508 if let Some(val) = resolve_prop(view, vars, row, var, field)? {
2509 if let AggAcc::Collect(items) = acc {
2510 items.push(val);
2511 }
2512 }
2513 }
2514 _ => {}
2517 }
2518 Ok(())
2519}
2520
2521fn agg_stream(
2524 ctx: &AggStreamCtx<'_>,
2525 ops: &[PlanOp],
2526 row: &Row,
2527 acc: &mut AggAcc,
2528) -> Result<(), String> {
2529 let (op, rest) = match ops.split_first() {
2530 Some(pair) => pair,
2531 None => {
2532 update_acc(ctx.view, ctx.vars, row, ctx.func, ctx.arg, acc)?;
2534 return Ok(());
2535 }
2536 };
2537
2538 match op {
2539 PlanOp::ScanLabel { var, label } => {
2540 let ids = scan_ids(ctx.view, label.as_deref());
2541 let slot = ctx
2542 .vars
2543 .slot(var)
2544 .ok_or_else(|| format!("unbound variable `{var}`"))?;
2545 for &id in &ids {
2546 let mut next = row.clone();
2547 next[slot] = Some(Cell::Node(id));
2548 agg_stream(ctx, rest, &next, acc)?;
2549 }
2550 }
2551 PlanOp::ScanKey { var, key, label } => {
2552 let slot = ctx
2553 .vars
2554 .slot(var)
2555 .ok_or_else(|| format!("unbound variable `{var}`"))?;
2556 if let Some(id) =
2557 resolve_scan_key_id(ctx.view, ctx.vars, row, key, label.as_deref(), ctx.params)?
2558 {
2559 let mut next = row.clone();
2560 next[slot] = Some(Cell::Node(id));
2561 agg_stream(ctx, rest, &next, acc)?;
2562 }
2563 }
2564 PlanOp::IndexScan {
2565 var,
2566 label,
2567 field,
2568 value,
2569 } => {
2570 let ids = index_scan_ids(
2571 ctx.view,
2572 ctx.vars,
2573 row,
2574 label.as_deref(),
2575 field,
2576 value,
2577 ctx.params,
2578 )?;
2579 let slot = ctx
2580 .vars
2581 .slot(var)
2582 .ok_or_else(|| format!("unbound variable `{var}`"))?;
2583 for &id in &ids {
2584 let mut next = row.clone();
2585 next[slot] = Some(Cell::Node(id));
2586 agg_stream(ctx, rest, &next, acc)?;
2587 }
2588 }
2589 PlanOp::Expand {
2590 from,
2591 rel_var,
2592 etypes,
2593 dir,
2594 to,
2595 to_label,
2596 to_props,
2597 } => {
2598 let etypes = resolve_etypes(ctx.view, etypes);
2599 let exp_dir = map_dir(*dir);
2600 let to_slot = ctx
2601 .vars
2602 .slot(to)
2603 .ok_or_else(|| format!("unbound variable `{to}`"))?;
2604 let rel_slot = rel_var.as_ref().and_then(|rv| ctx.vars.slot(rv));
2605 let from_id = require_node(row, ctx.vars, from)?;
2606 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
2607 Some(Cell::Node(id)) => Some(*id),
2608 Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
2609 return Err(format!("variable `{to}` is not a node"))
2610 }
2611 None => None,
2612 };
2613 for e in expand(ctx.view, from_id, etypes.as_deref(), exp_dir) {
2614 if row_has_edge(row, &e) {
2615 continue;
2616 }
2617 let nbr = neighbor(from_id, &e, *dir);
2618 if !ctx.view.visible(nbr) {
2619 continue;
2620 }
2621 if let Some(want) = bound_to {
2622 if nbr != want {
2623 continue;
2624 }
2625 }
2626 if !node_matches(
2627 ctx.view,
2628 ctx.vars,
2629 row,
2630 nbr,
2631 to_label.as_deref(),
2632 to_props,
2633 ctx.params,
2634 )? {
2635 continue;
2636 }
2637 let mut next = row.clone();
2638 if let Some(slot) = rel_slot {
2639 next[slot] = Some(Cell::Rel(e));
2640 }
2641 if bound_to.is_none() {
2642 next[to_slot] = Some(Cell::Node(nbr));
2643 }
2644 agg_stream(ctx, rest, &next, acc)?;
2645 }
2646 }
2647 PlanOp::Filter { expr } => {
2648 if eval_expr(ctx.view, ctx.vars, row, expr, ctx.params, 0)? {
2649 agg_stream(ctx, rest, row, acc)?;
2650 }
2651 }
2652 PlanOp::LookupProps { var, props } => {
2653 let id = require_node(row, ctx.vars, var)?;
2654 if node_matches(ctx.view, ctx.vars, row, id, None, props, ctx.params)? {
2655 agg_stream(ctx, rest, row, acc)?;
2656 }
2657 }
2658 PlanOp::JoinBound { var, label, props } => {
2659 let id = require_node(row, ctx.vars, var)?;
2660 if node_matches(
2661 ctx.view,
2662 ctx.vars,
2663 row,
2664 id,
2665 label.as_deref(),
2666 props,
2667 ctx.params,
2668 )? {
2669 agg_stream(ctx, rest, row, acc)?;
2670 }
2671 }
2672 PlanOp::VarExpand {
2673 from,
2674 rel_var,
2675 etypes,
2676 dir,
2677 to,
2678 min,
2679 max,
2680 } => {
2681 let new_rows = exec_var_expand(
2682 ctx.view,
2683 ctx.vars,
2684 std::slice::from_ref(row),
2685 from,
2686 rel_var,
2687 etypes,
2688 *dir,
2689 to,
2690 *min,
2691 *max,
2692 )?;
2693 for nr in &new_rows {
2694 agg_stream(ctx, rest, nr, acc)?;
2695 }
2696 }
2697 PlanOp::ShortestPath {
2698 from,
2699 rel_var,
2700 etypes,
2701 dir,
2702 to,
2703 max_hops,
2704 } => {
2705 let new_rows = exec_shortest_path(
2706 ctx.view,
2707 ctx.vars,
2708 std::slice::from_ref(row),
2709 from,
2710 rel_var,
2711 etypes,
2712 *dir,
2713 to,
2714 *max_hops,
2715 )?;
2716 for nr in &new_rows {
2717 agg_stream(ctx, rest, nr, acc)?;
2718 }
2719 }
2720 PlanOp::Project { .. } => {
2722 return Err(
2723 "agg executor: Project in producer slice — plan is structurally malformed"
2724 .to_string(),
2725 );
2726 }
2727 PlanOp::Distinct => {
2728 return Err(
2729 "agg executor: Distinct in producer slice — plan is structurally malformed"
2730 .to_string(),
2731 );
2732 }
2733 PlanOp::OrderBy { .. } => {
2734 return Err(
2735 "agg executor: OrderBy in producer slice — structurally malformed".to_string(),
2736 );
2737 }
2738 PlanOp::Skip(_) => {
2739 return Err(
2740 "agg executor: Skip in producer slice — structurally malformed".to_string(),
2741 );
2742 }
2743 PlanOp::Limit(_) => {
2744 return Err(
2745 "agg executor: Limit in producer slice — structurally malformed".to_string(),
2746 );
2747 }
2748 PlanOp::Aggregate { .. } => {
2749 return Err(
2750 "agg executor: nested Aggregate in producer slice — structurally malformed"
2751 .to_string(),
2752 );
2753 }
2754 PlanOp::GroupAggregate { .. } => {
2755 return Err(
2756 "agg executor: GroupAggregate in producer slice — structurally malformed"
2757 .to_string(),
2758 );
2759 }
2760 PlanOp::With { .. } => {
2761 return Err(
2762 "agg executor: With in producer slice — structurally malformed".to_string(),
2763 );
2764 }
2765 PlanOp::Unwind { .. } => {
2766 return Err(
2767 "agg executor: Unwind in producer slice — structurally malformed".to_string(),
2768 );
2769 }
2770 PlanOp::LeftOuterApply { .. } => {
2771 return Err(
2772 "agg executor: LeftOuterApply in producer slice — structurally malformed"
2773 .to_string(),
2774 );
2775 }
2776 }
2777 Ok(())
2778}
2779
2780struct GroupStreamCtx<'a> {
2788 view: &'a GraphView<'a>,
2789 vars: &'a VarTable,
2790 params: &'a Params<'a>,
2791 keys: &'a [(String, RetItem)],
2792 aggs: &'a [(AggFunc, AggArg, String)],
2793}
2794
2795fn build_group_projected(
2797 keys: &[(String, RetItem)],
2798 aggs: &[(AggFunc, AggArg, String)],
2799 key_order: Vec<GroupKey>,
2800 groups: &mut HashMap<GroupKey, GroupEntry>,
2801) -> Projected {
2802 let columns: Vec<String> = keys
2803 .iter()
2804 .map(|(col, _)| col.clone())
2805 .chain(aggs.iter().map(|(_, _, col)| col.clone()))
2806 .collect();
2807 let mut rows: Vec<Vec<Option<Value>>> = Vec::with_capacity(key_order.len());
2808 for gk in key_order {
2809 let (display_keys, accs) = groups.remove(&gk).unwrap_or_default();
2810 let mut row: Vec<Option<Value>> = Vec::with_capacity(columns.len());
2811 for display_val in display_keys {
2814 row.push(display_val);
2815 }
2816 for acc in accs {
2817 row.push(acc.finish());
2818 }
2819 rows.push(row);
2820 }
2821 Projected { columns, rows }
2822}
2823
2824fn group_result_to_rows(
2831 keys: &[(String, RetItem)],
2832 aggs: &[(AggFunc, AggArg, String)],
2833 key_order: Vec<GroupKey>,
2834 groups: &mut HashMap<GroupKey, GroupEntry>,
2835 vars: &VarTable,
2836) -> Vec<Row> {
2837 let row_len = vars.names.len();
2838 let mut out: Vec<Row> = Vec::with_capacity(key_order.len());
2839 for gk in key_order {
2840 let (display_keys, accs) = groups.remove(&gk).unwrap_or_default();
2841 let mut row: Row = vec![None; row_len];
2842 for ((col, _), val) in keys.iter().zip(display_keys) {
2843 if let Some(slot) = vars.slot(col) {
2844 row[slot] = val.map(Cell::Scalar);
2845 }
2846 }
2847 for ((_, _, col), acc) in aggs.iter().zip(accs) {
2848 if let Some(slot) = vars.slot(col) {
2849 row[slot] = acc.finish().map(Cell::Scalar);
2850 }
2851 }
2852 out.push(row);
2853 }
2854 out
2855}
2856
2857fn exec_order_by_rows(vars: &VarTable, rows: &mut Vec<Row>, items: &[OrderItem], view: &GraphView) {
2864 let mut key_table: Vec<Vec<Option<Value>>> = Vec::with_capacity(rows.len());
2867 for row in rows.iter() {
2868 let mut row_key: Vec<Option<Value>> = Vec::with_capacity(items.len());
2869 for item in items {
2870 let val = match &item.target {
2871 OrderTarget::Alias(name) | OrderTarget::Var(name) => vars
2872 .slot(name)
2873 .and_then(|s| row.get(s))
2874 .and_then(|c| c.as_ref())
2875 .and_then(|c| match c {
2876 Cell::Scalar(v) => Some(v.clone()),
2877 Cell::Node(id) => view.ids.key_of(*id).map(|k| Value::Str(k.to_owned())),
2878 Cell::Path(hops) => Some(Value::Int(*hops as i64)),
2879 Cell::Rel(_) => None,
2880 }),
2881 OrderTarget::Prop { var, field } => vars
2882 .slot(var)
2883 .and_then(|s| row.get(s))
2884 .and_then(|c| c.as_ref())
2885 .and_then(|c| match c {
2886 Cell::Node(id) => view.prop(*id, field).map(|vr| vr.into_value()),
2887 Cell::Rel(e) => view.edge_props.get(e.etype, e.src, e.dst, field),
2888 _ => None,
2889 }),
2890 };
2891 row_key.push(val);
2892 }
2893 key_table.push(row_key);
2894 }
2895 let mut indices: Vec<usize> = (0..rows.len()).collect();
2897 indices.sort_by(|&a, &b| {
2898 for (ki, item) in items.iter().enumerate() {
2899 let c = cmp_optional(
2900 key_table[a].get(ki).and_then(|x| x.as_ref()),
2901 key_table[b].get(ki).and_then(|x| x.as_ref()),
2902 item.descending,
2903 );
2904 if c != std::cmp::Ordering::Equal {
2905 return c;
2906 }
2907 }
2908 std::cmp::Ordering::Equal
2909 });
2910 let sorted: Vec<Row> = indices.into_iter().map(|i| rows[i].clone()).collect();
2911 *rows = sorted;
2912}
2913
2914fn execute_group_aggregate(
2920 view: &GraphView,
2921 plan: &[PlanOp],
2922 params: &Params,
2923) -> Result<ResultSet, String> {
2924 let gagg_pos = plan
2925 .iter()
2926 .position(|op| matches!(op, PlanOp::GroupAggregate { .. }))
2927 .ok_or_else(|| "internal: GroupAggregate op not found in plan".to_string())?;
2928 let producers = &plan[..gagg_pos];
2929 let (keys, aggs) = match &plan[gagg_pos] {
2930 PlanOp::GroupAggregate { keys, aggs } => (keys, aggs),
2931 _ => unreachable!(),
2932 };
2933 let tail = &plan[gagg_pos + 1..];
2934 let vars = collect_vars(plan);
2935 let initial_row: Row = vec![None; vars.names.len()];
2936 let mut groups: HashMap<GroupKey, GroupEntry> = HashMap::new();
2937 let mut key_order: Vec<GroupKey> = Vec::new();
2938 let ctx = GroupStreamCtx {
2939 view,
2940 vars: &vars,
2941 params,
2942 keys,
2943 aggs,
2944 };
2945 group_stream(&ctx, producers, &initial_row, &mut groups, &mut key_order)?;
2946 if keys.is_empty() && key_order.is_empty() {
2950 let empty_key: GroupKey = vec![];
2951 key_order.push(empty_key.clone());
2952 groups.insert(
2953 empty_key,
2954 (
2955 vec![],
2956 aggs.iter().map(|(f, _, _)| AggAcc::new(f)).collect(),
2957 ),
2958 );
2959 }
2960 let mut projected = build_group_projected(keys, aggs, key_order, &mut groups);
2961 for op in tail {
2963 match op {
2964 PlanOp::OrderBy { items } => exec_order_by(&mut projected, items)?,
2965 PlanOp::Skip(ls) => {
2966 let n = resolve_ls(ls, params)?;
2967 apply_skip(&mut projected.rows, n);
2968 }
2969 PlanOp::Limit(ls) => {
2970 let n = resolve_ls(ls, params)?;
2971 apply_limit(&mut projected.rows, n);
2972 }
2973 _ => {} }
2975 }
2976 Ok(finish(projected))
2977}
2978
2979fn group_stream(
2986 ctx: &GroupStreamCtx<'_>,
2987 ops: &[PlanOp],
2988 row: &Row,
2989 groups: &mut HashMap<GroupKey, GroupEntry>,
2990 key_order: &mut Vec<GroupKey>,
2991) -> Result<(), String> {
2992 let (op, rest) = match ops.split_first() {
2993 Some(pair) => pair,
2994 None => {
2995 let mut gk: GroupKey = Vec::with_capacity(ctx.keys.len());
2998 let mut display_vals: Vec<Option<Value>> = Vec::with_capacity(ctx.keys.len());
2999 for (_, item) in ctx.keys {
3000 let val = project_item(ctx.view, ctx.vars, row, item, ctx.params)?;
3001 gk.push(val.as_ref().and_then(group_key_normalize));
3002 display_vals.push(val);
3003 }
3004 if !groups.contains_key(&gk) {
3005 if groups.len() >= max_groups() {
3006 return Err(group_cap_err());
3007 }
3008 key_order.push(gk.clone());
3009 let init: Vec<AggAcc> = ctx.aggs.iter().map(|(f, _, _)| AggAcc::new(f)).collect();
3010 groups.insert(gk.clone(), (display_vals, init));
3011 }
3012 let (_, accs) = groups.get_mut(&gk).unwrap();
3013 for (acc, (func, arg, _)) in accs.iter_mut().zip(ctx.aggs.iter()) {
3014 update_acc(ctx.view, ctx.vars, row, func, arg, acc)?;
3015 }
3016 return Ok(());
3017 }
3018 };
3019
3020 match op {
3021 PlanOp::ScanLabel { var, label } => {
3022 let ids = scan_ids(ctx.view, label.as_deref());
3023 let slot = ctx
3024 .vars
3025 .slot(var)
3026 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3027 for &id in &ids {
3028 let mut next = row.clone();
3029 next[slot] = Some(Cell::Node(id));
3030 group_stream(ctx, rest, &next, groups, key_order)?;
3031 }
3032 }
3033 PlanOp::ScanKey { var, key, label } => {
3034 let slot = ctx
3035 .vars
3036 .slot(var)
3037 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3038 if let Some(id) =
3039 resolve_scan_key_id(ctx.view, ctx.vars, row, key, label.as_deref(), ctx.params)?
3040 {
3041 let mut next = row.clone();
3042 next[slot] = Some(Cell::Node(id));
3043 group_stream(ctx, rest, &next, groups, key_order)?;
3044 }
3045 }
3046 PlanOp::IndexScan {
3047 var,
3048 label,
3049 field,
3050 value,
3051 } => {
3052 let ids = index_scan_ids(
3053 ctx.view,
3054 ctx.vars,
3055 row,
3056 label.as_deref(),
3057 field,
3058 value,
3059 ctx.params,
3060 )?;
3061 let slot = ctx
3062 .vars
3063 .slot(var)
3064 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3065 for &id in &ids {
3066 let mut next = row.clone();
3067 next[slot] = Some(Cell::Node(id));
3068 group_stream(ctx, rest, &next, groups, key_order)?;
3069 }
3070 }
3071 PlanOp::Expand {
3072 from,
3073 rel_var,
3074 etypes,
3075 dir,
3076 to,
3077 to_label,
3078 to_props,
3079 } => {
3080 let etypes = resolve_etypes(ctx.view, etypes);
3081 let exp_dir = map_dir(*dir);
3082 let to_slot = ctx
3083 .vars
3084 .slot(to)
3085 .ok_or_else(|| format!("unbound variable `{to}`"))?;
3086 let rel_slot = rel_var.as_ref().and_then(|rv| ctx.vars.slot(rv));
3087 let from_id = require_node(row, ctx.vars, from)?;
3088 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
3089 Some(Cell::Node(id)) => Some(*id),
3090 Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
3091 return Err(format!("variable `{to}` is not a node"))
3092 }
3093 None => None,
3094 };
3095 for e in expand(ctx.view, from_id, etypes.as_deref(), exp_dir) {
3096 if row_has_edge(row, &e) {
3097 continue;
3098 }
3099 let nbr = neighbor(from_id, &e, *dir);
3100 if !ctx.view.visible(nbr) {
3101 continue;
3102 }
3103 if let Some(want) = bound_to {
3104 if nbr != want {
3105 continue;
3106 }
3107 }
3108 if !node_matches(
3109 ctx.view,
3110 ctx.vars,
3111 row,
3112 nbr,
3113 to_label.as_deref(),
3114 to_props,
3115 ctx.params,
3116 )? {
3117 continue;
3118 }
3119 let mut next = row.clone();
3120 if let Some(slot) = rel_slot {
3121 next[slot] = Some(Cell::Rel(e));
3122 }
3123 if bound_to.is_none() {
3124 next[to_slot] = Some(Cell::Node(nbr));
3125 }
3126 group_stream(ctx, rest, &next, groups, key_order)?;
3127 }
3128 }
3129 PlanOp::Filter { expr } => {
3130 if eval_expr(ctx.view, ctx.vars, row, expr, ctx.params, 0)? {
3131 group_stream(ctx, rest, row, groups, key_order)?;
3132 }
3133 }
3134 PlanOp::LookupProps { var, props } => {
3135 let id = require_node(row, ctx.vars, var)?;
3136 if node_matches(ctx.view, ctx.vars, row, id, None, props, ctx.params)? {
3137 group_stream(ctx, rest, row, groups, key_order)?;
3138 }
3139 }
3140 PlanOp::JoinBound { var, label, props } => {
3141 let id = require_node(row, ctx.vars, var)?;
3142 if node_matches(
3143 ctx.view,
3144 ctx.vars,
3145 row,
3146 id,
3147 label.as_deref(),
3148 props,
3149 ctx.params,
3150 )? {
3151 group_stream(ctx, rest, row, groups, key_order)?;
3152 }
3153 }
3154 PlanOp::VarExpand {
3155 from,
3156 rel_var,
3157 etypes,
3158 dir,
3159 to,
3160 min,
3161 max,
3162 } => {
3163 let new_rows = exec_var_expand(
3164 ctx.view,
3165 ctx.vars,
3166 std::slice::from_ref(row),
3167 from,
3168 rel_var,
3169 etypes,
3170 *dir,
3171 to,
3172 *min,
3173 *max,
3174 )?;
3175 for nr in &new_rows {
3176 group_stream(ctx, rest, nr, groups, key_order)?;
3177 }
3178 }
3179 PlanOp::ShortestPath {
3180 from,
3181 rel_var,
3182 etypes,
3183 dir,
3184 to,
3185 max_hops,
3186 } => {
3187 let new_rows = exec_shortest_path(
3188 ctx.view,
3189 ctx.vars,
3190 std::slice::from_ref(row),
3191 from,
3192 rel_var,
3193 etypes,
3194 *dir,
3195 to,
3196 *max_hops,
3197 )?;
3198 for nr in &new_rows {
3199 group_stream(ctx, rest, nr, groups, key_order)?;
3200 }
3201 }
3202 PlanOp::Project { .. } => {
3204 return Err(
3205 "group executor: Project in producer slice — structurally malformed".to_string(),
3206 );
3207 }
3208 PlanOp::Distinct => {
3209 return Err(
3210 "group executor: Distinct in producer slice — structurally malformed".to_string(),
3211 );
3212 }
3213 PlanOp::OrderBy { .. } => {
3214 return Err(
3215 "group executor: OrderBy in producer slice — structurally malformed".to_string(),
3216 );
3217 }
3218 PlanOp::Skip(_) => {
3219 return Err(
3220 "group executor: Skip in producer slice — structurally malformed".to_string(),
3221 );
3222 }
3223 PlanOp::Limit(_) => {
3224 return Err(
3225 "group executor: Limit in producer slice — structurally malformed".to_string(),
3226 );
3227 }
3228 PlanOp::Aggregate { .. } => {
3229 return Err(
3230 "group executor: Aggregate in producer slice — structurally malformed".to_string(),
3231 );
3232 }
3233 PlanOp::GroupAggregate { .. } => {
3234 return Err(
3235 "group executor: nested GroupAggregate in producer slice — structurally malformed"
3236 .to_string(),
3237 );
3238 }
3239 PlanOp::With { .. } => {
3240 return Err(
3241 "group executor: With in producer slice — structurally malformed".to_string(),
3242 );
3243 }
3244 PlanOp::Unwind { .. } => {
3245 return Err(
3246 "group executor: Unwind in producer slice — structurally malformed".to_string(),
3247 );
3248 }
3249 PlanOp::LeftOuterApply { .. } => {
3250 return Err(
3251 "group executor: LeftOuterApply in producer slice — structurally malformed"
3252 .to_string(),
3253 );
3254 }
3255 }
3256 Ok(())
3257}
3258
3259fn pull_rows(
3273 ctx: &PullCtx<'_>,
3274 ops: &[PlanOp],
3275 row: &mut Row,
3276 result: &mut Vec<Vec<Option<Value>>>,
3277) -> Result<(), String> {
3278 if result.len() >= ctx.bound {
3279 return Ok(());
3280 }
3281 let (op, rest) = match ops.split_first() {
3282 Some(pair) => pair,
3283 None => {
3284 let mut cells = Vec::with_capacity(ctx.project_items.len());
3286 for item in ctx.project_items {
3287 cells.push(project_item(ctx.view, ctx.vars, row, item, ctx.params)?);
3288 }
3289 result.push(cells);
3290 return Ok(());
3291 }
3292 };
3293 match op {
3294 PlanOp::ScanLabel { var, label } => {
3295 let slot = ctx
3296 .vars
3297 .slot(var)
3298 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3299 let want_sym = label.as_deref().and_then(|l| ctx.view.syms.get(l));
3302 if label.is_some() && want_sym.is_none() {
3303 return Ok(());
3304 }
3305 let prev = row[slot].clone();
3307
3308 let fused_filter = rest.first().and_then(|next_op| {
3314 if let PlanOp::Filter {
3315 expr:
3316 Expr::Cmp {
3317 lhs:
3318 Operand::Prop {
3319 var: ref fv,
3320 field: ref f,
3321 },
3322 op: ref cmp_op_ref,
3323 rhs: Operand::Lit(ref lit),
3324 },
3325 } = *next_op
3326 {
3327 if fv == var {
3328 return Some((f.as_str(), cmp_op_ref, lit));
3329 }
3330 }
3331 None
3332 });
3333
3334 if let Some((field, cmp_op_ref, lit)) = fused_filter {
3335 #[cfg(test)]
3338 FUSED_SCAN_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3339 let col = ctx.view.props.column(field);
3340 let rest_after_filter = &rest[1..];
3341 for (i, &sym) in ctx.view.labels.iter().enumerate() {
3342 if result.len() >= ctx.bound {
3343 break;
3344 }
3345 if sym == u32::MAX {
3346 continue;
3347 }
3348 if let Some(ws) = want_sym {
3349 if sym != ws {
3350 continue;
3351 }
3352 }
3353 let id = i as u32;
3354 if !ctx.view.visible(id) {
3355 continue;
3356 }
3357 if let Some(v) = col.get(id) {
3358 if eval_cmp(cmp_op_ref, v, lit) {
3359 row[slot] = Some(Cell::Node(id));
3360 pull_rows(ctx, rest_after_filter, row, result)?;
3361 }
3362 }
3363 }
3364 } else {
3365 for (i, &sym) in ctx.view.labels.iter().enumerate() {
3368 if result.len() >= ctx.bound {
3369 break;
3370 }
3371 if sym == u32::MAX {
3373 continue;
3374 }
3375 if let Some(ws) = want_sym {
3377 if sym != ws {
3378 continue;
3379 }
3380 }
3381 let id = i as u32;
3382 if !ctx.view.visible(id) {
3383 continue;
3384 }
3385 row[slot] = Some(Cell::Node(id));
3386 pull_rows(ctx, rest, row, result)?;
3387 }
3388 }
3389 row[slot] = prev;
3395 }
3396 PlanOp::ScanKey { var, key, label } => {
3397 let slot = ctx
3398 .vars
3399 .slot(var)
3400 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3401 if let Some(id) =
3402 resolve_scan_key_id(ctx.view, ctx.vars, row, key, label.as_deref(), ctx.params)?
3403 {
3404 let prev = row[slot].clone();
3405 row[slot] = Some(Cell::Node(id));
3406 pull_rows(ctx, rest, row, result)?;
3407 row[slot] = prev;
3408 }
3409 }
3410 PlanOp::IndexScan {
3411 var,
3412 label,
3413 field,
3414 value,
3415 } => {
3416 let slot = ctx
3417 .vars
3418 .slot(var)
3419 .ok_or_else(|| format!("unbound variable `{var}`"))?;
3420 let ids = index_scan_ids(
3421 ctx.view,
3422 ctx.vars,
3423 row,
3424 label.as_deref(),
3425 field,
3426 value,
3427 ctx.params,
3428 )?;
3429 let prev = row[slot].clone();
3430 for id in ids {
3431 if result.len() >= ctx.bound {
3432 break;
3433 }
3434 row[slot] = Some(Cell::Node(id));
3435 pull_rows(ctx, rest, row, result)?;
3436 }
3437 row[slot] = prev;
3438 }
3439 PlanOp::Expand {
3440 from,
3441 rel_var,
3442 etypes,
3443 dir,
3444 to,
3445 to_label,
3446 to_props,
3447 } => {
3448 let etypes = resolve_etypes(ctx.view, etypes);
3449 let exp_dir = map_dir(*dir);
3450 let to_slot = ctx
3451 .vars
3452 .slot(to)
3453 .ok_or_else(|| format!("unbound variable `{to}`"))?;
3454 let rel_slot = rel_var.as_ref().and_then(|rv| ctx.vars.slot(rv));
3455 let from_id = require_node(row, ctx.vars, from)?;
3456 let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
3457 Some(Cell::Node(id)) => Some(*id),
3458 Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
3459 return Err(format!("variable `{to}` is not a node"))
3460 }
3461 None => None,
3462 };
3463 for e in expand(ctx.view, from_id, etypes.as_deref(), exp_dir) {
3464 if result.len() >= ctx.bound {
3465 break;
3466 }
3467 if row_has_edge(row, &e) {
3468 continue;
3469 }
3470 let nbr = neighbor(from_id, &e, *dir);
3471 if !ctx.view.visible(nbr) {
3472 continue;
3473 }
3474 if let Some(want) = bound_to {
3475 if nbr != want {
3476 continue;
3477 }
3478 }
3479 if !node_matches(
3480 ctx.view,
3481 ctx.vars,
3482 row,
3483 nbr,
3484 to_label.as_deref(),
3485 to_props,
3486 ctx.params,
3487 )? {
3488 continue;
3489 }
3490 let mut next = row.clone();
3491 if let Some(slot) = rel_slot {
3492 next[slot] = Some(Cell::Rel(e));
3493 }
3494 if bound_to.is_none() {
3495 next[to_slot] = Some(Cell::Node(nbr));
3496 }
3497 #[cfg(test)]
3498 record_expand_row();
3499 pull_rows(ctx, rest, &mut next, result)?;
3500 }
3501 }
3502 PlanOp::Filter { expr } => {
3503 if eval_expr(ctx.view, ctx.vars, row, expr, ctx.params, 0)? {
3504 pull_rows(ctx, rest, row, result)?;
3505 }
3506 }
3507 PlanOp::LookupProps { var, props } => {
3508 let id = require_node(row, ctx.vars, var)?;
3509 if node_matches(ctx.view, ctx.vars, row, id, None, props, ctx.params)? {
3510 pull_rows(ctx, rest, row, result)?;
3511 }
3512 }
3513 PlanOp::JoinBound { var, label, props } => {
3514 let id = require_node(row, ctx.vars, var)?;
3515 if node_matches(
3516 ctx.view,
3517 ctx.vars,
3518 row,
3519 id,
3520 label.as_deref(),
3521 props,
3522 ctx.params,
3523 )? {
3524 pull_rows(ctx, rest, row, result)?;
3525 }
3526 }
3527 PlanOp::Project { .. } => {
3532 return Err(
3533 "pull executor: Project reached pull_rows — plan is structurally malformed"
3534 .to_string(),
3535 );
3536 }
3537 PlanOp::Distinct => {
3538 return Err(
3539 "pull executor: Distinct reached pull_rows — DISTINCT queries must use \
3540 the staged path (row_bound returns None)"
3541 .to_string(),
3542 );
3543 }
3544 PlanOp::OrderBy { .. } => {
3545 return Err(
3546 "pull executor: OrderBy reached pull_rows — queries with ORDER BY \
3547 must use the staged path (row_bound returns None)"
3548 .to_string(),
3549 );
3550 }
3551 PlanOp::Skip(_) => {
3552 return Err(
3553 "pull executor: Skip reached pull_rows — Skip must appear after Project"
3554 .to_string(),
3555 );
3556 }
3557 PlanOp::Limit(_) => {
3558 return Err(
3559 "pull executor: Limit reached pull_rows — Limit must appear after Project"
3560 .to_string(),
3561 );
3562 }
3563 PlanOp::Aggregate { .. } => {
3564 return Err(
3565 "pull executor: Aggregate reached pull_rows — aggregate plans must use \
3566 the execute_aggregate path (routed before pull in execute_inner)"
3567 .to_string(),
3568 );
3569 }
3570 PlanOp::VarExpand { .. } => {
3575 return Err(
3576 "pull executor: VarExpand reached pull_rows — variable-length path \
3577 plans must use the staged path (row_bound returns None)"
3578 .to_string(),
3579 );
3580 }
3581 PlanOp::ShortestPath { .. } => {
3582 return Err(
3583 "pull executor: ShortestPath reached pull_rows — shortestPath plans \
3584 must use the staged path (row_bound returns None)"
3585 .to_string(),
3586 );
3587 }
3588 PlanOp::GroupAggregate { .. } => {
3589 return Err(
3590 "pull executor: GroupAggregate reached pull_rows — grouped aggregate plans \
3591 must use the execute_group_aggregate path (routed before pull in execute_inner)"
3592 .to_string(),
3593 );
3594 }
3595 PlanOp::With { .. } => {
3596 return Err(
3597 "pull executor: With reached pull_rows — pipeline plans must use the staged path \
3598 (row_bound returns None for plans containing With)"
3599 .to_string(),
3600 );
3601 }
3602 PlanOp::Unwind { .. } => {
3603 return Err(
3604 "pull executor: Unwind reached pull_rows — pipeline plans must use the staged path \
3605 (row_bound returns None for plans containing Unwind)"
3606 .to_string(),
3607 );
3608 }
3609 PlanOp::LeftOuterApply { .. } => {
3610 return Err(
3611 "pull executor: LeftOuterApply reached pull_rows — OPTIONAL MATCH plans must use \
3612 the staged path (row_bound returns None for plans containing LeftOuterApply)"
3613 .to_string(),
3614 );
3615 }
3616 }
3617 Ok(())
3618}
3619
3620fn eval_expr(
3621 view: &GraphView,
3622 vars: &VarTable,
3623 row: &Row,
3624 expr: &Expr,
3625 params: &Params,
3626 depth: u32,
3627) -> Result<bool, String> {
3628 if depth > 256 {
3629 return Err("expression nesting too deep".into());
3630 }
3631 match expr {
3632 Expr::And(lhs, rhs) => {
3633 let l = eval_expr(view, vars, row, lhs, params, depth + 1)?;
3634 let r = eval_expr(view, vars, row, rhs, params, depth + 1)?;
3635 Ok(l && r)
3636 }
3637 Expr::Or(lhs, rhs) => {
3638 let l = eval_expr(view, vars, row, lhs, params, depth + 1)?;
3639 let r = eval_expr(view, vars, row, rhs, params, depth + 1)?;
3640 Ok(l || r)
3641 }
3642 Expr::Not(inner) => Ok(!eval_expr(view, vars, row, inner, params, depth + 1)?),
3643 Expr::Cmp { lhs, op, rhs } => {
3644 let l = resolve_operand(view, vars, row, lhs, params)?;
3645 let r = resolve_operand(view, vars, row, rhs, params)?;
3646 match (l, r) {
3647 (Some(a), Some(b)) => Ok(eval_cmp(op, &a, &b)),
3648 _ => Ok(false),
3649 }
3650 }
3651 Expr::Truthy(op) => {
3652 let val = resolve_operand(view, vars, row, op, params)?;
3653 Ok(match val {
3654 None => false,
3655 Some(Value::Bool(b)) => b,
3656 Some(Value::Int(n)) => n != 0,
3657 Some(Value::Float(f)) => f != 0.0,
3658 Some(Value::Str(s)) => !s.is_empty(),
3659 Some(Value::List(v)) => !v.is_empty(),
3660 Some(Value::Map(m)) => !m.is_empty(),
3661 })
3662 }
3663 Expr::IsNull(op) => {
3664 let val = resolve_operand(view, vars, row, op, params)?;
3665 Ok(val.is_none())
3666 }
3667 Expr::IsNotNull(op) => {
3668 let val = resolve_operand(view, vars, row, op, params)?;
3669 Ok(val.is_some())
3670 }
3671 Expr::In { expr, list } => eval_in(view, vars, row, expr, list, params),
3672 }
3673}
3674
3675fn eval_in(
3676 view: &GraphView,
3677 vars: &VarTable,
3678 row: &Row,
3679 expr: &Operand,
3680 list: &[Operand],
3681 params: &Params,
3682) -> Result<bool, String> {
3683 let Some(needle) = resolve_operand(view, vars, row, expr, params)? else {
3684 return Ok(false);
3685 };
3686 for item_op in list {
3687 match resolve_operand(view, vars, row, item_op, params)? {
3688 None => {}
3689 Some(Value::List(items)) => {
3690 for item in items {
3691 if crate::filter::eval_cmp(&crate::filter::CmpOp::Eq, &needle, &item) {
3692 return Ok(true);
3693 }
3694 }
3695 }
3696 Some(item) if crate::filter::eval_cmp(&crate::filter::CmpOp::Eq, &needle, &item) => {
3697 return Ok(true);
3698 }
3699 Some(_) => {}
3700 }
3701 }
3702 Ok(false)
3703}
3704
3705fn exec_distinct(table: &mut Projected) -> Result<(), String> {
3707 let cap = max_intermediate_rows();
3708 let mut seen: BTreeSet<Vec<Option<ValueKey>>> = BTreeSet::new();
3709 let mut out = Vec::with_capacity(table.rows.len().min(cap));
3710 for row in table.rows.drain(..) {
3711 let key: Vec<Option<ValueKey>> = row
3712 .iter()
3713 .map(|cell| cell.as_ref().and_then(group_key_normalize))
3714 .collect();
3715 if seen.insert(key) {
3716 if out.len() >= cap {
3717 return Err(row_cap_err(cap));
3718 }
3719 out.push(row);
3720 }
3721 }
3722 table.rows = out;
3723 Ok(())
3724}
3725
3726fn column_name(item: &RetItem) -> String {
3727 if let Some(alias) = &item.alias {
3728 return alias.clone();
3729 }
3730 match &item.value {
3731 RetVal::Var(v) => v.clone(),
3732 RetVal::Prop { var, field } => format!("{var}.{field}"),
3733 RetVal::Agg { func, arg } => {
3736 use crate::cypher::ast::AggArg;
3737 let f = match func {
3738 AggFunc::Count => "COUNT",
3739 AggFunc::Sum => "SUM",
3740 AggFunc::Avg => "AVG",
3741 AggFunc::Min => "MIN",
3742 AggFunc::Max => "MAX",
3743 AggFunc::Collect => "COLLECT",
3744 };
3745 let a = match arg {
3746 AggArg::Star => "*".to_string(),
3747 AggArg::Var(v) => v.clone(),
3748 AggArg::Prop { var, field } => format!("{var}.{field}"),
3749 };
3750 format!("{f}({a})")
3751 }
3752 RetVal::FuncCall { name, args } => {
3753 let arg_strs: Vec<String> = args
3754 .iter()
3755 .map(|a| match a {
3756 Operand::Var(v) => v.clone(),
3757 Operand::Prop { var, field } => format!("{var}.{field}"),
3758 Operand::Lit(_) => "<lit>".to_string(),
3759 Operand::Param(p) => format!("${p}"),
3760 Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
3761 Operand::BinArith { .. } => "<arith>".to_string(),
3762 Operand::Case { .. } => "<case>".to_string(),
3763 })
3764 .collect();
3765 format!("{name}({})", arg_strs.join(", "))
3766 }
3767 RetVal::ScalarExpr(_) => "<expr>".to_string(),
3768 }
3769}
3770
3771fn exec_project(
3772 view: &GraphView,
3773 vars: &VarTable,
3774 rows: &[Row],
3775 items: &[RetItem],
3776 params: &Params,
3777) -> Result<Projected, String> {
3778 let columns: Vec<String> = items.iter().map(column_name).collect();
3779 let mut out_rows = Vec::with_capacity(rows.len());
3780 for row in rows {
3781 let mut cells = Vec::with_capacity(items.len());
3782 for item in items {
3783 cells.push(project_item(view, vars, row, item, params)?);
3784 }
3785 out_rows.push(cells);
3786 }
3787 Ok(Projected {
3788 columns,
3789 rows: out_rows,
3790 })
3791}
3792
3793fn project_item(
3794 view: &GraphView,
3795 vars: &VarTable,
3796 row: &Row,
3797 item: &RetItem,
3798 params: &Params,
3799) -> Result<Option<Value>, String> {
3800 match &item.value {
3801 RetVal::Var(v) => {
3802 let slot = vars.slot(v).ok_or_else(|| format!("unbound variable `{v}`"))?;
3804 match row.get(slot).and_then(|c| c.as_ref()) {
3805 None => Ok(None),
3807 Some(Cell::Node(id)) => match view.ids.key_of(*id) {
3808 Some(key) => Ok(Some(Value::Str(key.to_owned()))),
3809 None => Err(format!("unknown node id {id}")),
3810 },
3811 Some(Cell::Scalar(val)) => Ok(Some(val.clone())),
3813 Some(Cell::Rel(_)) => Err(format!(
3814 "variable `{v}` is a relationship; return its properties ({v}.field) instead"
3815 )),
3816 Some(Cell::Path(hops)) => Ok(Some(Value::Int(*hops as i64))),
3817 }
3818 }
3819 RetVal::Prop { var, field } => resolve_prop(view, vars, row, var, field),
3820 RetVal::Agg { .. } => Err(
3823 "project_item: Agg variant reached exec_project — aggregate plans must not contain Project"
3824 .to_string(),
3825 ),
3826 RetVal::FuncCall { name, args } => eval_func(name, args, view, vars, row, params),
3827 RetVal::ScalarExpr(op) => resolve_operand(view, vars, row, op, params),
3828 }
3829}
3830
3831fn order_column(item: &OrderItem) -> String {
3832 match &item.target {
3833 OrderTarget::Alias(name) | OrderTarget::Var(name) => name.clone(),
3834 OrderTarget::Prop { var, field } => format!("{var}.{field}"),
3835 }
3836}
3837
3838fn exec_order_by(table: &mut Projected, items: &[OrderItem]) -> Result<(), String> {
3839 let mut keys = Vec::with_capacity(items.len());
3840 for item in items {
3841 let name = order_column(item);
3842 let idx = table
3843 .columns
3844 .iter()
3845 .position(|c| c == &name)
3846 .ok_or_else(|| format!("ORDER BY target `{name}` is not a projected column"))?;
3847 keys.push((idx, item.descending));
3848 }
3849 table.rows.sort_by(|a, b| {
3850 for &(idx, desc) in &keys {
3851 let c = cmp_optional(
3852 a.get(idx).and_then(|x| x.as_ref()),
3853 b.get(idx).and_then(|x| x.as_ref()),
3854 desc,
3855 );
3856 if c != std::cmp::Ordering::Equal {
3857 return c;
3858 }
3859 }
3860 std::cmp::Ordering::Equal
3861 });
3862 Ok(())
3863}
3864
3865fn resolve_ls(ls: &LimitSkip, params: &Params) -> Result<u64, String> {
3870 match ls {
3871 LimitSkip::Exact(n) => Ok(*n),
3872 LimitSkip::Param(name) => {
3873 let val = params
3874 .0
3875 .get(name)
3876 .ok_or_else(|| format!("missing parameter `{name}` (used in LIMIT/SKIP)"))?;
3877 match val {
3878 Value::Int(i) if *i >= 0 => Ok(*i as u64),
3879 Value::Int(i) => Err(format!(
3880 "LIMIT/SKIP parameter `{name}` must be a non-negative integer, got {i}"
3881 )),
3882 other => Err(format!(
3883 "LIMIT/SKIP parameter `{name}` must be an integer, got {other:?}"
3884 )),
3885 }
3886 }
3887 }
3888}
3889
3890fn apply_skip<T>(rows: &mut Vec<T>, n: u64) {
3891 let n = usize::try_from(n).unwrap_or(usize::MAX);
3892 if n >= rows.len() {
3893 rows.clear();
3894 } else {
3895 rows.drain(0..n);
3896 }
3897}
3898
3899fn apply_limit<T>(rows: &mut Vec<T>, n: u64) {
3900 let n = usize::try_from(n).unwrap_or(usize::MAX);
3901 rows.truncate(n);
3902}
3903
3904#[cfg(test)]
3905mod tests {
3906 use super::{execute, resolve_operand, Params, Row, VarTable};
3907 use crate::cypher::ast::{
3908 ArithOp, LimitSkip, Operand, OrderItem, OrderTarget, RetItem, RetVal,
3909 };
3910 use crate::cypher::plan::{plan, PlanOp};
3911 use crate::cypher::{lex, parse, RelDir};
3912 use crate::result::ResultSet;
3913 use crate::view::GraphView;
3914 use core_storage::v8::seam::{ColumnsView, EdgePropsView, TopologyView};
3915 use core_storage::{ColumnStore, EdgeProps, IdMap, Interner, Topology, Value};
3916 use proptest::prelude::*;
3917 use std::collections::BTreeMap;
3918
3919 struct Fx {
3920 ids: IdMap,
3921 syms: Interner,
3922 labels: Vec<u32>,
3923 props: ColumnStore,
3924 topo: Topology,
3925 eprops: EdgeProps,
3926 }
3927
3928 impl Fx {
3929 fn new() -> Self {
3930 Fx {
3931 ids: IdMap::new(),
3932 syms: Interner::new(),
3933 labels: vec![],
3934 props: ColumnStore::new(),
3935 topo: Topology::new(),
3936 eprops: EdgeProps::new(),
3937 }
3938 }
3939
3940 fn add(&mut self, label: &str, key: &str, props: Vec<(&str, Value)>) -> u32 {
3941 let id = self.ids.get_or_insert(key);
3942 let sym = self.syms.intern(label);
3943 self.labels.resize(id as usize + 1, u32::MAX);
3944 self.labels[id as usize] = sym;
3945 for (f, v) in props {
3946 self.props.set(id, f, v);
3947 }
3948 id
3949 }
3950
3951 fn edge(&mut self, etype: &str, src: u32, dst: u32, props: Vec<(&str, Value)>) {
3952 let et = self.syms.intern(etype);
3953 self.topo.add_edge(et, src, dst);
3954 for (f, v) in props {
3955 self.eprops.set(et, src, dst, f, v);
3956 }
3957 }
3958
3959 fn view(&self) -> GraphView<'_> {
3960 GraphView {
3961 ids: &self.ids,
3962 syms: &self.syms,
3963 labels: &self.labels,
3964 props: ColumnsView::owned(&self.props),
3965 topo: TopologyView::owned(&self.topo),
3966 edge_props: EdgePropsView::owned(&self.eprops),
3967 mask: None,
3968 prop_index: None,
3969 }
3970 }
3971
3972 fn view_indexed<'a>(
3973 &'a self,
3974 index: &'a core_storage::property_index::PropertyIndex,
3975 ) -> GraphView<'a> {
3976 GraphView {
3977 prop_index: Some(index),
3978 ..self.view()
3979 }
3980 }
3981 }
3982
3983 fn compile(src: &str) -> Vec<PlanOp> {
3984 plan(&parse(&lex(src).expect("lex")).expect("parse")).expect("plan")
3985 }
3986
3987 fn run(
3988 view: &GraphView,
3989 src: &str,
3990 params: &BTreeMap<String, Value>,
3991 ) -> Result<ResultSet, String> {
3992 execute(view, &compile(src), &Params(params))
3993 }
3994
3995 fn s(v: &str) -> Value {
3996 Value::Str(v.into())
3997 }
3998
3999 fn f(v: f64) -> Value {
4000 Value::Float(v)
4001 }
4002
4003 fn i(v: i64) -> Value {
4004 Value::Int(v)
4005 }
4006
4007 fn rows_of(rs: &ResultSet) -> Vec<Vec<Option<Value>>> {
4008 (0..rs.len()).map(|i| rs.row(i).to_vec()).collect()
4009 }
4010
4011 fn col(rs: &ResultSet, name: &str) -> Vec<Option<Value>> {
4012 (0..rs.len()).map(|i| rs.get(i, name).cloned()).collect()
4013 }
4014
4015 fn hop_graph() -> Fx {
4016 let mut fx = Fx::new();
4017 let ada = fx.add("Person", "ada", vec![]);
4018 let bob = fx.add("Person", "bob", vec![]);
4019 let cam = fx.add("Person", "cam", vec![]);
4020 let acme = fx.add("Company", "acme", vec![]);
4021 fx.edge("KNOWS", ada, bob, vec![]);
4022 fx.edge("KNOWS", ada, cam, vec![]);
4023 fx.edge("KNOWS", bob, cam, vec![]);
4024 fx.edge("LIKES", ada, acme, vec![]);
4025 fx
4026 }
4027
4028 fn undirected_graph() -> Fx {
4029 let mut fx = Fx::new();
4030 let a = fx.add("N", "a", vec![]);
4031 let b = fx.add("N", "b", vec![]);
4032 fx.edge("T", a, b, vec![("w", i(42))]);
4033 fx
4034 }
4035
4036 fn triangle() -> Fx {
4037 let mut fx = Fx::new();
4038 let a = fx.add("N", "a", vec![]);
4039 let b = fx.add("N", "b", vec![]);
4040 let c = fx.add("N", "c", vec![]);
4041 fx.edge("T", a, b, vec![("eid", i(1))]);
4042 fx.edge("T", b, c, vec![("eid", i(2))]);
4043 fx.edge("T", c, a, vec![("eid", i(3))]);
4044 fx
4045 }
4046
4047 fn single_edge() -> (Fx, u32, u32) {
4048 let mut fx = Fx::new();
4049 let a = fx.add("N", "a", vec![]);
4050 let b = fx.add("N", "b", vec![]);
4051 fx.edge("T", a, b, vec![]);
4052 (fx, a, b)
4053 }
4054
4055 fn dogfood_graph() -> Fx {
4057 let mut fx = Fx::new();
4058 let t1 = fx.add("Talent", "t1", vec![("id", s("t1"))]);
4059 let acme = fx.add("Company", "acme", vec![]);
4060 let beta = fx.add("Company", "beta", vec![]);
4061 let gamma = fx.add("Company", "gamma", vec![]);
4062 let delta = fx.add("Company", "delta", vec![]);
4063 let echo = fx.add("Company", "echo", vec![]);
4064 let foxtrot = fx.add("Company", "foxtrot", vec![]);
4065 let zeta = fx.add("Company", "zeta", vec![]);
4066 fx.edge("INDUSTRY_ALIGNMENT", acme, t1, vec![("score", f(0.9))]);
4067 fx.edge("SPECIALTY_MATCH", acme, t1, vec![("score", f(0.8))]);
4068 fx.edge("INDUSTRY_ALIGNMENT", beta, t1, vec![("score", f(0.6))]);
4069 fx.edge("SPECIALTY_MATCH", beta, t1, vec![("score", f(0.7))]);
4070 fx.edge("INDUSTRY_ALIGNMENT", gamma, t1, vec![("score", f(0.4))]);
4071 fx.edge("SPECIALTY_MATCH", gamma, t1, vec![("score", f(0.9))]);
4072 fx.edge("INDUSTRY_ALIGNMENT", delta, t1, vec![("score", f(0.8))]);
4073 fx.edge("SPECIALTY_MATCH", delta, t1, vec![("score", f(0.3))]);
4074 fx.edge("INDUSTRY_ALIGNMENT", echo, t1, vec![("score", f(0.5))]);
4075 fx.edge("SPECIALTY_MATCH", echo, t1, vec![("score", f(0.5))]);
4076 fx.edge("INDUSTRY_ALIGNMENT", foxtrot, t1, vec![("score", f(0.95))]);
4077 fx.edge("INDUSTRY_ALIGNMENT", zeta, t1, vec![("score", f(0.9))]);
4078 fx.edge("SPECIALTY_MATCH", zeta, t1, vec![("score", f(0.6))]);
4079 fx
4080 }
4081
4082 const DOGFOOD: &str = "\
4083MATCH (t:Talent {id: $tid}) \
4084MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
4085MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
4086WHERE i.score >= 0.5 AND s.score >= 0.5 \
4087RETURN c, i.score AS industry, s.score AS specialty \
4088ORDER BY industry DESC, specialty DESC \
4089LIMIT 10";
4090
4091 fn tid_params() -> BTreeMap<String, Value> {
4092 let mut p = BTreeMap::new();
4093 p.insert("tid".into(), s("t1"));
4094 p
4095 }
4096
4097 #[test]
4098 fn single_hop_match_label_and_etype_filters() {
4099 let fx = hop_graph();
4100 let v = fx.view();
4101 let rs = run(
4102 &v,
4103 "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b",
4104 &BTreeMap::new(),
4105 )
4106 .expect("single-hop");
4107 assert_eq!(rs.columns(), &["a".to_string(), "b".to_string()]);
4108 assert_eq!(
4109 rows_of(&rs),
4110 vec![
4111 vec![Some(s("ada")), Some(s("bob"))],
4112 vec![Some(s("ada")), Some(s("cam"))],
4113 vec![Some(s("bob")), Some(s("cam"))],
4114 ]
4115 );
4116 let likes = run(
4118 &v,
4119 "MATCH (a:Person)-[:LIKES]->(b:Company) RETURN a, b",
4120 &BTreeMap::new(),
4121 )
4122 .unwrap();
4123 assert_eq!(rows_of(&likes), vec![vec![Some(s("ada")), Some(s("acme"))]]);
4124 let no_combo = run(
4125 &v,
4126 "MATCH (a:Person)-[:KNOWS]->(b:Company) RETURN a, b",
4127 &BTreeMap::new(),
4128 )
4129 .unwrap();
4130 assert!(no_combo.is_empty());
4131 }
4132
4133 #[test]
4134 fn undirected_match_finds_both_orientations_and_binds_true_triple() {
4135 let fx = undirected_graph();
4136 let v = fx.view();
4137 let rs =
4140 run(&v, "MATCH (x)-[r:T]-(y) RETURN x, y, r.w", &BTreeMap::new()).expect("undirected");
4141 assert_eq!(
4142 rows_of(&rs),
4143 vec![
4144 vec![Some(s("a")), Some(s("b")), Some(i(42))],
4145 vec![Some(s("b")), Some(s("a")), Some(i(42))],
4146 ]
4147 );
4148
4149 let left = run(
4150 &v,
4151 "MATCH (y)<-[r:T]-(x) RETURN x, y, r.w",
4152 &BTreeMap::new(),
4153 )
4154 .unwrap();
4155 assert_eq!(
4156 rows_of(&left),
4157 vec![vec![Some(s("a")), Some(s("b")), Some(i(42))]]
4158 );
4159 }
4160
4161 #[test]
4162 fn relationship_uniqueness_triangle_and_two_hop_cycle() {
4163 let tri = triangle();
4164 let v = tri.view();
4165 let rs = run(
4166 &v,
4167 "MATCH (x)-[r1:T]->(y)-[r2:T]->(z) RETURN x, y, z, r1.eid, r2.eid",
4168 &BTreeMap::new(),
4169 )
4170 .expect("triangle 2-hop");
4171 assert_eq!(
4172 rows_of(&rs),
4173 vec![
4174 vec![
4175 Some(s("a")),
4176 Some(s("b")),
4177 Some(s("c")),
4178 Some(i(1)),
4179 Some(i(2))
4180 ],
4181 vec![
4182 Some(s("b")),
4183 Some(s("c")),
4184 Some(s("a")),
4185 Some(i(2)),
4186 Some(i(3))
4187 ],
4188 vec![
4189 Some(s("c")),
4190 Some(s("a")),
4191 Some(s("b")),
4192 Some(i(3)),
4193 Some(i(1))
4194 ],
4195 ]
4196 );
4197 for row in rows_of(&rs) {
4198 assert_ne!(row[3], row[4], "r1 must never bind the same edge as r2");
4199 }
4200
4201 let (mut one, a, b) = single_edge();
4202 let v = one.view();
4203 let cycle = run(
4204 &v,
4205 "MATCH (x)-[:T]->(y)-[:T]->(x) RETURN x",
4206 &BTreeMap::new(),
4207 )
4208 .expect("2-hop cycle");
4209 assert!(
4210 cycle.is_empty(),
4211 "single directed edge cannot close a 2-hop cycle"
4212 );
4213
4214 let undirected_cycle = run(&v, "MATCH (x)-[:T]-(y)-[:T]-(x) RETURN x", &BTreeMap::new())
4216 .expect("undirected uniqueness");
4217 assert!(
4218 undirected_cycle.is_empty(),
4219 "relationship uniqueness must reject walking the same triple back"
4220 );
4221
4222 one.edge("T", b, a, vec![]);
4223 let v = one.view();
4224 let with_recip = run(
4225 &v,
4226 "MATCH (x)-[:T]->(y)-[:T]->(x) RETURN x",
4227 &BTreeMap::new(),
4228 )
4229 .unwrap();
4230 assert_eq!(col(&with_recip, "x"), vec![Some(s("a")), Some(s("b"))]);
4231 }
4232
4233 #[test]
4234 fn multi_match_join_bound_and_bound_destination_expand() {
4235 let fx = dogfood_graph();
4236 let v = fx.view();
4237 let rs = run(
4240 &v,
4241 "MATCH (t:Talent {id: $tid}) \
4242 MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
4243 MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
4244 RETURN c",
4245 &tid_params(),
4246 )
4247 .expect("join + bound dest");
4248 assert_eq!(
4249 col(&rs, "c"),
4250 vec![
4251 Some(s("acme")),
4252 Some(s("beta")),
4253 Some(s("gamma")),
4254 Some(s("delta")),
4255 Some(s("echo")),
4256 Some(s("zeta")),
4257 ]
4258 );
4259
4260 let keep = run(&v, "MATCH (c:Company) MATCH (c) RETURN c", &BTreeMap::new()).unwrap();
4262 assert_eq!(keep.len(), 7);
4263 let drop = run(
4265 &v,
4266 "MATCH (c:Company) MATCH (c:Talent) RETURN c",
4267 &BTreeMap::new(),
4268 )
4269 .unwrap();
4270 assert!(drop.is_empty());
4271 }
4272
4273 #[test]
4274 fn scan_key_exec_does_not_use_label_scan() {
4275 let mut fx = Fx::new();
4276 fx.add("Person", "p1", vec![]);
4277 fx.add("Person", "p2", vec![]);
4278 fx.add("Person", "p3", vec![]);
4279 fx.add("Company", "c1", vec![]);
4280 let v = fx.view();
4281 let params = BTreeMap::new();
4282
4283 let fires_before = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
4284 let rs = run(&v, "MATCH (n:Person {id: 'p2'}) RETURN n", ¶ms).expect("scan key");
4285 let fires_after = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
4286 assert!(
4287 fires_after > fires_before,
4288 "SCAN_KEY_FIRES must increment; before={fires_before} after={fires_after}"
4289 );
4290 assert_eq!(rows_of(&rs), vec![vec![Some(s("p2"))]]);
4291
4292 let miss = run(&v, "MATCH (n:Person {id: 'nope'}) RETURN n", ¶ms).unwrap();
4293 assert!(
4294 miss.is_empty(),
4295 "missing key must be zero rows, not an error"
4296 );
4297
4298 let wrong = run(&v, "MATCH (n:Person {id: 'c1'}) RETURN n", ¶ms).unwrap();
4299 assert!(
4300 wrong.is_empty(),
4301 "wrong label must be zero rows, not an error"
4302 );
4303 }
4304
4305 #[test]
4306 fn rel_var_edge_prop_filter() {
4307 let mut fx = Fx::new();
4308 let a = fx.add("N", "a", vec![]);
4309 let b = fx.add("N", "b", vec![]);
4310 let c = fx.add("N", "c", vec![]);
4311 fx.edge("T", a, b, vec![("w", f(0.7))]);
4312 fx.edge("T", a, c, vec![("w", f(0.3))]);
4313 let v = fx.view();
4314 let rs = run(
4315 &v,
4316 "MATCH (x)-[r:T]->(y) WHERE r.w >= 0.5 RETURN y, r.w",
4317 &BTreeMap::new(),
4318 )
4319 .expect("edge-prop filter");
4320 assert_eq!(rows_of(&rs), vec![vec![Some(s("b")), Some(f(0.7))]]);
4321 let fail = run(
4322 &v,
4323 "MATCH (x)-[r:T]->(y) WHERE r.w >= 0.8 RETURN y",
4324 &BTreeMap::new(),
4325 )
4326 .unwrap();
4327 assert!(fail.is_empty());
4328 }
4329
4330 #[test]
4331 fn params_present_resolve_missing_is_err_before_rows() {
4332 let fx = dogfood_graph();
4333 let v = fx.view();
4334 let hit =
4335 run(&v, "MATCH (t:Talent {id: $tid}) RETURN t", &tid_params()).expect("present param");
4336 assert_eq!(col(&hit, "t"), vec![Some(s("t1"))]);
4337
4338 let err = run(
4340 &v,
4341 "MATCH (t:NoSuchLabel {id: $tid}) RETURN t",
4342 &BTreeMap::new(),
4343 )
4344 .expect_err("missing param must be Err, not Ok(empty)");
4345 assert!(
4346 err.contains("tid")
4347 && (err.contains("param") || err.contains("Param") || err.contains("missing")),
4348 "missing-param error must name the parameter, got: {err}"
4349 );
4350
4351 let err = run(
4352 &v,
4353 "MATCH (t:Talent) WHERE t.id = $tid RETURN t",
4354 &BTreeMap::new(),
4355 )
4356 .expect_err("missing WHERE param");
4357 assert!(err.contains("tid"), "got: {err}");
4358 }
4359
4360 #[test]
4361 fn order_by_none_last_then_skip_limit() {
4362 let mut fx = Fx::new();
4363 fx.add("Person", "ada", vec![("age", i(30))]);
4364 fx.add("Person", "bob", vec![]); fx.add("Person", "cam", vec![("age", i(10))]);
4366 fx.add("Person", "dan", vec![("age", i(20))]);
4367 let v = fx.view();
4368
4369 let asc = run(
4370 &v,
4371 "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age",
4372 &BTreeMap::new(),
4373 )
4374 .unwrap();
4375 assert_eq!(
4376 rows_of(&asc),
4377 vec![
4378 vec![Some(s("cam")), Some(i(10))],
4379 vec![Some(s("dan")), Some(i(20))],
4380 vec![Some(s("ada")), Some(i(30))],
4381 vec![Some(s("bob")), None],
4382 ]
4383 );
4384
4385 let desc = run(
4386 &v,
4387 "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age DESC",
4388 &BTreeMap::new(),
4389 )
4390 .unwrap();
4391 assert_eq!(
4392 rows_of(&desc),
4393 vec![
4394 vec![Some(s("ada")), Some(i(30))],
4395 vec![Some(s("dan")), Some(i(20))],
4396 vec![Some(s("cam")), Some(i(10))],
4397 vec![Some(s("bob")), None],
4398 ]
4399 );
4400
4401 let skip_lim = run(
4402 &v,
4403 "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age SKIP 1 LIMIT 2",
4404 &BTreeMap::new(),
4405 )
4406 .unwrap();
4407 assert_eq!(
4408 rows_of(&skip_lim),
4409 vec![
4410 vec![Some(s("dan")), Some(i(20))],
4411 vec![Some(s("ada")), Some(i(30))],
4412 ]
4413 );
4414
4415 let desc_sl = run(
4416 &v,
4417 "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age DESC SKIP 1 LIMIT 2",
4418 &BTreeMap::new(),
4419 )
4420 .unwrap();
4421 assert_eq!(
4422 rows_of(&desc_sl),
4423 vec![
4424 vec![Some(s("dan")), Some(i(20))],
4425 vec![Some(s("cam")), Some(i(10))],
4426 ]
4427 );
4428 }
4429
4430 #[test]
4431 fn unknown_label_and_etype_are_ok_empty() {
4432 let fx = hop_graph();
4433 let v = fx.view();
4434 let lab = run(&v, "MATCH (x:Nope) RETURN x", &BTreeMap::new()).expect("unknown label");
4435 assert!(lab.is_empty());
4436 let et = run(
4437 &v,
4438 "MATCH (a)-[:NO_SUCH_ETYPE]->(b) RETURN a",
4439 &BTreeMap::new(),
4440 )
4441 .expect("unknown etype");
4442 assert!(et.is_empty());
4443 }
4444
4445 #[test]
4446 fn execute_is_deterministic() {
4447 let fx = dogfood_graph();
4448 let v = fx.view();
4449 let p = tid_params();
4450 let a = run(&v, DOGFOOD, &p).expect("first");
4451 let b = run(&v, DOGFOOD, &p).expect("second");
4452 assert_eq!(a, b);
4453 let hop = hop_graph();
4454 let hv = hop.view();
4455 let q = "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b";
4456 assert_eq!(run(&hv, q, &BTreeMap::new()), run(&hv, q, &BTreeMap::new()));
4457 }
4458
4459 #[test]
4460 fn dogfood_pipeline_exact_rows() {
4461 let fx = dogfood_graph();
4462 let v = fx.view();
4463 let rs = run(&v, DOGFOOD, &tid_params()).expect("dogfood");
4464 assert_eq!(
4465 rs.columns(),
4466 &[
4467 "c".to_string(),
4468 "industry".to_string(),
4469 "specialty".to_string()
4470 ]
4471 );
4472 assert_eq!(
4474 rows_of(&rs),
4475 vec![
4476 vec![Some(s("acme")), Some(f(0.9)), Some(f(0.8))],
4477 vec![Some(s("zeta")), Some(f(0.9)), Some(f(0.6))],
4478 vec![Some(s("beta")), Some(f(0.6)), Some(f(0.7))],
4479 vec![Some(s("echo")), Some(f(0.5)), Some(f(0.5))],
4480 ]
4481 );
4482 }
4483
4484 #[test]
4485 fn unknown_var_in_op_is_err_not_panic() {
4486 let fx = hop_graph();
4487 let v = fx.view();
4488 let plan = vec![
4489 PlanOp::ScanLabel {
4490 var: "a".into(),
4491 label: None,
4492 },
4493 PlanOp::Expand {
4494 from: "zzz".into(),
4495 rel_var: Some("r".into()),
4496 etypes: vec![],
4497 dir: RelDir::Right,
4498 to: "b".into(),
4499 to_label: None,
4500 to_props: vec![],
4501 },
4502 PlanOp::Project {
4503 items: vec![RetItem {
4504 value: RetVal::Var("a".into()),
4505 alias: None,
4506 }],
4507 },
4508 ];
4509 let params = BTreeMap::new();
4510 let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4511 execute(&v, &plan, &Params(¶ms))
4512 }));
4513 assert!(caught.is_ok(), "execute panicked on unknown var");
4514 let err = caught.unwrap().expect_err("unknown var must be Err");
4515 assert!(
4516 err.contains("zzz") && err.to_ascii_lowercase().contains("unbound"),
4517 "got: {err}"
4518 );
4519
4520 let join = vec![
4521 PlanOp::JoinBound {
4522 var: "ghost".into(),
4523 label: None,
4524 props: vec![],
4525 },
4526 PlanOp::Project {
4527 items: vec![RetItem {
4528 value: RetVal::Var("ghost".into()),
4529 alias: None,
4530 }],
4531 },
4532 ];
4533 let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4534 execute(&v, &join, &Params(¶ms))
4535 }));
4536 assert!(caught.is_ok(), "execute panicked on JoinBound unknown var");
4537 assert!(caught.unwrap().is_err());
4538 }
4539
4540 #[test]
4541 fn dest_props_on_bound_expand_are_applied() {
4542 let fx = dogfood_graph();
4543 let v = fx.view();
4544 let rs = run(
4545 &v,
4546 "MATCH (t:Talent {id: $tid}) \
4547 MATCH (c:Company)-[r:INDUSTRY_ALIGNMENT]->(t:Talent {id: $tid}) \
4548 RETURN c",
4549 &tid_params(),
4550 )
4551 .unwrap();
4552 assert_eq!(
4554 col(&rs, "c"),
4555 vec![
4556 Some(s("acme")),
4557 Some(s("beta")),
4558 Some(s("gamma")),
4559 Some(s("delta")),
4560 Some(s("echo")),
4561 Some(s("foxtrot")),
4562 Some(s("zeta")),
4563 ]
4564 );
4565 let miss = run(
4566 &v,
4567 "MATCH (t:Talent {id: $tid}) \
4568 MATCH (c:Company)-[r:INDUSTRY_ALIGNMENT]->(t {id: 'nope'}) \
4569 RETURN c",
4570 &tid_params(),
4571 )
4572 .unwrap();
4573 assert!(miss.is_empty());
4574 }
4575
4576 #[test]
4577 fn missing_node_prop_projects_none_and_pattern_misses() {
4578 let mut fx = Fx::new();
4579 fx.add("Person", "ada", vec![("age", i(30))]);
4580 fx.add("Person", "bob", vec![]);
4581 let v = fx.view();
4582 let rs = run(&v, "MATCH (p:Person) RETURN p.age", &BTreeMap::new()).unwrap();
4583 assert_eq!(col(&rs, "p.age"), vec![Some(i(30)), None]);
4584 let pat = run(&v, "MATCH (p:Person {age: 30}) RETURN p", &BTreeMap::new()).unwrap();
4585 assert_eq!(col(&pat, "p"), vec![Some(s("ada"))]);
4586 }
4587
4588 #[test]
4589 fn unlabeled_match_does_not_project_sentinel_ghost_key() {
4590 let mut fx = Fx::new();
4591 fx.add("Person", "ada", vec![]);
4592 fx.ids.get_or_insert("ghost");
4595 fx.labels.resize(fx.ids.len(), u32::MAX);
4596 fx.add("Person", "bob", vec![]);
4597 let v = fx.view();
4598 let rs = run(&v, "MATCH (n) RETURN n", &BTreeMap::new()).expect("unlabeled scan");
4599 assert_eq!(col(&rs, "n"), vec![Some(s("ada")), Some(s("bob"))]);
4600 assert!(
4601 !rows_of(&rs)
4602 .iter()
4603 .any(|row| row.iter().any(|c| *c == Some(s("ghost")))),
4604 "sentinel slot must not project a ghost key"
4605 );
4606 }
4607
4608 #[test]
4609 fn execute_never_panics_on_hostile_plans() {
4610 let fx = hop_graph();
4611 let v = fx.view();
4612 let params = BTreeMap::new();
4613 let hostile = vec![
4614 vec![],
4615 vec![PlanOp::Project { items: vec![] }],
4616 vec![PlanOp::OrderBy {
4617 items: vec![OrderItem {
4618 target: OrderTarget::Alias("nope".into()),
4619 descending: false,
4620 }],
4621 }],
4622 vec![PlanOp::Filter {
4623 expr: crate::cypher::ast::Expr::Cmp {
4624 lhs: Operand::Prop {
4625 var: "missing".into(),
4626 field: "x".into(),
4627 },
4628 op: crate::filter::CmpOp::Eq,
4629 rhs: Operand::Lit(i(1)),
4630 },
4631 }],
4632 vec![
4633 PlanOp::ScanLabel {
4634 var: "a".into(),
4635 label: None,
4636 },
4637 PlanOp::LookupProps {
4638 var: "zzz".into(),
4639 props: vec![("k".into(), Operand::Lit(i(1)))],
4640 },
4641 ],
4642 vec![
4643 PlanOp::Skip(LimitSkip::Exact(99)),
4644 PlanOp::Limit(LimitSkip::Exact(0)),
4645 ],
4646 ];
4647 for plan in hostile {
4648 let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4649 execute(&v, &plan, &Params(¶ms))
4650 }));
4651 assert!(caught.is_ok(), "execute panicked on hostile plan: {plan:?}");
4652 }
4653 }
4654
4655 #[test]
4656 fn unlabeled_and_labeled_scans_skip_tombstoned_ids() {
4657 let mut fx = Fx::new();
4658 let ada = fx.add("Person", "ada", vec![]);
4659 let bob = fx.add("Person", "bob", vec![]);
4660 fx.edge("KNOWS", ada, bob, vec![]);
4661 fx.ids.delete("ada");
4663 fx.labels[ada as usize] = u32::MAX;
4664 let knows = fx.syms.get("KNOWS").unwrap();
4665 fx.topo.remove_edge(knows, ada, bob);
4666 let v = fx.view();
4667
4668 let labeled = run(&v, "MATCH (p:Person) RETURN p", &BTreeMap::new()).unwrap();
4669 assert_eq!(col(&labeled, "p"), vec![Some(s("bob"))]);
4670 let unlabeled = run(&v, "MATCH (n) RETURN n", &BTreeMap::new()).unwrap();
4671 assert_eq!(col(&unlabeled, "n"), vec![Some(s("bob"))]);
4672 let hop = run(&v, "MATCH (x)-[:KNOWS]->(y) RETURN x, y", &BTreeMap::new()).unwrap();
4673 assert!(
4674 hop.is_empty(),
4675 "expand cannot yield edges to a deleted node once topology is swept"
4676 );
4677 }
4678
4679 proptest! {
4684 #[test]
4697 fn prop_bounded_equals_unbounded_slice(
4698 n_nodes in 2u32..10u32,
4699 edge_pairs in proptest::collection::vec(
4700 (any::<u32>(), any::<u32>()), 0..20usize
4701 ),
4702 recip_pairs in proptest::collection::vec(
4704 (any::<u32>(), any::<u32>()), 0..8usize
4705 ),
4706 n_hops in 1u32..4u32, use_filter in any::<bool>(),
4708 threshold in 0i64..8i64, limit in 1u64..10u64,
4710 skip in 0u64..4u64,
4711 ) {
4712 let mut fx = Fx::new();
4713 let mut node_ids = Vec::new();
4714 for idx in 0..n_nodes {
4715 let id = fx.add("N", &format!("n{idx}"), vec![("v", i(idx as i64 % 8))]);
4717 node_ids.push(id);
4718 }
4719 let n = node_ids.len();
4720
4721 for (si, di) in &edge_pairs {
4723 let si = (*si as usize) % n;
4724 let di = (*di as usize) % n;
4725 if si != di {
4726 fx.edge("T", node_ids[si], node_ids[di], vec![]);
4727 }
4728 }
4729 for (si, di) in &recip_pairs {
4732 let si = (*si as usize) % n;
4733 let di = (*di as usize) % n;
4734 if si != di {
4735 fx.edge("T", node_ids[si], node_ids[di], vec![]);
4736 fx.edge("T", node_ids[di], node_ids[si], vec![]);
4737 }
4738 }
4739
4740 let v = fx.view();
4741 let params = BTreeMap::new();
4742
4743 let var_names = ["a", "b", "c", "d"];
4746 let hop_count = n_hops as usize;
4747 let mut pattern = format!("({}:N)", var_names[0]);
4748 for h in 0..hop_count {
4749 pattern.push_str(&format!("-[:T]->({}", var_names[h + 1]));
4750 if h + 1 == hop_count {
4752 pattern.push_str(":N)");
4753 } else {
4754 pattern.push(')');
4755 }
4756 }
4757 let last_var = var_names[hop_count];
4758 let where_clause = if use_filter {
4759 format!(" WHERE {last_var}.v > {threshold}")
4760 } else {
4761 String::new()
4762 };
4763 let ret_vars: Vec<&str> = var_names[..=hop_count].to_vec();
4764 let ret_clause = ret_vars.join(", ");
4765
4766 let full_q = format!("MATCH {pattern}{where_clause} RETURN {ret_clause}");
4767 let bounded_q = format!(
4768 "MATCH {pattern}{where_clause} RETURN {ret_clause} SKIP {skip} LIMIT {limit}"
4769 );
4770
4771 let full_plan = compile(&full_q);
4772 let unbounded = super::with_max_intermediate_rows(100_000, || {
4775 super::execute_unbounded(&v, &full_plan, &Params(¶ms))
4776 });
4777 let unbounded = match unbounded {
4780 Ok(rs) => rs,
4781 Err(_) => return Ok(()),
4782 };
4783 let total = unbounded.len();
4784 let full_rows = rows_of(&unbounded);
4785
4786 let bounded = super::with_max_intermediate_rows(100_000, || {
4787 run(&v, &bounded_q, ¶ms)
4788 }).expect("bounded must not error");
4789
4790 let s = (skip as usize).min(total);
4791 let e = (skip as usize + limit as usize).min(total);
4792 prop_assert_eq!(
4793 rows_of(&bounded),
4794 full_rows[s..e].to_vec(),
4795 "hops={} filter={} threshold={} SKIP {} LIMIT {}: \
4796 bounded != unbounded[{}..{}]",
4797 hop_count, use_filter, threshold, skip, limit, s, e
4798 );
4799 }
4800 }
4801
4802 proptest! {
4803 #[test]
4821 fn prop_scan_filter_fused_equals_unbounded_slice(
4822 n_nodes in 0u32..20u32,
4823 prop_mask in any::<u32>(),
4825 float_mask in any::<u32>(),
4827 threshold in -1i64..8i64,
4830 op_idx in 0u32..6u32,
4832 skip in 0u64..5u64,
4833 limit in 1u64..8u64,
4834 ) {
4835 let op_str = match op_idx {
4836 0 => "=",
4837 1 => "<>",
4838 2 => "<",
4839 3 => "<=",
4840 4 => ">",
4841 _ => ">=",
4842 };
4843
4844 let mut fx = Fx::new();
4845 for idx in 0..n_nodes {
4846 let has_prop = (prop_mask >> (idx % 32)) & 1 == 1;
4847 let use_float = (float_mask >> (idx % 32)) & 1 == 1;
4848 let props: Vec<(&str, Value)> = if has_prop {
4850 if use_float {
4851 vec![("v", f(idx as f64 % 7.0))]
4852 } else {
4853 vec![("v", i(idx as i64 % 7))]
4854 }
4855 } else {
4856 vec![]
4857 };
4858 fx.add("N", &format!("n{idx}"), props);
4859 }
4860
4861 let v = fx.view();
4862 let params = BTreeMap::new();
4863
4864 let full_q = format!("MATCH (n:N) WHERE n.v {op_str} {threshold} RETURN n");
4866 let bounded_q = format!(
4867 "MATCH (n:N) WHERE n.v {op_str} {threshold} RETURN n SKIP {skip} LIMIT {limit}"
4868 );
4869
4870 let full_plan = compile(&full_q);
4871 let unbounded = super::execute_unbounded(&v, &full_plan, &Params(¶ms));
4872 let unbounded = match unbounded {
4873 Ok(rs) => rs,
4874 Err(_) => return Ok(()),
4875 };
4876 let total = unbounded.len();
4877 let full_rows = rows_of(&unbounded);
4878
4879 let fires_before =
4881 super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
4882 let bounded = run(&v, &bounded_q, ¶ms).expect("fused bounded must not error");
4883 let fires_after =
4884 super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
4885
4886 if n_nodes > 0 {
4891 prop_assert!(
4892 fires_after > fires_before,
4893 "fused arm did NOT fire for op={} threshold={} n_nodes={}: \
4894 counter before={} after={}",
4895 op_str,
4896 threshold,
4897 n_nodes,
4898 fires_before,
4899 fires_after
4900 );
4901 }
4902
4903 let s = (skip as usize).min(total);
4904 let e = (skip as usize + limit as usize).min(total);
4905 prop_assert_eq!(
4906 rows_of(&bounded),
4907 full_rows[s..e].to_vec(),
4908 "fused path: op={} threshold={} n_nodes={} SKIP {} LIMIT {}: \
4909 bounded != unbounded[{}..{}]",
4910 op_str, threshold, n_nodes, skip, limit, s, e
4911 );
4912
4913 let compound_q = format!(
4917 "MATCH (n:N) WHERE n.v {} {} AND n.v >= -999 RETURN n SKIP {} LIMIT {}",
4918 op_str, threshold, skip, limit
4919 );
4920 let fires_before_compound =
4921 super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
4922 let compound_bounded =
4923 run(&v, &compound_q, ¶ms).expect("compound-AND bounded must not error");
4924 let fires_after_compound =
4925 super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
4926
4927 prop_assert_eq!(
4929 fires_after_compound,
4930 fires_before_compound,
4931 "fused arm fired for compound-AND shape (should use generic path): \
4932 op={} threshold={} n_nodes={}",
4933 op_str,
4934 threshold,
4935 n_nodes
4936 );
4937 prop_assert_eq!(
4938 rows_of(&compound_bounded),
4939 full_rows[s..e].to_vec(),
4940 "compound-AND fallback: op={} threshold={} n_nodes={} SKIP {} LIMIT {}: \
4941 result differs from fused",
4942 op_str, threshold, n_nodes, skip, limit
4943 );
4944 }
4945 }
4946
4947 #[test]
4958 fn dense_hop1_with_filter_survives_pull() {
4959 const LEAVES: usize = 120; const CAP: usize = 100;
4961
4962 let mut fx = Fx::new();
4963 let src = fx.add("Src", "src", vec![]);
4964 for idx in 0..LEAVES {
4965 let leaf = fx.add("Leaf", &format!("l{idx}"), vec![("v", i(idx as i64))]);
4966 fx.edge("T", src, leaf, vec![]);
4967 }
4968 let v = fx.view();
4969 let params = BTreeMap::new();
4970
4971 let staged_err = super::with_max_intermediate_rows(CAP, || {
4973 super::execute_unbounded(
4974 &v,
4975 &compile("MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v >= 110 RETURN l, l.v"),
4976 &Params(¶ms),
4977 )
4978 });
4979 assert!(
4980 staged_err.is_err(),
4981 "staged path must error on 120 leaves with cap={CAP}"
4982 );
4983 assert!(
4984 staged_err
4985 .unwrap_err()
4986 .contains("intermediate result exceeds"),
4987 "wrong error message"
4988 );
4989
4990 let ok = super::with_max_intermediate_rows(CAP, || {
4994 run(
4995 &v,
4996 "MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v >= 110 RETURN l, l.v LIMIT 5",
4997 ¶ms,
4998 )
4999 });
5000 let rs = ok.expect("pull-based must survive despite dense hop-1 exceeding cap");
5001 assert_eq!(rs.len(), 5, "LIMIT 5 must return exactly 5 rows");
5002
5003 let vs: Vec<i64> = (0..rs.len())
5005 .filter_map(|i| match rs.get(i, "l.v") {
5006 Some(Value::Int(n)) => Some(*n),
5007 _ => None,
5008 })
5009 .collect();
5010 assert_eq!(vs.len(), 5, "all projected rows must have v");
5011 for v_val in &vs {
5012 assert!(*v_val >= 110, "filter must hold: v={v_val} is not >= 110");
5013 }
5014
5015 let (pull_result, pull_produced) = super::with_expand_counter(|| {
5019 super::with_max_intermediate_rows(1_000_000, || {
5020 run(
5021 &v,
5022 "MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v < 10 RETURN l LIMIT 5",
5023 ¶ms,
5024 )
5025 })
5026 });
5027 pull_result.expect("pull must succeed without cap");
5028 assert!(
5030 pull_produced <= 5,
5031 "pull expand count {pull_produced} should be ≤ 5 (stops after 5 passing leaves)"
5032 );
5033
5034 let (staged_result, staged_produced) = super::with_expand_counter(|| {
5035 super::with_max_intermediate_rows(1_000_000, || {
5036 super::execute_unbounded(
5037 &v,
5038 &compile("MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v < 10 RETURN l"),
5039 &Params(¶ms),
5040 )
5041 })
5042 });
5043 staged_result.expect("staged must succeed with 1M cap");
5044 assert_eq!(
5045 staged_produced, LEAVES,
5046 "staged must expand all {LEAVES} leaves"
5047 );
5048
5049 assert!(
5050 staged_produced >= pull_produced * 10,
5051 "staged ({staged_produced}) must be ≥ 10× pull ({pull_produced})"
5052 );
5053 }
5054
5055 #[test]
5073 fn harness_shape_two_hop_dense_survives_pull() {
5074 const N_TALENT: usize = 70;
5075 const N_COMPANY: usize = 20;
5076 const N_INDUSTRY: usize = 3;
5077 const CAP: usize = 100;
5078 const LIMIT: usize = 10;
5079
5080 let mut fx = Fx::new();
5081
5082 let mut talent_ids: Vec<u32> = Vec::new();
5084 let mut talent_industry: Vec<usize> = Vec::new();
5085 for i in 0..N_TALENT {
5086 let ind = i % N_INDUSTRY;
5087 let id = fx.add(
5088 "Talent",
5089 &format!("t{i}"),
5090 vec![("industry", s(&ind.to_string()))],
5091 );
5092 talent_ids.push(id);
5093 talent_industry.push(ind);
5094 }
5095
5096 let mut company_ids: Vec<u32> = Vec::new();
5098 let mut company_industry: Vec<usize> = Vec::new();
5099 for i in 0..N_COMPANY {
5100 let ind = i % N_INDUSTRY;
5101 let id = fx.add(
5102 "Company",
5103 &format!("c{i}"),
5104 vec![("industry", s(&ind.to_string()))],
5105 );
5106 company_ids.push(id);
5107 company_industry.push(ind);
5108 }
5109
5110 for (ti, &tid) in talent_ids.iter().enumerate() {
5112 for (ci, &cid) in company_ids.iter().enumerate() {
5113 if talent_industry[ti] == company_industry[ci] {
5114 fx.edge("INDUSTRY_ALIGNMENT", tid, cid, vec![]);
5115 }
5116 }
5117 }
5118
5119 let v = fx.view();
5120 let params = BTreeMap::new();
5121 let query = format!(
5122 "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company)\
5123 <-[:INDUSTRY_ALIGNMENT]-(t2:Talent) RETURN t, c, t2 LIMIT {LIMIT}"
5124 );
5125
5126 const UNBOUNDED_Q: &str = "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company)\
5128 <-[:INDUSTRY_ALIGNMENT]-(t2:Talent) RETURN t, c, t2";
5129 let staged_err = super::with_max_intermediate_rows(CAP, || {
5130 super::execute_unbounded(&v, &compile(UNBOUNDED_Q), &Params(¶ms))
5131 });
5132 assert!(
5133 staged_err.is_err(),
5134 "staged must error with cap={CAP} on harness-shape graph"
5135 );
5136 assert!(
5137 staged_err
5138 .unwrap_err()
5139 .contains("intermediate result exceeds"),
5140 "wrong error"
5141 );
5142
5143 let ok = super::with_max_intermediate_rows(CAP, || run(&v, &query, ¶ms));
5145 let rs = ok.expect("pull-based must complete on harness-shape with LIMIT 10");
5146 assert_eq!(rs.len(), LIMIT, "must return exactly {LIMIT} rows");
5147
5148 for i in 0..rs.len() {
5153 let row = rs.row(i);
5154 assert_eq!(row.len(), 3, "each row must have 3 columns (t, c, t2)");
5155 assert!(row.iter().all(|c| c.is_some()), "all cells must be Some");
5156 }
5157 }
5158
5159 #[test]
5160 fn intermediate_row_cap_errors_on_scan_and_expand() {
5161 let cap_msg = |n: usize| {
5162 format!(
5163 "intermediate result exceeds {n} rows; add a LIMIT or constrain patterns with shared variables"
5164 )
5165 };
5166
5167 let mut scan_fx = Fx::new();
5168 scan_fx.add("N", "a", vec![]);
5169 scan_fx.add("N", "b", vec![]);
5170 scan_fx.add("N", "c", vec![]);
5171 let sv = scan_fx.view();
5172 let scan_err = super::with_max_intermediate_rows(2, || {
5173 run(&sv, "MATCH (n:N) RETURN n", &BTreeMap::new())
5174 })
5175 .expect_err("3-row scan must exceed cap 2");
5176 assert_eq!(scan_err, cap_msg(2));
5177
5178 let mut exp_fx = Fx::new();
5179 let src = exp_fx.add("Src", "s", vec![]);
5180 let d1 = exp_fx.add("Dst", "d1", vec![]);
5181 let d2 = exp_fx.add("Dst", "d2", vec![]);
5182 exp_fx.edge("T", src, d1, vec![]);
5183 exp_fx.edge("T", src, d2, vec![]);
5184 let ev = exp_fx.view();
5185 let exp_err = super::with_max_intermediate_rows(1, || {
5187 run(&ev, "MATCH (x:Src)-[:T]->(y) RETURN x, y", &BTreeMap::new())
5188 })
5189 .expect_err("2-row expand must exceed cap 1");
5190 assert_eq!(exp_err, cap_msg(1));
5191 }
5192
5193 #[test]
5203 fn bounded_matches_unbounded_slice_various_limits() {
5204 let fx = hop_graph();
5206 let v = fx.view();
5207 let params = BTreeMap::new();
5208
5209 let full_plan = compile("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b");
5211 let full_rs = super::execute_unbounded(&v, &full_plan, &Params(¶ms)).unwrap();
5212 let full_rows = rows_of(&full_rs);
5213 assert_eq!(full_rows.len(), 3, "hop_graph has exactly 3 KNOWS paths");
5214
5215 for limit in [1u64, 2, 3, 10] {
5216 let q = format!("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b LIMIT {limit}");
5217 let rs = run(&v, &q, ¶ms).unwrap();
5218 let expected_len = (limit as usize).min(full_rows.len());
5219 assert_eq!(
5220 rs.len(),
5221 expected_len,
5222 "LIMIT {limit}: expected {expected_len} rows, got {}",
5223 rs.len()
5224 );
5225 assert_eq!(
5226 rows_of(&rs),
5227 full_rows[..expected_len],
5228 "LIMIT {limit}: rows differ from reference slice"
5229 );
5230 }
5231
5232 let skip_rs = run(
5234 &v,
5235 "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b SKIP 1 LIMIT 2",
5236 ¶ms,
5237 )
5238 .unwrap();
5239 assert_eq!(rows_of(&skip_rs), full_rows[1..3]);
5240
5241 let mut fx2 = Fx::new();
5243 fx2.add("N", "a", vec![("v", i(1))]);
5244 fx2.add("N", "b", vec![("v", i(2))]);
5245 fx2.add("N", "c", vec![("v", i(3))]);
5246 let v2 = fx2.view();
5247 let filter_full = super::execute_unbounded(
5249 &v2,
5250 &compile("MATCH (n:N) WHERE n.v > 1 RETURN n"),
5251 &Params(¶ms),
5252 )
5253 .unwrap();
5254 assert_eq!(filter_full.len(), 2);
5255
5256 let filter_lim = run(&v2, "MATCH (n:N) WHERE n.v > 1 RETURN n LIMIT 1", ¶ms).unwrap();
5257 assert_eq!(filter_lim.len(), 1, "LIMIT 1 on filter query");
5258 assert_eq!(rows_of(&filter_lim), rows_of(&filter_full)[..1]);
5259
5260 let tri = triangle();
5262 let tv = tri.view();
5263 let tri_full = super::execute_unbounded(
5265 &tv,
5266 &compile("MATCH (x)-[r1:T]->(y)-[r2:T]->(z) RETURN x, y, z"),
5267 &Params(¶ms),
5268 )
5269 .unwrap();
5270 assert_eq!(tri_full.len(), 3, "triangle has 3 unique two-hop paths");
5271 for limit in [1u64, 2, 3, 5] {
5272 let q = format!("MATCH (x)-[r1:T]->(y)-[r2:T]->(z) RETURN x, y, z LIMIT {limit}");
5273 let rs = run(&tv, &q, ¶ms).unwrap();
5274 let expected_len = (limit as usize).min(3);
5275 assert_eq!(
5276 rs.len(),
5277 expected_len,
5278 "triangle LIMIT {limit}: got {} rows",
5279 rs.len()
5280 );
5281 assert_eq!(
5282 rows_of(&rs),
5283 rows_of(&tri_full)[..expected_len],
5284 "triangle LIMIT {limit}: rows differ"
5285 );
5286 }
5287 }
5288
5289 #[test]
5293 fn expand_terminates_early_with_row_bound() {
5294 const LEAVES: usize = 500;
5295 let mut fx = Fx::new();
5296 let hub = fx.add("Hub", "hub", vec![]);
5297 for i in 0..LEAVES {
5298 let leaf = fx.add("Leaf", &format!("leaf-{i}"), vec![]);
5299 fx.edge("T", hub, leaf, vec![]);
5300 }
5301 let v = fx.view();
5302 let params = BTreeMap::new();
5303 let full_plan = compile("MATCH (h:Hub)-[:T]->(x:Leaf) RETURN x");
5304
5305 let (bounded_result, bounded_produced) = super::with_expand_counter(|| {
5307 run(&v, "MATCH (h:Hub)-[:T]->(x:Leaf) RETURN x LIMIT 5", ¶ms)
5308 });
5309 let bounded_rs = bounded_result.unwrap();
5310 assert_eq!(bounded_rs.len(), 5, "LIMIT 5 must return exactly 5 rows");
5311 assert!(
5312 bounded_produced <= 5,
5313 "bounded: exec_expand emitted {bounded_produced} rows, expected ≤ 5"
5314 );
5315
5316 let (unbounded_result, unbounded_produced) = super::with_expand_counter(|| {
5318 super::execute_unbounded(&v, &full_plan, &Params(¶ms))
5319 });
5320 let unbounded_rs = unbounded_result.unwrap();
5321 assert_eq!(
5322 unbounded_rs.len(),
5323 LEAVES,
5324 "unbounded must return all {LEAVES} rows"
5325 );
5326 assert_eq!(
5327 unbounded_produced, LEAVES,
5328 "unbounded: exec_expand must emit all {LEAVES} rows"
5329 );
5330
5331 assert!(
5333 unbounded_produced >= bounded_produced * 100,
5334 "unbounded ({unbounded_produced}) must be ≥ 100× bounded ({bounded_produced})"
5335 );
5336 }
5337
5338 #[test]
5344 fn bounded_query_survives_low_intermediate_row_cap() {
5345 let mut fx = Fx::new();
5347 let src = fx.add("Src", "s", vec![]);
5348 for i in 0..20usize {
5349 let dst = fx.add("Dst", &format!("d{i}"), vec![]);
5350 fx.edge("T", src, dst, vec![]);
5351 }
5352 let v = fx.view();
5353 let params = BTreeMap::new();
5354 let full_plan = compile("MATCH (s:Src)-[:T]->(d:Dst) RETURN d");
5355
5356 let cap_err = super::with_max_intermediate_rows(10, || {
5358 super::execute_unbounded(&v, &full_plan, &Params(¶ms))
5359 });
5360 assert!(
5361 cap_err.is_err(),
5362 "unbounded must hit the intermediate-row cap"
5363 );
5364 assert!(
5365 cap_err.unwrap_err().contains("intermediate result exceeds"),
5366 "cap error message must be the budget message"
5367 );
5368
5369 let ok = super::with_max_intermediate_rows(10, || {
5371 run(&v, "MATCH (s:Src)-[:T]->(d:Dst) RETURN d LIMIT 5", ¶ms)
5372 });
5373 assert_eq!(
5374 ok.unwrap().len(),
5375 5,
5376 "bounded (LIMIT 5) must complete with 5 rows, not a cap error"
5377 );
5378
5379 let at_cap = super::with_max_intermediate_rows(10, || {
5381 run(&v, "MATCH (s:Src)-[:T]->(d:Dst) RETURN d LIMIT 10", ¶ms)
5382 });
5383 assert_eq!(
5384 at_cap.unwrap().len(),
5385 10,
5386 "bounded at LIMIT==cap must complete with 10 rows"
5387 );
5388 }
5389
5390 #[test]
5395 fn count_star_returns_total_node_count() {
5396 let mut fx = Fx::new();
5397 fx.add("Person", "ada", vec![]);
5398 fx.add("Person", "bob", vec![]);
5399 fx.add("Person", "cam", vec![]);
5400 let v = fx.view();
5401 let params = BTreeMap::new();
5402
5403 let rs = run(&v, "MATCH (n:Person) RETURN COUNT(*)", ¶ms).expect("COUNT(*)");
5404 assert_eq!(rs.columns(), &["COUNT(*)".to_string()]);
5405 assert_eq!(rs.len(), 1);
5406 assert_eq!(rs.row(0), &[Some(i(3))]);
5407
5408 let rs_empty = run(&v, "MATCH (n:Ghost) RETURN COUNT(*)", ¶ms).expect("COUNT(*) empty");
5410 assert_eq!(rs_empty.row(0), &[Some(i(0))]);
5411 }
5412
5413 #[test]
5414 fn count_star_alias_sets_column_name() {
5415 let mut fx = Fx::new();
5416 fx.add("N", "a", vec![]);
5417 let v = fx.view();
5418 let params = BTreeMap::new();
5419 let rs = run(&v, "MATCH (n:N) RETURN COUNT(*) AS total", ¶ms).expect("COUNT AS");
5420 assert_eq!(rs.columns(), &["total".to_string()]);
5421 assert_eq!(rs.row(0), &[Some(i(1))]);
5422 }
5423
5424 #[test]
5425 fn count_var_skips_null_node_bindings() {
5426 let mut fx = Fx::new();
5429 fx.add("N", "a", vec![]);
5430 fx.add("N", "b", vec![]);
5431 let v = fx.view();
5432 let params = BTreeMap::new();
5433 let rs = run(&v, "MATCH (n:N) RETURN COUNT(n)", ¶ms).expect("COUNT(n)");
5434 assert_eq!(rs.columns(), &["COUNT(n)".to_string()]);
5435 assert_eq!(rs.row(0), &[Some(i(2))]);
5436 }
5437
5438 #[test]
5439 fn sum_numeric_prop_ignores_null_and_non_numeric() {
5440 let mut fx = Fx::new();
5441 fx.add("N", "a", vec![("v", i(10))]);
5442 fx.add("N", "b", vec![("v", i(20))]);
5443 fx.add("N", "c", vec![]); let v = fx.view();
5445 let params = BTreeMap::new();
5446 let rs = run(&v, "MATCH (n:N) RETURN SUM(n.v)", ¶ms).expect("SUM");
5447 assert_eq!(rs.columns(), &["SUM(n.v)".to_string()]);
5448 assert_eq!(rs.row(0), &[Some(f(30.0))]);
5450
5451 let rs_null = run(&v, "MATCH (n:N) RETURN SUM(n.missing)", ¶ms).expect("SUM null");
5453 assert_eq!(rs_null.row(0), &[None]);
5454 }
5455
5456 #[test]
5457 fn avg_numeric_prop() {
5458 let mut fx = Fx::new();
5459 fx.add("N", "a", vec![("v", i(10))]);
5460 fx.add("N", "b", vec![("v", i(30))]);
5461 let v = fx.view();
5462 let params = BTreeMap::new();
5463 let rs = run(&v, "MATCH (n:N) RETURN AVG(n.v) AS avg_v", ¶ms).expect("AVG");
5464 assert_eq!(rs.columns(), &["avg_v".to_string()]);
5465 assert_eq!(rs.row(0), &[Some(f(20.0))]);
5467
5468 let rs_empty = run(&v, "MATCH (n:Ghost) RETURN AVG(n.v)", ¶ms).expect("AVG empty");
5470 assert_eq!(rs_empty.row(0), &[None]);
5471 }
5472
5473 #[test]
5474 fn min_max_numeric_prop() {
5475 let mut fx = Fx::new();
5476 fx.add("N", "a", vec![("v", i(5))]);
5477 fx.add("N", "b", vec![("v", i(1))]);
5478 fx.add("N", "c", vec![("v", i(9))]);
5479 fx.add("N", "d", vec![]); let v = fx.view();
5481 let params = BTreeMap::new();
5482
5483 let min_rs = run(&v, "MATCH (n:N) RETURN MIN(n.v)", ¶ms).expect("MIN");
5484 assert_eq!(min_rs.row(0), &[Some(i(1))]);
5485
5486 let max_rs = run(&v, "MATCH (n:N) RETURN MAX(n.v)", ¶ms).expect("MAX");
5487 assert_eq!(max_rs.row(0), &[Some(i(9))]);
5488 }
5489
5490 #[test]
5493 fn min_max_mixed_int_float_props() {
5494 let mut fx = Fx::new();
5495 fx.add("N", "a", vec![("v", i(3))]);
5497 fx.add("N", "b", vec![("v", f(1.5))]);
5498 fx.add("N", "c", vec![("v", i(7))]);
5499 fx.add("N", "d", vec![("v", f(2.0))]);
5500 let v = fx.view();
5501 let params = BTreeMap::new();
5502
5503 let min_rs = run(&v, "MATCH (n:N) RETURN MIN(n.v)", ¶ms).expect("MIN mixed");
5504 assert_eq!(min_rs.row(0), &[Some(f(1.5))]);
5506
5507 let max_rs = run(&v, "MATCH (n:N) RETURN MAX(n.v)", ¶ms).expect("MAX mixed");
5508 assert_eq!(max_rs.row(0), &[Some(i(7))]);
5510 }
5511
5512 #[test]
5515 fn aggregate_limit_skip_order_by_are_no_ops() {
5516 let mut fx = Fx::new();
5517 fx.add("N", "a", vec![]);
5518 fx.add("N", "b", vec![]);
5519 fx.add("N", "c", vec![]);
5520 let v = fx.view();
5521 let params = BTreeMap::new();
5522
5523 let rs_lim5 =
5525 run(&v, "MATCH (n:N) RETURN COUNT(*) LIMIT 5", ¶ms).expect("COUNT(*) LIMIT 5");
5526 assert_eq!(
5527 rs_lim5.len(),
5528 1,
5529 "aggregate with LIMIT 5 must still return 1 row"
5530 );
5531 assert_eq!(rs_lim5.row(0), &[Some(i(3))]);
5532
5533 let rs_lim0 =
5535 run(&v, "MATCH (n:N) RETURN COUNT(*) LIMIT 0", ¶ms).expect("COUNT(*) LIMIT 0");
5536 assert_eq!(
5537 rs_lim0.len(),
5538 1,
5539 "aggregate with LIMIT 0 must still return 1 row"
5540 );
5541 assert_eq!(rs_lim0.row(0), &[Some(i(3))]);
5542
5543 let rs_skip =
5545 run(&v, "MATCH (n:N) RETURN COUNT(*) SKIP 100", ¶ms).expect("COUNT(*) SKIP 100");
5546 assert_eq!(
5547 rs_skip.len(),
5548 1,
5549 "aggregate with large SKIP must still return 1 row"
5550 );
5551
5552 let rs_ord = plan_src("MATCH (n:N) RETURN COUNT(*) ORDER BY n");
5556 assert!(
5559 rs_ord.is_ok(),
5560 "COUNT(*) ORDER BY should plan without error (ORDER BY dropped)"
5561 );
5562 let plan_ops = rs_ord.unwrap();
5563 assert!(
5565 !plan_ops
5566 .iter()
5567 .any(|op| matches!(op, crate::cypher::plan::PlanOp::OrderBy { .. })),
5568 "aggregate plan must not contain OrderBy"
5569 );
5570 }
5571
5572 #[test]
5573 fn count_star_no_budget_cap_applies() {
5574 let mut fx = Fx::new();
5577 let src = fx.add("Src", "s", vec![]);
5578 for i in 0..30usize {
5579 let dst = fx.add("Dst", &format!("d{i}"), vec![]);
5580 fx.edge("T", src, dst, vec![]);
5581 }
5582 let v = fx.view();
5583 let params = BTreeMap::new();
5584
5585 let cap_err =
5587 super::with_max_intermediate_rows(10, || run(&v, "MATCH (n:Dst) RETURN n", ¶ms));
5588 assert!(
5589 cap_err.is_err(),
5590 "staged path must error on 30 nodes with cap=10"
5591 );
5592
5593 let agg_ok = super::with_max_intermediate_rows(10, || {
5595 run(&v, "MATCH (n:Dst) RETURN COUNT(*)", ¶ms)
5596 })
5597 .expect("aggregate must not hit the intermediate-row cap");
5598 assert_eq!(
5599 agg_ok.row(0),
5600 &[Some(i(30))],
5601 "COUNT(*) must count all 30 nodes regardless of cap"
5602 );
5603 }
5604
5605 fn plan_src(src: &str) -> Result<Vec<crate::cypher::plan::PlanOp>, String> {
5606 use crate::cypher::{lex, parse, plan};
5607 let toks = lex(src).map_err(|e| format!("lex: {e}"))?;
5608 let ast = parse(&toks).map_err(|e| format!("parse: {e}"))?;
5609 plan(&ast).map_err(|e| format!("plan: {e}"))
5610 }
5611
5612 #[test]
5615 fn grouped_aggregation_plan_routing() {
5616 use crate::cypher::plan::PlanOp;
5617
5618 let ops = plan_src("MATCH (a:N) RETURN a, COUNT(*)")
5620 .expect("grouped aggregation must now succeed");
5621 assert!(
5622 ops.iter()
5623 .any(|op| matches!(op, PlanOp::GroupAggregate { .. })),
5624 "grouped aggregation plan must contain GroupAggregate op, got: {ops:?}"
5625 );
5626
5627 let ops2 = plan_src("MATCH (a:N) RETURN COUNT(*), COUNT(a)")
5629 .expect("multi-aggregate must now succeed");
5630 assert!(
5631 ops2.iter()
5632 .any(|op| matches!(op, PlanOp::GroupAggregate { .. })),
5633 "multi-aggregate plan must contain GroupAggregate op, got: {ops2:?}"
5634 );
5635
5636 let err3 = plan_src("MATCH (a:N) RETURN SUM(*)").expect_err("SUM(*) must be plan error");
5638 assert!(
5639 err3.to_ascii_lowercase().contains("sum") || err3.to_ascii_lowercase().contains("*"),
5640 "error must mention SUM or *, got: {err3}"
5641 );
5642 }
5643
5644 #[test]
5647 fn grouped_single_key_count() {
5648 let mut fx = Fx::new();
5650 fx.add("N", "a", vec![("t", s("X"))]);
5651 fx.add("N", "b", vec![("t", s("X"))]);
5652 fx.add("N", "c", vec![("t", s("Y"))]);
5653 let v = fx.view();
5654 let params = BTreeMap::new();
5655
5656 let rs = run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", ¶ms)
5657 .expect("single-key grouped COUNT must succeed");
5658 assert_eq!(
5659 rs.columns(),
5660 &["n.t".to_string(), "cnt".to_string()],
5661 "columns must match RETURN clause"
5662 );
5663 assert_eq!(rs.len(), 2, "must produce exactly 2 groups (X and Y)");
5664
5665 let find = |label: &Value| (0..rs.len()).find(|&i| rs.row(i)[0].as_ref() == Some(label));
5667 let xi = find(&s("X")).expect("group X must exist");
5668 let yi = find(&s("Y")).expect("group Y must exist");
5669 assert_eq!(rs.row(xi)[1], Some(i(2)), "X group count must be 2");
5670 assert_eq!(rs.row(yi)[1], Some(i(1)), "Y group count must be 1");
5671 }
5672
5673 #[test]
5674 fn grouped_two_keys_sum_avg() {
5675 let mut fx = Fx::new();
5677 fx.add(
5678 "N",
5679 "a",
5680 vec![("cat", s("A")), ("sub", s("1")), ("v", i(10))],
5681 );
5682 fx.add(
5683 "N",
5684 "b",
5685 vec![("cat", s("A")), ("sub", s("1")), ("v", i(20))],
5686 );
5687 fx.add(
5688 "N",
5689 "c",
5690 vec![("cat", s("A")), ("sub", s("2")), ("v", i(5))],
5691 );
5692 fx.add(
5693 "N",
5694 "d",
5695 vec![("cat", s("B")), ("sub", s("1")), ("v", i(100))],
5696 );
5697 let v = fx.view();
5698 let params = BTreeMap::new();
5699
5700 let rs = run(
5701 &v,
5702 "MATCH (n:N) RETURN n.cat, n.sub, SUM(n.v) AS total, AVG(n.v) AS avg_v",
5703 ¶ms,
5704 )
5705 .expect("two-key SUM + AVG must succeed");
5706 assert_eq!(
5707 rs.columns(),
5708 &[
5709 "n.cat".to_string(),
5710 "n.sub".to_string(),
5711 "total".to_string(),
5712 "avg_v".to_string()
5713 ]
5714 );
5715 assert_eq!(rs.len(), 3, "must produce 3 groups: (A,1), (A,2), (B,1)");
5716
5717 let find = |cat: &Value, sub: &Value| {
5718 (0..rs.len())
5719 .find(|&i| rs.row(i)[0].as_ref() == Some(cat) && rs.row(i)[1].as_ref() == Some(sub))
5720 };
5721 let a1 = find(&s("A"), &s("1")).expect("group (A,1) must exist");
5722 assert_eq!(rs.row(a1)[2], Some(f(30.0)), "(A,1) SUM must be 30.0");
5723 assert_eq!(rs.row(a1)[3], Some(f(15.0)), "(A,1) AVG must be 15.0");
5724
5725 let a2 = find(&s("A"), &s("2")).expect("group (A,2) must exist");
5726 assert_eq!(rs.row(a2)[2], Some(f(5.0)), "(A,2) SUM must be 5.0");
5727
5728 let b1 = find(&s("B"), &s("1")).expect("group (B,1) must exist");
5729 assert_eq!(rs.row(b1)[2], Some(f(100.0)), "(B,1) SUM must be 100.0");
5730 assert_eq!(rs.row(b1)[3], Some(f(100.0)), "(B,1) AVG must be 100.0");
5731 }
5732
5733 #[test]
5734 fn grouped_order_by_count_desc_limit() {
5735 let mut fx = Fx::new();
5737 fx.add("N", "a1", vec![("cat", s("A"))]);
5738 fx.add("N", "a2", vec![("cat", s("A"))]);
5739 fx.add("N", "a3", vec![("cat", s("A"))]);
5740 fx.add("N", "b1", vec![("cat", s("B"))]);
5741 fx.add("N", "b2", vec![("cat", s("B"))]);
5742 fx.add("N", "c1", vec![("cat", s("C"))]);
5743 fx.add("N", "d1", vec![("cat", s("D"))]);
5744 fx.add("N", "d2", vec![("cat", s("D"))]);
5745 fx.add("N", "d3", vec![("cat", s("D"))]);
5746 fx.add("N", "d4", vec![("cat", s("D"))]);
5747 fx.add("N", "e1", vec![("cat", s("E"))]);
5748 let v = fx.view();
5749 let params = BTreeMap::new();
5750
5751 let rs = run(
5752 &v,
5753 "MATCH (n:N) RETURN n.cat, COUNT(*) AS cnt ORDER BY cnt DESC LIMIT 3",
5754 ¶ms,
5755 )
5756 .expect("ORDER BY count DESC LIMIT 3 must succeed");
5757 assert_eq!(rs.len(), 3, "LIMIT 3 must return exactly 3 groups");
5758 assert_eq!(rs.row(0)[1], Some(i(4)), "row 0 must be count 4");
5760 assert_eq!(rs.row(0)[0], Some(s("D")), "row 0 must be category D");
5761 assert_eq!(rs.row(1)[1], Some(i(3)), "row 1 must be count 3");
5762 assert_eq!(rs.row(1)[0], Some(s("A")), "row 1 must be category A");
5763 assert_eq!(rs.row(2)[1], Some(i(2)), "row 2 must be count 2");
5764 assert_eq!(rs.row(2)[0], Some(s("B")), "row 2 must be category B");
5765
5766 let plan_ops =
5768 plan_src("MATCH (n:N) RETURN n.cat, COUNT(*) AS cnt ORDER BY cnt DESC LIMIT 3")
5769 .expect("plan must succeed");
5770 assert_eq!(
5771 crate::cypher::plan::row_bound(&plan_ops),
5772 None,
5773 "GroupAggregate plan with LIMIT must have row_bound = None"
5774 );
5775 }
5776
5777 #[test]
5778 fn grouped_empty_input_yields_zero_groups() {
5779 let fx = Fx::new(); let v = fx.view();
5781 let params = BTreeMap::new();
5782
5783 let rs = run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", ¶ms)
5784 .expect("grouped aggregate on empty graph must succeed");
5785 assert_eq!(rs.len(), 0, "empty input must yield zero groups");
5786 assert_eq!(
5787 rs.columns(),
5788 &["n.t".to_string(), "cnt".to_string()],
5789 "columns must still be present even with zero rows"
5790 );
5791 }
5792
5793 #[test]
5794 fn grouped_null_key_groups_together() {
5795 let mut fx = Fx::new();
5797 fx.add("N", "a", vec![("t", s("X"))]);
5798 fx.add("N", "b", vec![]); fx.add("N", "c", vec![]); fx.add("N", "d", vec![("t", s("Y"))]);
5801 let v = fx.view();
5802 let params = BTreeMap::new();
5803
5804 let rs = run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", ¶ms)
5805 .expect("null-key grouped aggregate must succeed");
5806 assert_eq!(rs.len(), 3, "must produce 3 groups: X, null, Y");
5807
5808 let null_row = (0..rs.len())
5810 .find(|&i| rs.row(i)[0].is_none())
5811 .expect("null group must be present");
5812 assert_eq!(
5813 rs.row(null_row)[1],
5814 Some(i(2)),
5815 "null group must count 2 rows (b and c)"
5816 );
5817
5818 let x_row = (0..rs.len())
5819 .find(|&i| rs.row(i)[0] == Some(s("X")))
5820 .expect("X group must exist");
5821 assert_eq!(rs.row(x_row)[1], Some(i(1)));
5822
5823 let y_row = (0..rs.len())
5824 .find(|&i| rs.row(i)[0] == Some(s("Y")))
5825 .expect("Y group must exist");
5826 assert_eq!(rs.row(y_row)[1], Some(i(1)));
5827 }
5828
5829 #[test]
5830 fn grouped_cap_error_on_high_cardinality() {
5831 let mut fx = Fx::new();
5834 fx.add("N", "a", vec![("t", s("A"))]);
5835 fx.add("N", "b", vec![("t", s("B"))]);
5836 fx.add("N", "c", vec![("t", s("C"))]);
5837 let v = fx.view();
5838 let params = BTreeMap::new();
5839
5840 let err = super::with_max_groups(2, || {
5841 run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", ¶ms)
5842 })
5843 .expect_err("must error when group count exceeds cap");
5844 assert!(
5845 err.to_ascii_lowercase().contains("group count"),
5846 "error must mention group count, got: {err}"
5847 );
5848 }
5849
5850 #[test]
5851 fn multi_aggregate_no_keys() {
5852 let mut fx = Fx::new();
5855 fx.add("N", "a", vec![]);
5856 fx.add("N", "b", vec![]);
5857 let v = fx.view();
5858 let params = BTreeMap::new();
5859
5860 let rs = run(&v, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", ¶ms)
5861 .expect("multi-aggregate no keys must succeed");
5862 assert_eq!(rs.len(), 1, "must produce exactly one result row");
5863 assert_eq!(rs.row(0)[0], Some(i(2)), "COUNT(*) must be 2");
5864 assert_eq!(rs.row(0)[1], Some(i(2)), "COUNT(n) must be 2");
5865 }
5866
5867 #[test]
5868 fn aggregate_over_hop_counts_edges() {
5869 let fx = hop_graph();
5870 let v = fx.view();
5871 let params = BTreeMap::new();
5872 let rs = run(
5874 &v,
5875 "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN COUNT(*)",
5876 ¶ms,
5877 )
5878 .expect("COUNT(*) on hop graph");
5879 assert_eq!(rs.row(0), &[Some(i(3))]);
5880 }
5881
5882 #[test]
5887 fn multi_aggregate_no_keys_empty_graph() {
5888 let fx = Fx::new(); let v = fx.view();
5894 let params = BTreeMap::new();
5895
5896 let rs = run(&v, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", ¶ms)
5897 .expect("empty-graph multi-agg must succeed");
5898 assert_eq!(rs.len(), 1, "must produce exactly 1 row on empty input");
5899 assert_eq!(
5900 rs.row(0)[0],
5901 Some(i(0)),
5902 "COUNT(*) on empty graph must be 0"
5903 );
5904 assert_eq!(
5905 rs.row(0)[1],
5906 Some(i(0)),
5907 "COUNT(n) on empty graph must be 0"
5908 );
5909 }
5910
5911 #[test]
5912 fn fast_path_and_group_path_agree_on_empty_input() {
5913 let fx = Fx::new();
5919 let v = fx.view();
5920 let params = BTreeMap::new();
5921
5922 let fast = run(&v, "MATCH (n:N) RETURN COUNT(*)", ¶ms)
5923 .expect("fast-path COUNT on empty graph");
5924 assert_eq!(fast.len(), 1, "fast path: 1 row on empty input");
5925 let fast_count = fast.row(0)[0].clone();
5926
5927 let grouped = run(&v, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", ¶ms)
5928 .expect("group-path COUNT on empty graph");
5929 assert_eq!(grouped.len(), 1, "group path: 1 row on empty input");
5930 let group_count = grouped.row(0)[0].clone();
5931
5932 assert_eq!(
5933 fast_count, group_count,
5934 "fast path and group path must agree on COUNT(*) for empty input"
5935 );
5936
5937 let mut fx2 = Fx::new();
5939 fx2.add("N", "x", vec![]);
5940 fx2.add("N", "y", vec![]);
5941 let v2 = fx2.view();
5942
5943 let fast2 = run(&v2, "MATCH (n:N) RETURN COUNT(*)", ¶ms)
5944 .expect("fast-path COUNT on 2-node graph");
5945 assert_eq!(fast2.row(0)[0], Some(i(2)), "fast path: COUNT(*) = 2");
5946
5947 let grouped2 = run(&v2, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", ¶ms)
5948 .expect("group-path COUNT on 2-node graph");
5949 assert_eq!(grouped2.row(0)[0], Some(i(2)), "group path: COUNT(*) = 2");
5950 assert_eq!(grouped2.row(0)[1], Some(i(2)), "group path: COUNT(n) = 2");
5951 }
5952
5953 #[test]
5958 fn grouped_int_float_key_unification() {
5959 let mut fx = Fx::new();
5966 fx.add("N", "a", vec![("score", i(1))]); fx.add("N", "b", vec![("score", f(1.0))]);
5968 let v = fx.view();
5969 let params = BTreeMap::new();
5970
5971 let rs = run(&v, "MATCH (n:N) RETURN n.score, COUNT(*) AS cnt", ¶ms)
5972 .expect("int/float unification must succeed");
5973 assert_eq!(
5974 rs.len(),
5975 1,
5976 "Int(1) and Float(1.0) must group together into 1 group"
5977 );
5978 assert_eq!(
5980 rs.row(0)[0],
5981 Some(i(1)),
5982 "key column must display Int(1) (first-seen)"
5983 );
5984 assert_eq!(rs.row(0)[1], Some(i(2)), "unified group must have count=2");
5985 }
5986
5987 #[test]
5988 fn distinct_collapses_duplicate_projected_values() {
5989 let mut fx = Fx::new();
5990 fx.add("N", "a1", vec![("city", s("Austin"))]);
5991 fx.add("N", "a2", vec![("city", s("Austin"))]);
5992 let v = fx.view();
5993 let params = BTreeMap::new();
5994 let rs = run(&v, "MATCH (n:N) RETURN DISTINCT n.city", ¶ms).unwrap();
5995 assert_eq!(rs.len(), 1);
5996 assert_eq!(rs.row(0)[0], Some(s("Austin")));
5997 }
5998
5999 #[test]
6000 fn distinct_int_float_unify() {
6001 let mut fx = Fx::new();
6002 fx.add("N", "a", vec![("score", i(1))]);
6003 fx.add("N", "b", vec![("score", f(1.0))]);
6004 let v = fx.view();
6005 let params = BTreeMap::new();
6006 let rs = run(&v, "MATCH (n:N) RETURN DISTINCT n.score", ¶ms).unwrap();
6007 assert_eq!(
6008 rs.len(),
6009 1,
6010 "Int(1) and Float(1.0) must DISTINCT as one row"
6011 );
6012 assert_eq!(rs.row(0)[0], Some(i(1)), "first-seen Int(1) wins display");
6013 }
6014
6015 #[test]
6016 fn distinct_caps_at_intermediate_row_budget() {
6017 let mut fx = Fx::new();
6018 fx.add("N", "a", vec![("city", s("Austin"))]);
6019 fx.add("N", "b", vec![("city", s("Paris"))]);
6020 let v = fx.view();
6021 let params = BTreeMap::new();
6022 let err = super::with_max_intermediate_rows(1, || {
6023 run(&v, "MATCH (n:N) RETURN DISTINCT n.city", ¶ms)
6024 })
6025 .expect_err("two distinct cities must exceed cap=1");
6026 assert!(
6027 err.contains("1") || err.contains("row"),
6028 "cap error must mention the budget, got: {err}"
6029 );
6030 }
6031
6032 #[test]
6033 fn where_in_list_filters_rows() {
6034 let mut fx = Fx::new();
6035 fx.add("N", "a", vec![("city", s("Austin"))]);
6036 fx.add("N", "p", vec![("city", s("Paris"))]);
6037 fx.add("N", "l", vec![("city", s("London"))]);
6038 let v = fx.view();
6039 let mut params = BTreeMap::new();
6040 params.insert("c".into(), s("Paris"));
6041 let rs = run(
6042 &v,
6043 "MATCH (n:N) WHERE n.city IN ['Austin', $c] RETURN n.city",
6044 ¶ms,
6045 )
6046 .unwrap();
6047 assert_eq!(rs.len(), 2);
6048 }
6049
6050 #[test]
6051 fn pure_int_keys_display_as_int() {
6052 let mut fx = Fx::new();
6057 fx.add("N", "a", vec![("age", i(10))]);
6058 fx.add("N", "b", vec![("age", i(20))]);
6059 fx.add("N", "c", vec![("age", i(10))]);
6060 let v = fx.view();
6061 let params = BTreeMap::new();
6062
6063 let rs = run(
6064 &v,
6065 "MATCH (n:N) RETURN n.age, COUNT(*) AS cnt ORDER BY n.age",
6066 ¶ms,
6067 )
6068 .expect("pure-Int group keys must succeed");
6069 assert_eq!(rs.len(), 2, "must have 2 groups: age=10 and age=20");
6070 assert_eq!(
6072 rs.row(0)[0],
6073 Some(i(10)),
6074 "age=10 key column must display as Int(10)"
6075 );
6076 assert_eq!(rs.row(0)[1], Some(i(2)), "age=10 group has 2 nodes");
6077 assert_eq!(
6078 rs.row(1)[0],
6079 Some(i(20)),
6080 "age=20 key column must display as Int(20)"
6081 );
6082 assert_eq!(rs.row(1)[1], Some(i(1)), "age=20 group has 1 node");
6083 }
6084
6085 #[test]
6090 fn var_expand_group_aggregate_staged_path() {
6091 let mut fx = Fx::new();
6101 let a = fx.add("N", "a", vec![("key", s("a"))]);
6102 let b = fx.add("N", "b", vec![("key", s("b"))]);
6103 let c = fx.add("N", "c", vec![("key", s("c"))]);
6104 fx.edge("T", a, b, vec![]);
6105 fx.edge("T", b, c, vec![]);
6106 let v = fx.view();
6107 let params = BTreeMap::new();
6108
6109 let rs = run(
6110 &v,
6111 "MATCH (x:N)-[*1..2]->(y:N) RETURN y.key, COUNT(*) AS cnt ORDER BY y.key",
6112 ¶ms,
6113 )
6114 .expect("VarExpand + GroupAggregate must succeed");
6115
6116 assert_eq!(rs.len(), 2, "must have 2 destination groups: b and c");
6117 assert_eq!(rs.row(0)[0], Some(s("b")), "first group key must be 'b'");
6118 assert_eq!(rs.row(0)[1], Some(i(1)), "b is reached via 1 path");
6119 assert_eq!(rs.row(1)[0], Some(s("c")), "second group key must be 'c'");
6120 assert_eq!(
6121 rs.row(1)[1],
6122 Some(i(2)),
6123 "c is reached via 2 paths (1-hop and 2-hop)"
6124 );
6125 }
6126
6127 #[test]
6135 fn pull_rows_var_expand_arm_returns_named_err() {
6136 let fx = Fx::new();
6137 let view = fx.view();
6138 let vars = super::VarTable {
6139 names: vec!["a".into(), "b".into()],
6140 };
6141 let project_items: Vec<crate::cypher::ast::RetItem> = vec![];
6142 let empty_params = BTreeMap::new();
6143 let params = super::Params(&empty_params);
6144 let ctx = super::PullCtx {
6145 view: &view,
6146 vars: &vars,
6147 project_items: &project_items,
6148 params: ¶ms,
6149 bound: 100,
6150 };
6151 let ops = vec![PlanOp::VarExpand {
6152 from: "a".into(),
6153 rel_var: None,
6154 etypes: vec![],
6155 dir: crate::cypher::RelDir::Right,
6156 to: "b".into(),
6157 min: 1,
6158 max: 3,
6159 }];
6160 let mut row = vec![None; vars.names.len()];
6161 let mut result = Vec::new();
6162 let err = super::pull_rows(&ctx, &ops, &mut row, &mut result)
6163 .expect_err("VarExpand must Err in pull_rows");
6164 assert!(
6165 err.contains("VarExpand") && err.contains("pull executor"),
6166 "error must name VarExpand and pull executor, got: {err}"
6167 );
6168 }
6169
6170 #[test]
6171 fn pull_rows_shortest_path_arm_returns_named_err() {
6172 let fx = Fx::new();
6173 let view = fx.view();
6174 let vars = super::VarTable {
6175 names: vec!["a".into(), "b".into()],
6176 };
6177 let project_items: Vec<crate::cypher::ast::RetItem> = vec![];
6178 let empty_params = BTreeMap::new();
6179 let params = super::Params(&empty_params);
6180 let ctx = super::PullCtx {
6181 view: &view,
6182 vars: &vars,
6183 project_items: &project_items,
6184 params: ¶ms,
6185 bound: 100,
6186 };
6187 let ops = vec![PlanOp::ShortestPath {
6188 from: "a".into(),
6189 rel_var: None,
6190 etypes: vec![],
6191 dir: crate::cypher::RelDir::Right,
6192 to: "b".into(),
6193 max_hops: 5,
6194 }];
6195 let mut row = vec![None; vars.names.len()];
6196 let mut result = Vec::new();
6197 let err = super::pull_rows(&ctx, &ops, &mut row, &mut result)
6198 .expect_err("ShortestPath must Err in pull_rows");
6199 assert!(
6200 err.contains("ShortestPath") && err.contains("pull executor"),
6201 "error must name ShortestPath and pull executor, got: {err}"
6202 );
6203 }
6204
6205 fn city_graph() -> Fx {
6209 let mut fx = Fx::new();
6210 fx.add(
6211 "Person",
6212 "alice",
6213 vec![("city", s("Boston")), ("age", i(30))],
6214 );
6215 fx.add("Person", "bob", vec![("city", s("Boston")), ("age", i(25))]);
6216 fx.add(
6217 "Person",
6218 "carol",
6219 vec![("city", s("Austin")), ("age", i(35))],
6220 );
6221 fx.add(
6222 "Person",
6223 "dave",
6224 vec![("city", s("Austin")), ("age", i(28))],
6225 );
6226 fx.add("Person", "eve", vec![("city", s("Boston")), ("age", i(22))]);
6227 fx
6228 }
6229
6230 #[test]
6233 fn with_aggregate_having_filters_groups() {
6234 let fx = city_graph();
6235 let view = fx.view();
6236 let params = BTreeMap::new();
6237 let rs = run(
6238 &view,
6239 "MATCH (p:Person) WITH p.city AS city, COUNT(*) AS cnt WHERE cnt > 2 RETURN city, cnt",
6240 ¶ms,
6241 )
6242 .expect("WITH HAVING query must succeed");
6243 assert_eq!(rs.len(), 1, "only Boston group has cnt > 2");
6244 assert_eq!(rs.get(0, "city"), Some(&s("Boston")));
6245 assert_eq!(rs.get(0, "cnt"), Some(&i(3)));
6246 }
6247
6248 #[test]
6250 fn with_order_limit_then_return() {
6251 let fx = city_graph();
6252 let view = fx.view();
6253 let params = BTreeMap::new();
6254 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)
6256 .expect("WITH ORDER LIMIT must succeed");
6257 assert_eq!(rs.len(), 2, "LIMIT 2");
6258 let ages: Vec<Option<Value>> = (0..rs.len()).map(|i| rs.get(i, "age").cloned()).collect();
6260 assert_eq!(ages[0], Some(i(35)), "oldest person first");
6261 assert_eq!(ages[1], Some(i(30)), "second oldest");
6262 }
6263
6264 #[test]
6266 fn chained_with_stages() {
6267 let fx = city_graph();
6268 let view = fx.view();
6269 let params = BTreeMap::new();
6270 let rs = run(&view,
6272 "MATCH (p:Person) WITH p.city AS city, p.age AS age WITH city, age WHERE age > 25 RETURN city, age",
6273 ¶ms).expect("chained WITH must succeed");
6274 assert_eq!(rs.len(), 3, "3 people with age > 25");
6276 }
6277
6278 #[test]
6280 fn with_then_match_reentry() {
6281 let mut fx = Fx::new();
6282 let alice = fx.add("Person", "alice", vec![]);
6283 let bob = fx.add("Person", "bob", vec![]);
6284 let corp = fx.add("Company", "acme", vec![]);
6285 fx.edge("WORKS_AT", alice, corp, vec![("years", i(5))]);
6286 fx.edge("WORKS_AT", bob, corp, vec![("years", i(3))]);
6287 let view = fx.view();
6288 let params = BTreeMap::new();
6289 let rs = run(&view,
6291 "MATCH (p:Person)-[r:WORKS_AT]->(c:Company) WITH p, c MATCH (c)-[r2:WORKS_AT]-(colleague:Person) RETURN p, colleague",
6292 ¶ms).expect("WITH MATCH re-entry must succeed");
6293 assert!(rs.len() >= 2, "cross join via company: got {}", rs.len());
6295 }
6296
6297 #[test]
6299 fn unwind_literal_list_produces_rows() {
6300 let fx = city_graph();
6301 let view = fx.view();
6302 let params = BTreeMap::new();
6303 let rs = run(
6304 &view,
6305 "MATCH (p:Person) WHERE p.city = 'Boston' UNWIND [1, 2, 3] AS x RETURN p, x",
6306 ¶ms,
6307 )
6308 .expect("UNWIND literal must succeed");
6309 assert_eq!(rs.len(), 9, "UNWIND [1,2,3] × 3 Boston people = 9 rows");
6311 let xs: Vec<Option<Value>> = (0..rs.len())
6313 .map(|row_i| rs.get(row_i, "x").cloned())
6314 .collect();
6315 assert!(xs.contains(&Some(i(1))));
6316 assert!(xs.contains(&Some(i(2))));
6317 assert!(xs.contains(&Some(i(3))));
6318 }
6319
6320 #[test]
6322 fn unwind_list_property() {
6323 let mut fx = Fx::new();
6324 fx.add(
6325 "Tag",
6326 "post1",
6327 vec![("tags", Value::List(vec![s("rust"), s("graph")]))],
6328 );
6329 fx.add("Tag", "post2", vec![("tags", Value::List(vec![s("db")]))]);
6330 let view = fx.view();
6331 let params = BTreeMap::new();
6332 let rs = run(
6333 &view,
6334 "MATCH (p:Tag) UNWIND p.tags AS tag RETURN p, tag",
6335 ¶ms,
6336 )
6337 .expect("UNWIND property must succeed");
6338 assert_eq!(rs.len(), 3, "2+1 tag elements");
6339 }
6340
6341 #[test]
6343 fn unwind_empty_list_yields_zero_rows() {
6344 let fx = city_graph();
6345 let view = fx.view();
6346 let params = BTreeMap::new();
6347 let rs = run(
6348 &view,
6349 "MATCH (p:Person) WHERE p.city = 'Boston' UNWIND [] AS x RETURN x",
6350 ¶ms,
6351 )
6352 .expect("UNWIND [] must succeed with 0 rows");
6353 assert_eq!(rs.len(), 0, "UNWIND [] should produce 0 rows");
6354 }
6355
6356 #[test]
6358 fn unwind_non_list_is_named_error() {
6359 let fx = city_graph();
6360 let view = fx.view();
6361 let params = BTreeMap::new();
6362 let err = run(
6364 &view,
6365 "MATCH (p:Person) WHERE p.city = 'Boston' UNWIND p.city AS x RETURN x",
6366 ¶ms,
6367 )
6368 .expect_err("UNWIND non-list must error");
6369 assert!(
6370 err.contains("UNWIND") && err.contains("list"),
6371 "error must mention UNWIND and list: {err}"
6372 );
6373 }
6374
6375 #[test]
6377 fn unwind_cross_product_trips_budget() {
6378 let mut fx = Fx::new();
6379 for i in 0..10 {
6381 fx.add("N", &format!("n{i}"), vec![]);
6382 }
6383 let view = fx.view();
6384 let params = BTreeMap::new();
6385 let err = super::with_max_intermediate_rows(5, || {
6386 run(
6387 &view,
6388 "MATCH (n:N) UNWIND [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] AS x RETURN n, x",
6389 ¶ms,
6390 )
6391 })
6392 .expect_err("must hit budget");
6393 assert!(
6394 err.contains("intermediate result exceeds"),
6395 "error must mention intermediate result limit: {err}"
6396 );
6397 }
6398
6399 #[test]
6402 fn routing_with_plan_uses_staged_path() {
6403 use crate::cypher::plan::row_bound;
6404 let ops_with = compile("MATCH (a) WITH a RETURN a");
6405 assert_eq!(
6406 row_bound(&ops_with),
6407 None,
6408 "WITH plan must have row_bound=None"
6409 );
6410
6411 let ops_unwind = compile("MATCH (a) UNWIND [1, 2] AS x RETURN a");
6412 assert_eq!(
6413 row_bound(&ops_unwind),
6414 None,
6415 "UNWIND plan must have row_bound=None"
6416 );
6417
6418 let ops_plain = compile("MATCH (a) RETURN a LIMIT 5");
6420 assert!(
6421 row_bound(&ops_plain).is_some(),
6422 "plain LIMIT plan must have a row bound"
6423 );
6424 }
6425
6426 #[test]
6429 fn unwind_null_property_yields_zero_rows_for_that_node() {
6430 let mut fx = Fx::new();
6431 fx.add(
6433 "Post",
6434 "post1",
6435 vec![("tags", Value::List(vec![s("rust"), s("graph")]))],
6436 );
6437 fx.add("Post", "post2", vec![("other", Value::Str("hello".into()))]);
6438 let view = fx.view();
6439 let params = BTreeMap::new();
6440 let rs = run(
6441 &view,
6442 "MATCH (p:Post) UNWIND p.tags AS tag RETURN tag",
6443 ¶ms,
6444 )
6445 .expect("UNWIND null property must succeed (not error)");
6446 assert_eq!(
6448 rs.len(),
6449 2,
6450 "null property must yield 0 rows; list property yields N rows"
6451 );
6452 let tag0 = rs.get(0, "tag");
6453 let tag1 = rs.get(1, "tag");
6454 let tags = [tag0, tag1];
6455 assert!(tags.contains(&Some(&s("rust"))), "rust tag present");
6456 assert!(tags.contains(&Some(&s("graph"))), "graph tag present");
6457 }
6458
6459 #[test]
6461 fn where_before_unwind_filters_nodes() {
6462 let mut fx = Fx::new();
6463 fx.add(
6464 "P",
6465 "a",
6466 vec![
6467 ("group", Value::Str("keep".into())),
6468 ("items", Value::List(vec![Value::Int(1), Value::Int(2)])),
6469 ],
6470 );
6471 fx.add(
6472 "P",
6473 "b",
6474 vec![
6475 ("group", Value::Str("drop".into())),
6476 ("items", Value::List(vec![Value::Int(3), Value::Int(4)])),
6477 ],
6478 );
6479 let view = fx.view();
6480 let params = BTreeMap::new();
6481 let rs = run(
6483 &view,
6484 "MATCH (n:P) WHERE n.group = 'keep' UNWIND n.items AS x RETURN x",
6485 ¶ms,
6486 )
6487 .expect("WHERE before UNWIND must succeed");
6488 assert_eq!(rs.len(), 2, "only matching node expands; 2 items");
6489 }
6490
6491 #[test]
6493 fn where_after_unwind_filters_expanded_rows() {
6494 let mut fx = Fx::new();
6495 fx.add(
6496 "N",
6497 "n1",
6498 vec![(
6499 "vals",
6500 Value::List(vec![Value::Int(1), Value::Int(3), Value::Int(5)]),
6501 )],
6502 );
6503 let view = fx.view();
6504 let params = BTreeMap::new();
6505 let rs = run(
6507 &view,
6508 "MATCH (n:N) UNWIND n.vals AS x WHERE x > 2 RETURN x",
6509 ¶ms,
6510 )
6511 .expect("WHERE after UNWIND must succeed");
6512 assert_eq!(rs.len(), 2, "x=3 and x=5 pass; x=1 filtered out");
6513 let v0 = rs.get(0, "x").cloned();
6514 let v1 = rs.get(1, "x").cloned();
6515 let vals = [v0, v1];
6516 assert!(vals.contains(&Some(Value::Int(3))), "x=3 present");
6517 assert!(vals.contains(&Some(Value::Int(5))), "x=5 present");
6518 }
6519
6520 #[test]
6522 fn aggregate_with_order_by_unknown_alias_is_named_error() {
6523 use crate::cypher::{lex, parser::parse, plan::plan};
6524 let src = "MATCH (p:Person) WITH p.city AS city, COUNT(*) AS cnt ORDER BY cntt DESC RETURN city, cnt";
6526 let plan_result = plan(&parse(&lex(src).expect("lex")).expect("parse"));
6527 let err = plan_result
6528 .expect_err("typo'd ORDER BY alias in aggregate WITH must be a named plan error");
6529 assert!(
6530 err.contains("cntt") || err.contains("unbound"),
6531 "error must mention the unknown alias: {err}"
6532 );
6533 }
6534
6535 #[test]
6537 fn non_aggregate_with_order_by_unknown_alias_is_named_error() {
6538 use crate::cypher::{lex, parser::parse, plan::plan};
6539 let src = "MATCH (p:Person) WITH p, p.age AS age ORDER BY nope DESC RETURN p.city AS city";
6540 let plan_result = plan(&parse(&lex(src).expect("lex")).expect("parse"));
6541 let err = plan_result
6542 .expect_err("typo'd ORDER BY alias in non-aggregate WITH must be a named plan error");
6543 assert!(
6544 err.contains("nope") || err.contains("unbound"),
6545 "error must mention the unknown alias: {err}"
6546 );
6547 }
6548
6549 #[test]
6553 fn post_unwind_where_references_pre_match_variable() {
6554 let mut fx = Fx::new();
6555 fx.add(
6557 "N",
6558 "n1",
6559 vec![
6560 ("threshold", Value::Int(3)),
6561 (
6562 "xs",
6563 Value::List(vec![
6564 Value::Int(1),
6565 Value::Int(2),
6566 Value::Int(3),
6567 Value::Int(4),
6568 Value::Int(5),
6569 ]),
6570 ),
6571 ],
6572 );
6573 fx.add(
6575 "N",
6576 "n2",
6577 vec![
6578 ("threshold", Value::Int(10)),
6579 ("xs", Value::List(vec![Value::Int(1), Value::Int(2)])),
6580 ],
6581 );
6582 let view = fx.view();
6583 let params = BTreeMap::new();
6584 let rs = run(
6585 &view,
6586 "MATCH (n:N) UNWIND n.xs AS x WHERE x > n.threshold RETURN x",
6587 ¶ms,
6588 )
6589 .expect("post-UNWIND WHERE referencing MATCH variable must succeed");
6590 assert_eq!(rs.len(), 2, "exactly 2 rows: x=4 and x=5 from n1");
6592 let v0 = rs.get(0, "x").cloned();
6593 let v1 = rs.get(1, "x").cloned();
6594 let got = [v0, v1];
6595 assert!(got.contains(&Some(Value::Int(4))), "x=4 present");
6596 assert!(got.contains(&Some(Value::Int(5))), "x=5 present");
6597 }
6598
6599 #[test]
6603 fn with_stage_aggregate_order_by_descending() {
6604 let fx = city_graph();
6607 let view = fx.view();
6608 let params = BTreeMap::new();
6609 let rs = run(
6610 &view,
6611 "MATCH (p:Person) WITH p.city AS city, COUNT(*) AS cnt ORDER BY cnt DESC RETURN city, cnt",
6612 ¶ms,
6613 )
6614 .expect("aggregate WITH ORDER BY must succeed");
6615 assert_eq!(rs.len(), 2, "two city groups");
6616 assert_eq!(
6618 rs.get(0, "cnt"),
6619 Some(&Value::Int(3)),
6620 "first row must be the group with cnt=3 (Boston)"
6621 );
6622 assert_eq!(
6623 rs.get(1, "cnt"),
6624 Some(&Value::Int(2)),
6625 "second row must be the group with cnt=2 (Austin)"
6626 );
6627 }
6628
6629 #[test]
6633 fn unwind_then_with_composition() {
6634 let mut fx = Fx::new();
6635 fx.add(
6636 "Doc",
6637 "d1",
6638 vec![(
6639 "scores",
6640 Value::List(vec![Value::Int(10), Value::Int(20), Value::Int(30)]),
6641 )],
6642 );
6643 fx.add(
6644 "Doc",
6645 "d2",
6646 vec![("scores", Value::List(vec![Value::Int(5), Value::Int(15)]))],
6647 );
6648 let view = fx.view();
6649 let params = BTreeMap::new();
6650
6651 let rs = run(
6653 &view,
6654 "MATCH (n:Doc) UNWIND n.scores AS x WITH x RETURN x",
6655 ¶ms,
6656 )
6657 .expect("UNWIND then WITH pass-through must succeed");
6658 assert_eq!(rs.len(), 5, "3 + 2 = 5 expanded rows carried through WITH");
6659
6660 let rs2 = run(
6662 &view,
6663 "MATCH (n:Doc) UNWIND n.scores AS x WITH COUNT(*) AS total RETURN total",
6664 ¶ms,
6665 )
6666 .expect("UNWIND then aggregate WITH must succeed");
6667 assert_eq!(rs2.len(), 1, "single aggregate row");
6668 assert_eq!(
6669 rs2.get(0, "total"),
6670 Some(&Value::Int(5)),
6671 "total must be 5 (3+2 expanded rows)"
6672 );
6673 }
6674
6675 #[test]
6681 fn binarith_div_min_over_neg1_does_not_panic() {
6682 let fx = Fx::new();
6683 let view = fx.view();
6684 let vars = VarTable { names: vec![] };
6685 let row: Row = vec![];
6686 let params = BTreeMap::new();
6687
6688 let operand = Operand::BinArith {
6690 op: ArithOp::Div,
6691 left: Box::new(Operand::Lit(Value::Int(i64::MIN))),
6692 right: Box::new(Operand::Lit(Value::Int(-1))),
6693 };
6694
6695 let result = resolve_operand(&view, &vars, &row, &operand, &Params(¶ms));
6696 assert!(
6697 result.is_ok(),
6698 "overflow division must not return Err: {result:?}"
6699 );
6700 assert_eq!(
6701 result.unwrap(),
6702 Some(Value::Int(i64::MAX)),
6703 "i64::MIN / -1 must saturate to i64::MAX, not panic"
6704 );
6705 }
6706
6707 #[test]
6712 fn binarith_missing_param_caught_at_preflight() {
6713 let fx = Fx::new();
6714 let view = fx.view();
6715 let err = run(
6720 &view,
6721 "MATCH (n:X) RETURN abs($missing - 1)",
6722 &BTreeMap::new(),
6723 )
6724 .expect_err("missing param inside BinArith must be caught at preflight");
6725 assert!(
6726 err.contains("missing"),
6727 "error must name the param 'missing', got: {err}"
6728 );
6729 }
6730
6731 #[test]
6735 fn is_null_filters_absent_prop() {
6736 let mut fx = Fx::new();
6737 fx.add("Person", "alice", vec![("age", Value::Int(30))]);
6738 fx.add("Person", "bob", vec![]); let view = fx.view();
6740 let rs = run(
6741 &view,
6742 "MATCH (n:Person) WHERE n.age IS NULL RETURN n",
6743 &BTreeMap::new(),
6744 )
6745 .unwrap();
6746 assert_eq!(rs.len(), 1, "only bob lacks age");
6747 assert_eq!(rs.get(0, "n"), Some(&s("bob")));
6748 }
6749
6750 #[test]
6752 fn is_not_null_filters_present_prop() {
6753 let mut fx = Fx::new();
6754 fx.add("Person", "alice", vec![("age", Value::Int(30))]);
6755 fx.add("Person", "bob", vec![]); let view = fx.view();
6757 let rs = run(
6758 &view,
6759 "MATCH (n:Person) WHERE n.age IS NOT NULL RETURN n",
6760 &BTreeMap::new(),
6761 )
6762 .unwrap();
6763 assert_eq!(rs.len(), 1, "only alice has age");
6764 assert_eq!(rs.get(0, "n"), Some(&s("alice")));
6765 }
6766
6767 #[test]
6770 fn optional_match_is_null_anti_join() {
6771 let mut fx = Fx::new();
6772 let alice = fx.add("Person", "alice", vec![]);
6773 let bob = fx.add("Person", "bob", vec![]);
6774 let carol = fx.add("Person", "carol", vec![]);
6775 fx.edge("KNOWS", alice, carol, vec![]); fx.edge("KNOWS", carol, bob, vec![]); let view = fx.view();
6779 let rs = run(
6780 &view,
6781 "MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b) WITH a, b WHERE b IS NULL RETURN a",
6782 &BTreeMap::new(),
6783 )
6784 .unwrap();
6785 assert_eq!(rs.len(), 1, "only bob has no outgoing KNOWS edge");
6786 assert_eq!(rs.get(0, "a"), Some(&s("bob")));
6787 }
6788
6789 #[test]
6791 fn is_null_combined_with_and_exec() {
6792 let mut fx = Fx::new();
6793 fx.add("N", "a", vec![("x", Value::Int(1))]);
6794 fx.add("N", "b", vec![("x", Value::Int(2)), ("y", Value::Int(9))]);
6795 fx.add("N", "c", vec![]); let view = fx.view();
6797 let rs = run(
6799 &view,
6800 "MATCH (n:N) WHERE n.y IS NULL AND n.x IS NOT NULL RETURN n",
6801 &BTreeMap::new(),
6802 )
6803 .unwrap();
6804 assert_eq!(rs.len(), 1);
6805 assert_eq!(rs.get(0, "n"), Some(&s("a")));
6806 }
6807
6808 #[test]
6812 fn arithmetic_add_in_return() {
6813 let mut fx = Fx::new();
6814 fx.add("Person", "alice", vec![("age", Value::Int(29))]);
6815 let view = fx.view();
6816 let rs = run(
6817 &view,
6818 "MATCH (n:Person) RETURN n.age + 1 AS adjusted",
6819 &BTreeMap::new(),
6820 )
6821 .unwrap();
6822 assert_eq!(rs.len(), 1);
6823 assert_eq!(rs.get(0, "adjusted"), Some(&Value::Int(30)));
6824 }
6825
6826 #[test]
6828 fn arithmetic_precedence_parens_pin() {
6829 let mut fx = Fx::new();
6830 fx.add("N", "x", vec![]);
6831 let view = fx.view();
6832 let rs = run(
6833 &view,
6834 "MATCH (n:N) RETURN (1 + 2) * 3 AS r",
6835 &BTreeMap::new(),
6836 )
6837 .unwrap();
6838 assert_eq!(rs.len(), 1);
6839 assert_eq!(rs.get(0, "r"), Some(&Value::Int(9)));
6840 }
6841
6842 #[test]
6844 fn arithmetic_precedence_mul_over_add_pin() {
6845 let mut fx = Fx::new();
6846 fx.add("N", "x", vec![]);
6847 let view = fx.view();
6848 let rs = run(&view, "MATCH (n:N) RETURN 1 + 2 * 3 AS r", &BTreeMap::new()).unwrap();
6849 assert_eq!(rs.len(), 1);
6850 assert_eq!(rs.get(0, "r"), Some(&Value::Int(7)));
6851 }
6852
6853 #[test]
6855 fn arithmetic_div_by_zero_returns_error() {
6856 let mut fx = Fx::new();
6857 fx.add("N", "x", vec![]);
6858 let view = fx.view();
6859 let err = run(
6860 &view,
6861 "MATCH (n:N) WHERE 1 / 0 > 0 RETURN n",
6862 &BTreeMap::new(),
6863 )
6864 .expect_err("division by zero must error");
6865 assert!(
6866 err.contains("division by zero"),
6867 "error must mention division by zero, got: {err}"
6868 );
6869 }
6870
6871 #[test]
6873 fn arithmetic_null_propagates() {
6874 let mut fx = Fx::new();
6875 fx.add("N", "x", vec![]); let view = fx.view();
6877 let rs = run(
6879 &view,
6880 "MATCH (n:N) WHERE n.val + 1 > 0 RETURN n",
6881 &BTreeMap::new(),
6882 )
6883 .unwrap();
6884 assert_eq!(rs.len(), 0, "null arithmetic must not match");
6885 }
6886
6887 #[test]
6889 fn case_when_expression_in_return() {
6890 let mut fx = Fx::new();
6891 fx.add("N", "a", vec![("id", s("a")), ("age", i(20))]);
6892 fx.add("N", "b", vec![("id", s("b")), ("age", i(65))]);
6893 let v = fx.view();
6894 let rs = run(
6895 &v,
6896 "MATCH (n:N) RETURN n.id AS id, \
6897 CASE WHEN n.age >= 65 THEN 'senior' ELSE 'other' END AS band",
6898 &BTreeMap::new(),
6899 )
6900 .unwrap();
6901 let band_of = |who: &str| {
6902 (0..rs.len())
6903 .find(|&i| rs.get(i, "id") == Some(&s(who)))
6904 .and_then(|i| rs.get(i, "band").cloned())
6905 };
6906 assert_eq!(band_of("a"), Some(s("other")));
6907 assert_eq!(band_of("b"), Some(s("senior")));
6908 }
6909
6910 #[test]
6911 fn case_when_no_else_yields_null() {
6912 let mut fx = Fx::new();
6913 fx.add("N", "a", vec![("age", i(20))]);
6914 let v = fx.view();
6915 let rs = run(
6916 &v,
6917 "MATCH (n:N) RETURN CASE WHEN n.age >= 65 THEN 'senior' END AS band",
6918 &BTreeMap::new(),
6919 )
6920 .unwrap();
6921 assert_eq!(rs.get(0, "band"), None);
6922 }
6923
6924 #[test]
6925 fn multi_relationship_type_pattern_matches_either() {
6926 let mut fx = Fx::new();
6927 let a = fx.add("N", "a", vec![("id", s("a"))]);
6928 let b = fx.add("N", "b", vec![("id", s("b"))]);
6929 let c = fx.add("N", "c", vec![("id", s("c"))]);
6930 let d = fx.add("N", "d", vec![("id", s("d"))]);
6931 fx.edge("KNOWS", a, b, vec![]);
6932 fx.edge("LIKES", a, c, vec![]);
6933 fx.edge("HATES", a, d, vec![]);
6934 let v = fx.view();
6935 let rs = run(
6937 &v,
6938 "MATCH (a:N {id: 'a'})-[r:KNOWS|:LIKES]->(x) RETURN x",
6939 &BTreeMap::new(),
6940 )
6941 .unwrap();
6942 assert_eq!(rs.len(), 2, "KNOWS|LIKES reaches exactly b and c");
6943 }
6944
6945 #[test]
6946 fn collect_grouped_gathers_values_per_group() {
6947 let mut fx = Fx::new();
6948 fx.add("P", "a", vec![("city", s("austin")), ("name", s("Ann"))]);
6949 fx.add("P", "b", vec![("city", s("austin")), ("name", s("Bob"))]);
6950 fx.add("P", "c", vec![("city", s("boston")), ("name", s("Cy"))]);
6951 let v = fx.view();
6952 let rs = run(
6953 &v,
6954 "MATCH (n:P) RETURN n.city AS city, collect(n.name) AS names",
6955 &BTreeMap::new(),
6956 )
6957 .unwrap();
6958 assert_eq!(rs.len(), 2, "two city groups");
6959 let austin = (0..rs.len())
6961 .find(|&i| rs.get(i, "city") == Some(&s("austin")))
6962 .expect("austin group present");
6963 assert_eq!(
6964 rs.get(austin, "names"),
6965 Some(&Value::List(vec![s("Ann"), s("Bob")]))
6966 );
6967 }
6968
6969 #[test]
6970 fn collect_ungrouped_gathers_all_into_one_list() {
6971 let mut fx = Fx::new();
6972 fx.add("P", "a", vec![("name", s("Ann"))]);
6973 fx.add("P", "b", vec![("name", s("Bob"))]);
6974 let v = fx.view();
6975 let rs = run(
6976 &v,
6977 "MATCH (n:P) RETURN collect(n.name) AS names",
6978 &BTreeMap::new(),
6979 )
6980 .unwrap();
6981 assert_eq!(rs.len(), 1);
6982 assert_eq!(
6983 rs.get(0, "names"),
6984 Some(&Value::List(vec![s("Ann"), s("Bob")]))
6985 );
6986 }
6987
6988 #[test]
6989 fn string_predicate_functions_in_where() {
6990 let mut fx = Fx::new();
6991 fx.add("N", "a", vec![("email", s("alice@acme.com"))]);
6992 fx.add("N", "b", vec![("email", s("bob@other.org"))]);
6993 let v = fx.view();
6994 let p = BTreeMap::new();
6995 assert_eq!(
6996 run(
6997 &v,
6998 "MATCH (n:N) WHERE endsWith(n.email, '.com') RETURN n",
6999 &p
7000 )
7001 .unwrap()
7002 .len(),
7003 1
7004 );
7005 assert_eq!(
7006 run(
7007 &v,
7008 "MATCH (n:N) WHERE startsWith(n.email, 'bob') RETURN n",
7009 &p
7010 )
7011 .unwrap()
7012 .len(),
7013 1
7014 );
7015 assert_eq!(
7016 run(
7017 &v,
7018 "MATCH (n:N) WHERE contains(n.email, 'acme') RETURN n",
7019 &p
7020 )
7021 .unwrap()
7022 .len(),
7023 1
7024 );
7025 }
7026
7027 #[test]
7028 fn coercion_functions_in_return() {
7029 let mut fx = Fx::new();
7030 fx.add("N", "a", vec![("s", s("42")), ("n", i(7)), ("g", f(3.9))]);
7031 let v = fx.view();
7032 let rs = run(
7033 &v,
7034 "MATCH (n:N) RETURN toInteger(n.s) AS ti, toFloat(n.n) AS tf, toString(n.g) AS ts",
7035 &BTreeMap::new(),
7036 )
7037 .unwrap();
7038 assert_eq!(rs.get(0, "ti"), Some(&Value::Int(42)));
7039 assert_eq!(rs.get(0, "tf"), Some(&Value::Float(7.0)));
7040 assert_eq!(rs.get(0, "ts"), Some(&Value::Str("3.9".into())));
7041 }
7042
7043 #[test]
7044 fn to_integer_unparseable_string_is_null() {
7045 let mut fx = Fx::new();
7046 fx.add("N", "a", vec![("s", s("not-a-number"))]);
7047 let v = fx.view();
7048 let rs = run(
7049 &v,
7050 "MATCH (n:N) RETURN toInteger(n.s) AS ti",
7051 &BTreeMap::new(),
7052 )
7053 .unwrap();
7054 assert_eq!(rs.get(0, "ti"), None);
7055 }
7056
7057 #[test]
7058 fn index_scan_uses_index_and_matches_fallback() {
7059 use core_storage::property_index::PropertyIndex;
7060 use std::sync::atomic::Ordering;
7061
7062 let mut fx = Fx::new();
7063 let a = fx.add("Person", "a", vec![("city", s("austin"))]);
7064 let _b = fx.add("Person", "b", vec![("city", s("boston"))]);
7065 let c = fx.add("Person", "c", vec![("city", s("austin"))]);
7066
7067 let mut pi = PropertyIndex::new();
7068 pi.enable("Person", "city");
7069 pi.set("Person", "city", a, &s("austin"));
7070 pi.set("Person", "city", 1, &s("boston"));
7071 pi.set("Person", "city", c, &s("austin"));
7072
7073 let q = "MATCH (n:Person {city: 'austin'}) RETURN n";
7074
7075 let before = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7077 let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7078 let after = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7079 assert!(after > before, "IndexScan must take the indexed path");
7080 assert_eq!(indexed.len(), 2);
7081
7082 let before2 = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7085 let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7086 let after2 = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7087 assert_eq!(after2, before2, "fallback must not touch the index counter");
7088 assert_eq!(
7089 fallback.len(),
7090 indexed.len(),
7091 "fallback matches indexed result"
7092 );
7093 }
7094
7095 #[test]
7096 fn arithmetic_in_where_comparison() {
7097 let mut fx = Fx::new();
7098 fx.add("Person", "alice", vec![("age", Value::Int(5))]); fx.add("Person", "bob", vec![("age", Value::Int(4))]); let view = fx.view();
7101 let rs = run(
7102 &view,
7103 "MATCH (n:Person) WHERE n.age + 1 > 5 RETURN n",
7104 &BTreeMap::new(),
7105 )
7106 .unwrap();
7107 assert_eq!(rs.len(), 1);
7108 assert_eq!(rs.get(0, "n"), Some(&s("alice")));
7109 }
7110}