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