Skip to main content

core_query/cypher/
exec.rs

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