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`: integers add exactly as `i64` (an overflow is an error); once a
2656    /// float is seen the result is a float (row 158).
2657    Sum {
2658        ints: i64,
2659        floats: f64,
2660        any_float: bool,
2661        has_value: bool,
2662    },
2663    Avg {
2664        sum: f64,
2665        n: u64,
2666    },
2667    Min(Option<Value>),
2668    Max(Option<Value>),
2669    Collect(Vec<Value>),
2670    /// `DISTINCT` wrapper: forwards to `inner` the first time a value is
2671    /// seen in this group and drops every repeat.
2672    Distinct {
2673        seen: HashSet<Option<ValueKey>>,
2674        inner: Box<AggAcc>,
2675    },
2676}
2677
2678impl AggAcc {
2679    /// The accumulator an aggregate needs given the argument it was written
2680    /// with — a `DISTINCT` argument adds the seen-set around the plain one.
2681    fn for_arg(func: &AggFunc, arg: &AggArg) -> Self {
2682        match arg {
2683            AggArg::Distinct(_) => AggAcc::Distinct {
2684                seen: HashSet::new(),
2685                inner: Box::new(AggAcc::new(func)),
2686            },
2687            _ => AggAcc::new(func),
2688        }
2689    }
2690
2691    fn new(func: &AggFunc) -> Self {
2692        match func {
2693            AggFunc::Count => AggAcc::Count(0),
2694            AggFunc::Sum => AggAcc::Sum {
2695                ints: 0,
2696                floats: 0.0,
2697                any_float: false,
2698                has_value: false,
2699            },
2700            AggFunc::Avg => AggAcc::Avg { sum: 0.0, n: 0 },
2701            AggFunc::Min => AggAcc::Min(None),
2702            AggFunc::Max => AggAcc::Max(None),
2703            AggFunc::Collect => AggAcc::Collect(Vec::new()),
2704        }
2705    }
2706
2707    fn finish(self) -> Option<Value> {
2708        match self {
2709            // Saturating cast: a graph with >i64::MAX matched rows is not a
2710            // realistic concern today, but a silent wrapping cast would produce
2711            // a wrong (negative) count. Clamping to i64::MAX is the least-
2712            // surprising failure mode.
2713            AggAcc::Count(n) => Some(Value::Int(i64::try_from(n).unwrap_or(i64::MAX))),
2714            AggAcc::Sum {
2715                ints,
2716                floats,
2717                any_float,
2718                has_value,
2719            } => {
2720                if !has_value {
2721                    None
2722                } else if any_float {
2723                    Some(Value::Float(ints as f64 + floats))
2724                } else {
2725                    Some(Value::Int(ints))
2726                }
2727            }
2728            AggAcc::Avg { sum, n } => {
2729                if n > 0 {
2730                    Some(Value::Float(sum / n as f64))
2731                } else {
2732                    None
2733                }
2734            }
2735            AggAcc::Min(v) => v,
2736            AggAcc::Max(v) => v,
2737            // Empty collect() yields an empty list (never null), matching openCypher.
2738            AggAcc::Collect(items) => Some(Value::List(items)),
2739            AggAcc::Distinct { inner, .. } => inner.finish(),
2740        }
2741    }
2742}
2743
2744/// Extract a numeric (f64) value from a `Value`, returning `None` for null /
2745/// non-numeric types.  Silently skipped per the aggregate contract.
2746fn numeric_val(v: &Value) -> Option<f64> {
2747    match v {
2748        Value::Int(n) => Some(*n as f64),
2749        Value::Float(f) => Some(*f),
2750        _ => None,
2751    }
2752}
2753
2754/// Shared context for `agg_stream`.
2755struct AggStreamCtx<'a> {
2756    view: &'a GraphView<'a>,
2757    vars: &'a VarTable,
2758    params: &'a Params<'a>,
2759    func: &'a AggFunc,
2760    arg: &'a AggArg,
2761}
2762
2763/// Streaming accumulator executor for a single aggregate.
2764///
2765/// Locates the `Aggregate` op, builds the producer slice (everything before
2766/// it), then calls `agg_stream` to walk all matching rows and accumulate.
2767fn execute_aggregate(
2768    view: &GraphView,
2769    plan: &[PlanOp],
2770    params: &Params,
2771) -> Result<ResultSet, String> {
2772    let agg_pos = match plan
2773        .iter()
2774        .position(|op| matches!(op, PlanOp::Aggregate { .. }))
2775    {
2776        Some(p) => p,
2777        None => return Ok(ResultSet::new(vec![])),
2778    };
2779    let producers = &plan[..agg_pos];
2780    let (func, arg, column) = match &plan[agg_pos] {
2781        PlanOp::Aggregate { func, arg, column } => (func, arg, column),
2782        _ => unreachable!(),
2783    };
2784    let vars = collect_vars(plan);
2785    let ctx = AggStreamCtx {
2786        view,
2787        vars: &vars,
2788        params,
2789        func,
2790        arg,
2791    };
2792    let initial_row: Row = vec![None; vars.names.len()];
2793    let mut acc = AggAcc::for_arg(func, arg);
2794    agg_stream(&ctx, producers, &initial_row, &mut acc)?;
2795
2796    let value = acc.finish();
2797    let mut rs = ResultSet::new(vec![column.clone()]);
2798    rs.push_row(vec![value]);
2799    Ok(rs)
2800}
2801
2802/// The value a `DISTINCT` aggregate argument takes on one row.
2803///
2804/// A node variable is identified by its key, a relationship variable by the
2805/// edge it is bound to, a scalar alias by its value, and a property by the
2806/// property's value. `DISTINCT *` is rejected at parse time, so `Star` is
2807/// unreachable and yields `None` defensively.
2808///
2809/// # A relationship is keyed on the edge, not dropped
2810///
2811/// A relationship cell has no key of its own, so this used to answer `None`
2812/// for one — and `None` means "contributes to no aggregate", so
2813/// `count(DISTINCT r)` counted nothing at all and returned `0` on a graph
2814/// full of edges. An edge *is* identified, by the triple the executor binds:
2815/// its type and its two endpoints. That is what is keyed here, so
2816/// `count(DISTINCT r)` equals `count(r)` on a graph where no pair is joined
2817/// twice by one type, and is smaller exactly where an alternation matched the
2818/// same edge through more than one pattern.
2819///
2820/// The key is only ever compared against other keys from this same function
2821/// within one `DISTINCT` gate, and an aggregate argument is one variable, so
2822/// a relationship's key never meets a node key or a property string: the
2823/// separator is there to keep the three ids apart, not to fence off a
2824/// collision that the grammar can produce.
2825fn distinct_value(
2826    view: &GraphView,
2827    vars: &VarTable,
2828    row: &Row,
2829    arg: &AggArg,
2830) -> Result<Option<Value>, String> {
2831    match arg {
2832        AggArg::Star => Ok(None),
2833        AggArg::Var(v) => {
2834            let Some(slot) = vars.slot(v) else {
2835                return Ok(None);
2836            };
2837            Ok(match row.get(slot).and_then(|c| c.as_ref()) {
2838                Some(Cell::Node(id)) => view.ids.key_of(*id).map(|k| Value::Str(k.to_owned())),
2839                Some(Cell::Scalar(val)) => Some(val.clone()),
2840                Some(Cell::Path(hops)) => Some(Value::Int(*hops as i64)),
2841                Some(Cell::Rel(e)) => Some(Value::Str(format!(
2842                    "{}\u{1}{}\u{1}{}",
2843                    e.etype, e.src, e.dst
2844                ))),
2845                None => None,
2846            })
2847        }
2848        AggArg::Prop { var, field } => resolve_prop(view, vars, row, var, field),
2849        // The parser never nests DISTINCT; treat a nested one as its inner arg.
2850        AggArg::Distinct(inner) => distinct_value(view, vars, row, inner),
2851    }
2852}
2853
2854/// The value one row feeds a `sum` / `avg` / `min` / `max`: the property, or
2855/// the scalar a bare variable is bound to. Null, an unbound variable and a
2856/// node, relationship or path variable give `None` and contribute nothing, as
2857/// every non-property argument did before row 157.
2858fn scalar_agg_input(
2859    view: &GraphView,
2860    vars: &VarTable,
2861    row: &Row,
2862    arg: &AggArg,
2863) -> Result<Option<Value>, String> {
2864    Ok(match arg {
2865        AggArg::Prop { var, field } => resolve_prop(view, vars, row, var, field)?,
2866        AggArg::Var(v) => match vars
2867            .slot(v)
2868            .and_then(|s| row.get(s))
2869            .and_then(|c| c.as_ref())
2870        {
2871            Some(Cell::Scalar(x)) => Some(x.clone()),
2872            _ => None,
2873        },
2874        _ => None,
2875    })
2876}
2877
2878/// Keep the smaller (`largest == false`) or larger of the running extreme and
2879/// the next value. A number or a boolean counts (row 160: `false` before
2880/// `true`); any other value is skipped.
2881fn fold_extreme(current: &mut Option<Value>, next: Value, largest: bool) {
2882    if numeric_val(&next).is_none() && !matches!(next, Value::Bool(_)) {
2883        return;
2884    }
2885    *current = Some(match current.take() {
2886        None => next,
2887        Some(prev) => {
2888            if cmp_optional(Some(&prev), Some(&next), largest) == std::cmp::Ordering::Greater {
2889                next
2890            } else {
2891                prev
2892            }
2893        }
2894    });
2895}
2896
2897/// Update a single accumulator for one matched row.
2898///
2899/// Shared by `agg_stream` (single-aggregate path) and `group_stream` (grouped
2900/// aggregation).  The logic mirrors openCypher conventions: null / non-numeric
2901/// values are silently skipped for SUM / AVG / MIN / MAX.
2902fn update_acc(
2903    view: &GraphView,
2904    vars: &VarTable,
2905    row: &Row,
2906    func: &AggFunc,
2907    arg: &AggArg,
2908    acc: &mut AggAcc,
2909) -> Result<(), String> {
2910    // DISTINCT gate: resolve the row's value for the inner argument, drop the
2911    // row when this group has already seen it, and otherwise forward to the
2912    // plain accumulator underneath.
2913    if let AggArg::Distinct(inner_arg) = arg {
2914        let AggAcc::Distinct { seen, inner } = acc else {
2915            return Ok(());
2916        };
2917        let val = distinct_value(view, vars, row, inner_arg)?;
2918        // An unbound / null argument contributes to no aggregate, DISTINCT or
2919        // not: `count(DISTINCT x)` counts distinct non-null values.
2920        let Some(val) = val else {
2921            return Ok(());
2922        };
2923        if !seen.insert(group_key_normalize(&val)) {
2924            return Ok(());
2925        }
2926        return update_acc(view, vars, row, func, inner_arg, inner);
2927    }
2928    match (func, arg) {
2929        (AggFunc::Count, AggArg::Star) => {
2930            if let AggAcc::Count(n) = acc {
2931                *n += 1;
2932            }
2933        }
2934        (AggFunc::Count, AggArg::Var(v)) => {
2935            let slot = vars.slot(v);
2936            let is_bound = slot
2937                .and_then(|s| row.get(s))
2938                .and_then(|c| c.as_ref())
2939                .is_some();
2940            if is_bound {
2941                if let AggAcc::Count(n) = acc {
2942                    *n += 1;
2943                }
2944            }
2945        }
2946        (AggFunc::Count, AggArg::Prop { var, field }) => {
2947            let val = resolve_prop(view, vars, row, var, field)?;
2948            if val.is_some() {
2949                if let AggAcc::Count(n) = acc {
2950                    *n += 1;
2951                }
2952            }
2953        }
2954        // sum / avg / min / max read one value per row: a property or a bare
2955        // variable bound to a scalar (`UNWIND … AS x`, `WITH … AS x`). Row 157:
2956        // the bare-variable form used to fall through to the catch-all arm and
2957        // contribute nothing, so these returned null.
2958        (
2959            AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max,
2960            AggArg::Prop { .. } | AggArg::Var(_),
2961        ) => {
2962            let Some(v) = scalar_agg_input(view, vars, row, arg)? else {
2963                return Ok(());
2964            };
2965            match acc {
2966                AggAcc::Sum {
2967                    ints,
2968                    floats,
2969                    any_float,
2970                    has_value,
2971                } => match v {
2972                    Value::Int(n) => {
2973                        *ints = ints
2974                            .checked_add(n)
2975                            .ok_or_else(|| "sum(): integer overflow".to_string())?;
2976                        *has_value = true;
2977                    }
2978                    Value::Float(f) => {
2979                        *floats += f;
2980                        *any_float = true;
2981                        *has_value = true;
2982                    }
2983                    _ => {}
2984                },
2985                AggAcc::Avg { sum, n } => {
2986                    if let Some(num) = numeric_val(&v) {
2987                        *sum += num;
2988                        *n += 1;
2989                    }
2990                }
2991                AggAcc::Min(current) => fold_extreme(current, v, false),
2992                AggAcc::Max(current) => fold_extreme(current, v, true),
2993                _ => {}
2994            }
2995        }
2996        (AggFunc::Collect, AggArg::Var(v)) => {
2997            // Gather each row's value of `v` — node → key, scalar alias → value,
2998            // path → hop count. Nulls (unbound / relationship) are skipped.
2999            let val = vars
3000                .slot(v)
3001                .and_then(|s| row.get(s))
3002                .and_then(|c| c.as_ref())
3003                .and_then(|cell| match cell {
3004                    Cell::Scalar(x) => Some(x.clone()),
3005                    Cell::Node(id) => view.ids.key_of(*id).map(|k| Value::Str(k.to_owned())),
3006                    Cell::Path(h) => Some(Value::Int(*h as i64)),
3007                    Cell::Rel(_) => None,
3008                });
3009            if let Some(val) = val {
3010                if let AggAcc::Collect(items) = acc {
3011                    items.push(val);
3012                }
3013            }
3014        }
3015        (AggFunc::Collect, AggArg::Prop { var, field }) => {
3016            if let Some(val) = resolve_prop(view, vars, row, var, field)? {
3017                if let AggAcc::Collect(items) = acc {
3018                    items.push(val);
3019                }
3020            }
3021        }
3022        // Remaining combinations are rejected by the planner (e.g., SUM(*))
3023        // but handle defensively without panic.
3024        _ => {}
3025    }
3026    Ok(())
3027}
3028
3029/// Recursively walk all matching rows through `ops`, updating `acc` for each
3030/// terminal row.  No bound — visits every row the producers can emit.
3031fn agg_stream(
3032    ctx: &AggStreamCtx<'_>,
3033    ops: &[PlanOp],
3034    row: &Row,
3035    acc: &mut AggAcc,
3036) -> Result<(), String> {
3037    let (op, rest) = match ops.split_first() {
3038        Some(pair) => pair,
3039        None => {
3040            // Terminal row — delegate to shared update_acc helper.
3041            update_acc(ctx.view, ctx.vars, row, ctx.func, ctx.arg, acc)?;
3042            return Ok(());
3043        }
3044    };
3045
3046    match op {
3047        PlanOp::ScanLabel { var, label } => {
3048            let ids = scan_ids(ctx.view, label.as_deref());
3049            let slot = ctx
3050                .vars
3051                .slot(var)
3052                .ok_or_else(|| format!("unbound variable `{var}`"))?;
3053            for &id in &ids {
3054                let mut next = row.clone();
3055                next[slot] = Some(Cell::Node(id));
3056                agg_stream(ctx, rest, &next, acc)?;
3057            }
3058        }
3059        PlanOp::ScanKey { var, key, label } => {
3060            let slot = ctx
3061                .vars
3062                .slot(var)
3063                .ok_or_else(|| format!("unbound variable `{var}`"))?;
3064            if let Some(id) =
3065                resolve_scan_key_id(ctx.view, ctx.vars, row, key, label.as_deref(), ctx.params)?
3066            {
3067                let mut next = row.clone();
3068                next[slot] = Some(Cell::Node(id));
3069                agg_stream(ctx, rest, &next, acc)?;
3070            }
3071        }
3072        PlanOp::IndexScan {
3073            var,
3074            label,
3075            field,
3076            value,
3077        } => {
3078            let ids = index_scan_ids(
3079                ctx.view,
3080                ctx.vars,
3081                row,
3082                label.as_deref(),
3083                field,
3084                value,
3085                ctx.params,
3086            )?;
3087            let slot = ctx
3088                .vars
3089                .slot(var)
3090                .ok_or_else(|| format!("unbound variable `{var}`"))?;
3091            for &id in &ids {
3092                let mut next = row.clone();
3093                next[slot] = Some(Cell::Node(id));
3094                agg_stream(ctx, rest, &next, acc)?;
3095            }
3096        }
3097        PlanOp::IndexIntersect {
3098            var,
3099            label,
3100            equalities,
3101        } => {
3102            let ids = index_intersect_ids(
3103                ctx.view,
3104                ctx.vars,
3105                row,
3106                label.as_deref(),
3107                equalities,
3108                ctx.params,
3109            )?;
3110            let slot = ctx
3111                .vars
3112                .slot(var)
3113                .ok_or_else(|| format!("unbound variable `{var}`"))?;
3114            for id in ids {
3115                let mut next = row.clone();
3116                next[slot] = Some(Cell::Node(id));
3117                agg_stream(ctx, rest, &next, acc)?;
3118            }
3119        }
3120        PlanOp::Expand {
3121            from,
3122            rel_var,
3123            etypes,
3124            dir,
3125            to,
3126            to_label,
3127            to_props,
3128        } => {
3129            let etypes = resolve_etypes(ctx.view, etypes);
3130            let exp_dir = map_dir(*dir);
3131            let to_slot = ctx
3132                .vars
3133                .slot(to)
3134                .ok_or_else(|| format!("unbound variable `{to}`"))?;
3135            let rel_slot = rel_var.as_ref().and_then(|rv| ctx.vars.slot(rv));
3136            let from_id = require_node(row, ctx.vars, from)?;
3137            let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
3138                Some(Cell::Node(id)) => Some(*id),
3139                Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
3140                    return Err(format!("variable `{to}` is not a node"))
3141                }
3142                None => None,
3143            };
3144            for e in expand(ctx.view, from_id, etypes.as_deref(), exp_dir) {
3145                if row_has_edge(row, &e) {
3146                    continue;
3147                }
3148                let nbr = neighbor(from_id, &e, *dir);
3149                if !ctx.view.visible(nbr) {
3150                    continue;
3151                }
3152                if let Some(want) = bound_to {
3153                    if nbr != want {
3154                        continue;
3155                    }
3156                }
3157                if !node_matches(
3158                    ctx.view,
3159                    ctx.vars,
3160                    row,
3161                    nbr,
3162                    to_label.as_deref(),
3163                    to_props,
3164                    ctx.params,
3165                )? {
3166                    continue;
3167                }
3168                let mut next = row.clone();
3169                if let Some(slot) = rel_slot {
3170                    next[slot] = Some(Cell::Rel(e));
3171                }
3172                if bound_to.is_none() {
3173                    next[to_slot] = Some(Cell::Node(nbr));
3174                }
3175                agg_stream(ctx, rest, &next, acc)?;
3176            }
3177        }
3178        PlanOp::Filter { expr } => {
3179            if eval_expr(ctx.view, ctx.vars, row, expr, ctx.params, 0)? {
3180                agg_stream(ctx, rest, row, acc)?;
3181            }
3182        }
3183        PlanOp::LookupProps { var, props } => {
3184            let id = require_node(row, ctx.vars, var)?;
3185            if node_matches(ctx.view, ctx.vars, row, id, None, props, ctx.params)? {
3186                agg_stream(ctx, rest, row, acc)?;
3187            }
3188        }
3189        PlanOp::JoinBound { var, label, props } => {
3190            let id = require_node(row, ctx.vars, var)?;
3191            if node_matches(
3192                ctx.view,
3193                ctx.vars,
3194                row,
3195                id,
3196                label.as_deref(),
3197                props,
3198                ctx.params,
3199            )? {
3200                agg_stream(ctx, rest, row, acc)?;
3201            }
3202        }
3203        PlanOp::VarExpand {
3204            from,
3205            rel_var,
3206            etypes,
3207            dir,
3208            to,
3209            min,
3210            max,
3211        } => {
3212            let new_rows = exec_var_expand(
3213                ctx.view,
3214                ctx.vars,
3215                std::slice::from_ref(row),
3216                from,
3217                rel_var,
3218                etypes,
3219                *dir,
3220                to,
3221                *min,
3222                *max,
3223            )?;
3224            for nr in &new_rows {
3225                agg_stream(ctx, rest, nr, acc)?;
3226            }
3227        }
3228        PlanOp::ShortestPath {
3229            from,
3230            rel_var,
3231            etypes,
3232            dir,
3233            to,
3234            max_hops,
3235        } => {
3236            let new_rows = exec_shortest_path(
3237                ctx.view,
3238                ctx.vars,
3239                std::slice::from_ref(row),
3240                from,
3241                rel_var,
3242                etypes,
3243                *dir,
3244                to,
3245                *max_hops,
3246            )?;
3247            for nr in &new_rows {
3248                agg_stream(ctx, rest, nr, acc)?;
3249            }
3250        }
3251        // These must not appear in the producer slice of an aggregate plan.
3252        PlanOp::Project { .. } => {
3253            return Err(
3254                "agg executor: Project in producer slice — plan is structurally malformed"
3255                    .to_string(),
3256            );
3257        }
3258        PlanOp::Distinct => {
3259            return Err(
3260                "agg executor: Distinct in producer slice — plan is structurally malformed"
3261                    .to_string(),
3262            );
3263        }
3264        PlanOp::OrderBy { .. } => {
3265            return Err(
3266                "agg executor: OrderBy in producer slice — structurally malformed".to_string(),
3267            );
3268        }
3269        PlanOp::Skip(_) => {
3270            return Err(
3271                "agg executor: Skip in producer slice — structurally malformed".to_string(),
3272            );
3273        }
3274        PlanOp::Limit(_) => {
3275            return Err(
3276                "agg executor: Limit in producer slice — structurally malformed".to_string(),
3277            );
3278        }
3279        PlanOp::Aggregate { .. } => {
3280            return Err(
3281                "agg executor: nested Aggregate in producer slice — structurally malformed"
3282                    .to_string(),
3283            );
3284        }
3285        PlanOp::GroupAggregate { .. } => {
3286            return Err(
3287                "agg executor: GroupAggregate in producer slice — structurally malformed"
3288                    .to_string(),
3289            );
3290        }
3291        PlanOp::With { .. } => {
3292            return Err(
3293                "agg executor: With in producer slice — structurally malformed".to_string(),
3294            );
3295        }
3296        PlanOp::Unwind { .. } => {
3297            return Err(
3298                "agg executor: Unwind in producer slice — structurally malformed".to_string(),
3299            );
3300        }
3301        PlanOp::LeftOuterApply { .. } => {
3302            return Err(
3303                "agg executor: LeftOuterApply in producer slice — structurally malformed"
3304                    .to_string(),
3305            );
3306        }
3307    }
3308    Ok(())
3309}
3310
3311// ─── GroupAggregate execution path ──────────────────────────────────────────
3312//
3313// GroupAggregate plans stream through ALL matching rows, computing a group-key
3314// tuple per row and maintaining per-group accumulators in a HashMap.  Memory
3315// is O(distinct groups); the 1 M intermediate-row budget does not apply.
3316
3317/// Context shared across all `group_stream` recursive calls.
3318struct GroupStreamCtx<'a> {
3319    view: &'a GraphView<'a>,
3320    vars: &'a VarTable,
3321    params: &'a Params<'a>,
3322    keys: &'a [(String, RetItem)],
3323    aggs: &'a [(AggFunc, AggArg, String)],
3324}
3325
3326/// Build the `Projected` output table from a finished group map.
3327fn build_group_projected(
3328    keys: &[(String, RetItem)],
3329    aggs: &[(AggFunc, AggArg, String)],
3330    key_order: Vec<GroupKey>,
3331    groups: &mut HashMap<GroupKey, GroupEntry>,
3332) -> Projected {
3333    let columns: Vec<String> = keys
3334        .iter()
3335        .map(|(col, _)| col.clone())
3336        .chain(aggs.iter().map(|(_, _, col)| col.clone()))
3337        .collect();
3338    let mut rows: Vec<Vec<Option<Value>>> = Vec::with_capacity(key_order.len());
3339    for gk in key_order {
3340        let (display_keys, accs) = groups.remove(&gk).unwrap_or_default();
3341        let mut row: Vec<Option<Value>> = Vec::with_capacity(columns.len());
3342        // Use the first-seen original values for display; Int(42) stays Int(42)
3343        // even though it was normalized to FloatBits for hashing.
3344        for display_val in display_keys {
3345            row.push(display_val);
3346        }
3347        for acc in accs {
3348            row.push(acc.finish());
3349        }
3350        rows.push(row);
3351    }
3352    Projected { columns, rows }
3353}
3354
3355/// Convert a finished group map into raw `Row` vectors for pipeline consumption.
3356///
3357/// Used by the staged executor when a `GroupAggregate` appears in a WITH pipeline
3358/// (i.e., `is_pipeline = true`).  Each group becomes one `Row` with `Cell::Scalar`
3359/// values for key and aggregate columns.  Column names are interned in `vars` so
3360/// that subsequent pipeline stages can look them up by slot.
3361/// The original cell behind each grouping key that is a bare variable.
3362///
3363/// Only `RetVal::Var` keys carry a cell through: a computed key (`c.city`,
3364/// `toLower(c.name)`) has no node behind it and stays a scalar.
3365fn key_source_cells(vars: &VarTable, row: &Row, keys: &[(String, RetItem)]) -> Vec<Option<Cell>> {
3366    keys.iter()
3367        .map(|(_, item)| match &item.value {
3368            RetVal::Var(v) => vars
3369                .slot(v)
3370                .and_then(|s| row.get(s))
3371                .and_then(|c| c.clone()),
3372            _ => None,
3373        })
3374        .collect()
3375}
3376
3377fn group_result_to_rows(
3378    keys: &[(String, RetItem)],
3379    aggs: &[(AggFunc, AggArg, String)],
3380    key_order: Vec<GroupKey>,
3381    groups: &mut HashMap<GroupKey, GroupEntry>,
3382    key_cells: &mut HashMap<GroupKey, Vec<Option<Cell>>>,
3383    vars: &VarTable,
3384) -> Vec<Row> {
3385    let row_len = vars.names.len();
3386    let mut out: Vec<Row> = Vec::with_capacity(key_order.len());
3387    for gk in key_order {
3388        let (display_keys, accs) = groups.remove(&gk).unwrap_or_default();
3389        let mut cells = key_cells.remove(&gk).unwrap_or_default();
3390        cells.resize(keys.len(), None);
3391        let mut row: Row = vec![None; row_len];
3392        for (((col, _), val), cell) in keys.iter().zip(display_keys).zip(cells) {
3393            if let Some(slot) = vars.slot(col) {
3394                row[slot] = cell.or_else(|| val.map(Cell::Scalar));
3395            }
3396        }
3397        for ((_, _, col), acc) in aggs.iter().zip(accs) {
3398            if let Some(slot) = vars.slot(col) {
3399                row[slot] = acc.finish().map(Cell::Scalar);
3400            }
3401        }
3402        out.push(row);
3403    }
3404    out
3405}
3406
3407/// Sort raw rows by a list of `OrderItem`s (before projection).
3408///
3409/// Used by the staged path when an `OrderBy` op appears before `Project` —
3410/// for example, inside a non-aggregate WITH stage or after a pipeline
3411/// GroupAggregate.  `OrderTarget::Prop` is resolved by looking up the node
3412/// property; `Alias` / `Var` are resolved from `Cell::Scalar` in the row.
3413fn exec_order_by_rows(vars: &VarTable, rows: &mut Vec<Row>, items: &[OrderItem], view: &GraphView) {
3414    // Pre-compute sort-key values for all rows.  This avoids re-resolving
3415    // inside the comparator (the closure cannot return Err, so we pre-compute).
3416    let mut key_table: Vec<Vec<Option<Value>>> = Vec::with_capacity(rows.len());
3417    for row in rows.iter() {
3418        let mut row_key: Vec<Option<Value>> = Vec::with_capacity(items.len());
3419        for item in items {
3420            let val = match &item.target {
3421                OrderTarget::Alias(name) | OrderTarget::Var(name) => vars
3422                    .slot(name)
3423                    .and_then(|s| row.get(s))
3424                    .and_then(|c| c.as_ref())
3425                    .and_then(|c| match c {
3426                        Cell::Scalar(v) => Some(v.clone()),
3427                        Cell::Node(id) => view.ids.key_of(*id).map(|k| Value::Str(k.to_owned())),
3428                        Cell::Path(hops) => Some(Value::Int(*hops as i64)),
3429                        Cell::Rel(_) => None,
3430                    }),
3431                OrderTarget::Prop { var, field } => vars
3432                    .slot(var)
3433                    .and_then(|s| row.get(s))
3434                    .and_then(|c| c.as_ref())
3435                    .and_then(|c| match c {
3436                        Cell::Node(id) => view
3437                            .prop(*id, field)
3438                            .map(|vr| vr.into_value())
3439                            .or_else(|| node_identity_prop(view, *id, field)),
3440                        Cell::Rel(e) => view.edge_props.get(e.etype, e.src, e.dst, field),
3441                        _ => None,
3442                    }),
3443            };
3444            row_key.push(val);
3445        }
3446        key_table.push(row_key);
3447    }
3448    // Sort using a stable, index-based sort to keep equal rows in encounter order.
3449    let mut indices: Vec<usize> = (0..rows.len()).collect();
3450    indices.sort_by(|&a, &b| {
3451        for (ki, item) in items.iter().enumerate() {
3452            let c = cmp_optional(
3453                key_table[a].get(ki).and_then(|x| x.as_ref()),
3454                key_table[b].get(ki).and_then(|x| x.as_ref()),
3455                item.descending,
3456            );
3457            if c != std::cmp::Ordering::Equal {
3458                return c;
3459            }
3460        }
3461        std::cmp::Ordering::Equal
3462    });
3463    let sorted: Vec<Row> = indices.into_iter().map(|i| rows[i].clone()).collect();
3464    *rows = sorted;
3465}
3466
3467/// Streaming grouped-aggregate executor.
3468///
3469/// Locates the `GroupAggregate` op, builds the producer slice (everything
3470/// before it), streams through all matching rows via `group_stream`, then
3471/// applies any `OrderBy` / `Skip` / `Limit` ops that follow.
3472fn execute_group_aggregate(
3473    view: &GraphView,
3474    plan: &[PlanOp],
3475    params: &Params,
3476) -> Result<ResultSet, String> {
3477    let gagg_pos = plan
3478        .iter()
3479        .position(|op| matches!(op, PlanOp::GroupAggregate { .. }))
3480        .ok_or_else(|| "internal: GroupAggregate op not found in plan".to_string())?;
3481    let producers = &plan[..gagg_pos];
3482    let (keys, aggs) = match &plan[gagg_pos] {
3483        PlanOp::GroupAggregate { keys, aggs } => (keys, aggs),
3484        _ => unreachable!(),
3485    };
3486    let tail = &plan[gagg_pos + 1..];
3487    let vars = collect_vars(plan);
3488    let initial_row: Row = vec![None; vars.names.len()];
3489    let mut groups: HashMap<GroupKey, GroupEntry> = HashMap::new();
3490    let mut key_order: Vec<GroupKey> = Vec::new();
3491    let ctx = GroupStreamCtx {
3492        view,
3493        vars: &vars,
3494        params,
3495        keys,
3496        aggs,
3497    };
3498    group_stream(&ctx, producers, &initial_row, &mut groups, &mut key_order)?;
3499    // openCypher: aggregates with no group-key items on empty input must
3500    // produce exactly one row (COUNT=0, SUM/AVG/etc.=null).  Seed the empty
3501    // key when no terminal rows arrived and there are no grouping keys.
3502    if keys.is_empty() && key_order.is_empty() {
3503        let empty_key: GroupKey = vec![];
3504        key_order.push(empty_key.clone());
3505        groups.insert(
3506            empty_key,
3507            (
3508                vec![],
3509                aggs.iter().map(|(f, a, _)| AggAcc::for_arg(f, a)).collect(),
3510            ),
3511        );
3512    }
3513    let mut projected = build_group_projected(keys, aggs, key_order, &mut groups);
3514    // Apply OrderBy / Skip / Limit from the tail of the plan.
3515    for op in tail {
3516        match op {
3517            PlanOp::OrderBy { items } => exec_order_by(&mut projected, items)?,
3518            PlanOp::Skip(ls) => {
3519                let n = resolve_ls(ls, params)?;
3520                apply_skip(&mut projected.rows, n);
3521            }
3522            PlanOp::Limit(ls) => {
3523                let n = resolve_ls(ls, params)?;
3524                apply_limit(&mut projected.rows, n);
3525            }
3526            _ => {} // Ignore unexpected ops defensively.
3527        }
3528    }
3529    Ok(finish(projected))
3530}
3531
3532/// Recursively walk all matching rows through `ops`, computing the group key
3533/// and updating per-group accumulators at each terminal row.
3534///
3535/// `groups` maps group key → per-group accumulator vector.
3536/// `key_order` tracks insertion order so output rows are deterministic when
3537/// no ORDER BY is requested.
3538fn group_stream(
3539    ctx: &GroupStreamCtx<'_>,
3540    ops: &[PlanOp],
3541    row: &Row,
3542    groups: &mut HashMap<GroupKey, GroupEntry>,
3543    key_order: &mut Vec<GroupKey>,
3544) -> Result<(), String> {
3545    let (op, rest) = match ops.split_first() {
3546        Some(pair) => pair,
3547        None => {
3548            // Terminal row: compute normalized group key for equality/hashing
3549            // and capture original values for display (first-seen wins).
3550            let mut gk: GroupKey = Vec::with_capacity(ctx.keys.len());
3551            let mut display_vals: Vec<Option<Value>> = Vec::with_capacity(ctx.keys.len());
3552            for (_, item) in ctx.keys {
3553                let val = project_item(ctx.view, ctx.vars, row, item, ctx.params)?;
3554                gk.push(val.as_ref().and_then(group_key_normalize));
3555                display_vals.push(val);
3556            }
3557            if !groups.contains_key(&gk) {
3558                if groups.len() >= max_groups() {
3559                    return Err(group_cap_err());
3560                }
3561                key_order.push(gk.clone());
3562                let init: Vec<AggAcc> = ctx
3563                    .aggs
3564                    .iter()
3565                    .map(|(f, a, _)| AggAcc::for_arg(f, a))
3566                    .collect();
3567                groups.insert(gk.clone(), (display_vals, init));
3568            }
3569            let (_, accs) = groups.get_mut(&gk).unwrap();
3570            for (acc, (func, arg, _)) in accs.iter_mut().zip(ctx.aggs.iter()) {
3571                update_acc(ctx.view, ctx.vars, row, func, arg, acc)?;
3572            }
3573            return Ok(());
3574        }
3575    };
3576
3577    match op {
3578        PlanOp::ScanLabel { var, label } => {
3579            let ids = scan_ids(ctx.view, label.as_deref());
3580            let slot = ctx
3581                .vars
3582                .slot(var)
3583                .ok_or_else(|| format!("unbound variable `{var}`"))?;
3584            for &id in &ids {
3585                let mut next = row.clone();
3586                next[slot] = Some(Cell::Node(id));
3587                group_stream(ctx, rest, &next, groups, key_order)?;
3588            }
3589        }
3590        PlanOp::ScanKey { var, key, label } => {
3591            let slot = ctx
3592                .vars
3593                .slot(var)
3594                .ok_or_else(|| format!("unbound variable `{var}`"))?;
3595            if let Some(id) =
3596                resolve_scan_key_id(ctx.view, ctx.vars, row, key, label.as_deref(), ctx.params)?
3597            {
3598                let mut next = row.clone();
3599                next[slot] = Some(Cell::Node(id));
3600                group_stream(ctx, rest, &next, groups, key_order)?;
3601            }
3602        }
3603        PlanOp::IndexScan {
3604            var,
3605            label,
3606            field,
3607            value,
3608        } => {
3609            let ids = index_scan_ids(
3610                ctx.view,
3611                ctx.vars,
3612                row,
3613                label.as_deref(),
3614                field,
3615                value,
3616                ctx.params,
3617            )?;
3618            let slot = ctx
3619                .vars
3620                .slot(var)
3621                .ok_or_else(|| format!("unbound variable `{var}`"))?;
3622            for &id in &ids {
3623                let mut next = row.clone();
3624                next[slot] = Some(Cell::Node(id));
3625                group_stream(ctx, rest, &next, groups, key_order)?;
3626            }
3627        }
3628        PlanOp::IndexIntersect {
3629            var,
3630            label,
3631            equalities,
3632        } => {
3633            let ids = index_intersect_ids(
3634                ctx.view,
3635                ctx.vars,
3636                row,
3637                label.as_deref(),
3638                equalities,
3639                ctx.params,
3640            )?;
3641            let slot = ctx
3642                .vars
3643                .slot(var)
3644                .ok_or_else(|| format!("unbound variable `{var}`"))?;
3645            for id in ids {
3646                let mut next = row.clone();
3647                next[slot] = Some(Cell::Node(id));
3648                group_stream(ctx, rest, &next, groups, key_order)?;
3649            }
3650        }
3651        PlanOp::Expand {
3652            from,
3653            rel_var,
3654            etypes,
3655            dir,
3656            to,
3657            to_label,
3658            to_props,
3659        } => {
3660            let etypes = resolve_etypes(ctx.view, etypes);
3661            let exp_dir = map_dir(*dir);
3662            let to_slot = ctx
3663                .vars
3664                .slot(to)
3665                .ok_or_else(|| format!("unbound variable `{to}`"))?;
3666            let rel_slot = rel_var.as_ref().and_then(|rv| ctx.vars.slot(rv));
3667            let from_id = require_node(row, ctx.vars, from)?;
3668            let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
3669                Some(Cell::Node(id)) => Some(*id),
3670                Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
3671                    return Err(format!("variable `{to}` is not a node"))
3672                }
3673                None => None,
3674            };
3675            for e in expand(ctx.view, from_id, etypes.as_deref(), exp_dir) {
3676                if row_has_edge(row, &e) {
3677                    continue;
3678                }
3679                let nbr = neighbor(from_id, &e, *dir);
3680                if !ctx.view.visible(nbr) {
3681                    continue;
3682                }
3683                if let Some(want) = bound_to {
3684                    if nbr != want {
3685                        continue;
3686                    }
3687                }
3688                if !node_matches(
3689                    ctx.view,
3690                    ctx.vars,
3691                    row,
3692                    nbr,
3693                    to_label.as_deref(),
3694                    to_props,
3695                    ctx.params,
3696                )? {
3697                    continue;
3698                }
3699                let mut next = row.clone();
3700                if let Some(slot) = rel_slot {
3701                    next[slot] = Some(Cell::Rel(e));
3702                }
3703                if bound_to.is_none() {
3704                    next[to_slot] = Some(Cell::Node(nbr));
3705                }
3706                group_stream(ctx, rest, &next, groups, key_order)?;
3707            }
3708        }
3709        PlanOp::Filter { expr } => {
3710            if eval_expr(ctx.view, ctx.vars, row, expr, ctx.params, 0)? {
3711                group_stream(ctx, rest, row, groups, key_order)?;
3712            }
3713        }
3714        PlanOp::LookupProps { var, props } => {
3715            let id = require_node(row, ctx.vars, var)?;
3716            if node_matches(ctx.view, ctx.vars, row, id, None, props, ctx.params)? {
3717                group_stream(ctx, rest, row, groups, key_order)?;
3718            }
3719        }
3720        PlanOp::JoinBound { var, label, props } => {
3721            let id = require_node(row, ctx.vars, var)?;
3722            if node_matches(
3723                ctx.view,
3724                ctx.vars,
3725                row,
3726                id,
3727                label.as_deref(),
3728                props,
3729                ctx.params,
3730            )? {
3731                group_stream(ctx, rest, row, groups, key_order)?;
3732            }
3733        }
3734        PlanOp::VarExpand {
3735            from,
3736            rel_var,
3737            etypes,
3738            dir,
3739            to,
3740            min,
3741            max,
3742        } => {
3743            let new_rows = exec_var_expand(
3744                ctx.view,
3745                ctx.vars,
3746                std::slice::from_ref(row),
3747                from,
3748                rel_var,
3749                etypes,
3750                *dir,
3751                to,
3752                *min,
3753                *max,
3754            )?;
3755            for nr in &new_rows {
3756                group_stream(ctx, rest, nr, groups, key_order)?;
3757            }
3758        }
3759        PlanOp::ShortestPath {
3760            from,
3761            rel_var,
3762            etypes,
3763            dir,
3764            to,
3765            max_hops,
3766        } => {
3767            let new_rows = exec_shortest_path(
3768                ctx.view,
3769                ctx.vars,
3770                std::slice::from_ref(row),
3771                from,
3772                rel_var,
3773                etypes,
3774                *dir,
3775                to,
3776                *max_hops,
3777            )?;
3778            for nr in &new_rows {
3779                group_stream(ctx, rest, nr, groups, key_order)?;
3780            }
3781        }
3782        // These ops must not appear in the producer slice of a GroupAggregate plan.
3783        PlanOp::Project { .. } => {
3784            return Err(
3785                "group executor: Project in producer slice — structurally malformed".to_string(),
3786            );
3787        }
3788        PlanOp::Distinct => {
3789            return Err(
3790                "group executor: Distinct in producer slice — structurally malformed".to_string(),
3791            );
3792        }
3793        PlanOp::OrderBy { .. } => {
3794            return Err(
3795                "group executor: OrderBy in producer slice — structurally malformed".to_string(),
3796            );
3797        }
3798        PlanOp::Skip(_) => {
3799            return Err(
3800                "group executor: Skip in producer slice — structurally malformed".to_string(),
3801            );
3802        }
3803        PlanOp::Limit(_) => {
3804            return Err(
3805                "group executor: Limit in producer slice — structurally malformed".to_string(),
3806            );
3807        }
3808        PlanOp::Aggregate { .. } => {
3809            return Err(
3810                "group executor: Aggregate in producer slice — structurally malformed".to_string(),
3811            );
3812        }
3813        PlanOp::GroupAggregate { .. } => {
3814            return Err(
3815                "group executor: nested GroupAggregate in producer slice — structurally malformed"
3816                    .to_string(),
3817            );
3818        }
3819        PlanOp::With { .. } => {
3820            return Err(
3821                "group executor: With in producer slice — structurally malformed".to_string(),
3822            );
3823        }
3824        PlanOp::Unwind { .. } => {
3825            return Err(
3826                "group executor: Unwind in producer slice — structurally malformed".to_string(),
3827            );
3828        }
3829        PlanOp::LeftOuterApply { .. } => {
3830            return Err(
3831                "group executor: LeftOuterApply in producer slice — structurally malformed"
3832                    .to_string(),
3833            );
3834        }
3835    }
3836    Ok(())
3837}
3838
3839/// Recursively pull rows through `ops`, projecting into `result` until
3840/// `result.len() >= ctx.bound`.
3841///
3842/// Each arm binds one `PlanOp` and recurses on `rest`.  When `ops` is empty
3843/// (all producers consumed), the current `row` is projected and appended.
3844///
3845/// `row` is a mutable scratch buffer: each arm that assigns a slot saves and
3846/// restores it around the recursive call so the caller sees no net change.
3847/// This eliminates the per-row `Vec` clone that the staged path requires.
3848///
3849/// ScanLabel iterates `view.labels` directly (lazy, no intermediate `Vec<u32>`)
3850/// so the bound truncates the scan itself — scanning stops as soon as enough
3851/// result rows have been collected.
3852fn pull_rows(
3853    ctx: &PullCtx<'_>,
3854    ops: &[PlanOp],
3855    row: &mut Row,
3856    result: &mut Vec<Vec<Option<Value>>>,
3857) -> Result<(), String> {
3858    if result.len() >= ctx.bound {
3859        return Ok(());
3860    }
3861    let (op, rest) = match ops.split_first() {
3862        Some(pair) => pair,
3863        None => {
3864            // All producers consumed — project this final row.
3865            let mut cells = Vec::with_capacity(ctx.project_items.len());
3866            for item in ctx.project_items {
3867                cells.push(project_item(ctx.view, ctx.vars, row, item, ctx.params)?);
3868            }
3869            result.push(cells);
3870            return Ok(());
3871        }
3872    };
3873    match op {
3874        PlanOp::ScanLabel { var, label } => {
3875            let slot = ctx
3876                .vars
3877                .slot(var)
3878                .ok_or_else(|| format!("unbound variable `{var}`"))?;
3879            // Resolve the label to an interned symbol once. If the label is
3880            // specified but unknown, there are no matching nodes — return early.
3881            let want_sym = label.as_deref().and_then(|l| ctx.view.syms.get(l));
3882            if label.is_some() && want_sym.is_none() {
3883                return Ok(());
3884            }
3885            // Save the slot value so we can restore it after the loop.
3886            let prev = row[slot].clone();
3887
3888            // Fast path: if the immediately following op is a simple
3889            // `Prop op Lit` comparison on this scan variable, fuse the filter
3890            // into the scan loop.  Pre-resolving the property column once
3891            // (hashing the field name once instead of per-node) eliminates the
3892            // outer HashMap string-hash on every candidate row.
3893            let fused_filter = rest.first().and_then(|next_op| {
3894                if let PlanOp::Filter {
3895                    expr:
3896                        Expr::Cmp {
3897                            lhs:
3898                                Operand::Prop {
3899                                    var: ref fv,
3900                                    field: ref f,
3901                                },
3902                            op: ref cmp_op_ref,
3903                            rhs: Operand::Lit(ref lit),
3904                        },
3905                } = *next_op
3906                {
3907                    if fv == var {
3908                        return Some((f.as_str(), cmp_op_ref, lit));
3909                    }
3910                }
3911                None
3912            });
3913
3914            // `n.key` / `n.id` / `n.label` are not columns, so the fused path
3915            // would see an empty column and drop every row. Fall through to
3916            // the generic path, which goes through `resolve_prop`.
3917            let fused_filter = fused_filter.filter(|(f, _, _)| !is_identity_field(f));
3918            if let Some((field, cmp_op_ref, lit)) = fused_filter {
3919                // Fused scan+filter: column resolved once, comparison done
3920                // inline — no recursive call into pull_rows for the Filter arm.
3921                // The column reads overlay and snapshot base, as
3922                // `resolve_prop` does (ledger row 139).
3923                #[cfg(test)]
3924                FUSED_SCAN_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3925                let col = ctx.view.props.view_column(field);
3926                let rest_after_filter = &rest[1..];
3927                for (i, &sym) in ctx.view.labels.iter().enumerate() {
3928                    if result.len() >= ctx.bound {
3929                        break;
3930                    }
3931                    if sym == u32::MAX {
3932                        continue;
3933                    }
3934                    if let Some(ws) = want_sym {
3935                        if sym != ws {
3936                            continue;
3937                        }
3938                    }
3939                    let id = i as u32;
3940                    if !ctx.view.visible(id) {
3941                        continue;
3942                    }
3943                    if col.test(id, |v| eval_cmp(cmp_op_ref, v, lit)) {
3944                        row[slot] = Some(Cell::Node(id));
3945                        pull_rows(ctx, rest_after_filter, row, result)?;
3946                    }
3947                }
3948            } else {
3949                // Generic path: iterate label array lazily, no Vec allocation,
3950                // with early exit when the row bound is satisfied.
3951                for (i, &sym) in ctx.view.labels.iter().enumerate() {
3952                    if result.len() >= ctx.bound {
3953                        break;
3954                    }
3955                    // Skip tombstone / gap slots (u32::MAX sentinel).
3956                    if sym == u32::MAX {
3957                        continue;
3958                    }
3959                    // Filter by label symbol when a label is requested.
3960                    if let Some(ws) = want_sym {
3961                        if sym != ws {
3962                            continue;
3963                        }
3964                    }
3965                    let id = i as u32;
3966                    if !ctx.view.visible(id) {
3967                        continue;
3968                    }
3969                    row[slot] = Some(Cell::Node(id));
3970                    pull_rows(ctx, rest, row, result)?;
3971                }
3972            }
3973            // SAFETY: the `?` inside both scan loops propagates Err directly to
3974            // `execute_pull`, which drops `initial_row` — the skipped restore is
3975            // unobservable.  This invariant MUST stay true: no call site between
3976            // here and `execute_pull` may catch an Err and re-use the row.
3977            // Future refactors adding mid-stream error recovery must audit this site.
3978            row[slot] = prev;
3979        }
3980        PlanOp::ScanKey { var, key, label } => {
3981            let slot = ctx
3982                .vars
3983                .slot(var)
3984                .ok_or_else(|| format!("unbound variable `{var}`"))?;
3985            if let Some(id) =
3986                resolve_scan_key_id(ctx.view, ctx.vars, row, key, label.as_deref(), ctx.params)?
3987            {
3988                let prev = row[slot].clone();
3989                row[slot] = Some(Cell::Node(id));
3990                pull_rows(ctx, rest, row, result)?;
3991                row[slot] = prev;
3992            }
3993        }
3994        PlanOp::IndexScan {
3995            var,
3996            label,
3997            field,
3998            value,
3999        } => {
4000            let slot = ctx
4001                .vars
4002                .slot(var)
4003                .ok_or_else(|| format!("unbound variable `{var}`"))?;
4004            let ids = index_scan_ids(
4005                ctx.view,
4006                ctx.vars,
4007                row,
4008                label.as_deref(),
4009                field,
4010                value,
4011                ctx.params,
4012            )?;
4013            let prev = row[slot].clone();
4014            for id in ids {
4015                if result.len() >= ctx.bound {
4016                    break;
4017                }
4018                row[slot] = Some(Cell::Node(id));
4019                pull_rows(ctx, rest, row, result)?;
4020            }
4021            row[slot] = prev;
4022        }
4023        PlanOp::IndexIntersect {
4024            var,
4025            label,
4026            equalities,
4027        } => {
4028            let slot = ctx
4029                .vars
4030                .slot(var)
4031                .ok_or_else(|| format!("unbound variable `{var}`"))?;
4032            let ids = index_intersect_ids(
4033                ctx.view,
4034                ctx.vars,
4035                row,
4036                label.as_deref(),
4037                equalities,
4038                ctx.params,
4039            )?;
4040            let prev = row[slot].clone();
4041            for id in ids {
4042                if result.len() >= ctx.bound {
4043                    break;
4044                }
4045                row[slot] = Some(Cell::Node(id));
4046                pull_rows(ctx, rest, row, result)?;
4047            }
4048            row[slot] = prev;
4049        }
4050        PlanOp::Expand {
4051            from,
4052            rel_var,
4053            etypes,
4054            dir,
4055            to,
4056            to_label,
4057            to_props,
4058        } => {
4059            let etypes = resolve_etypes(ctx.view, etypes);
4060            let exp_dir = map_dir(*dir);
4061            let to_slot = ctx
4062                .vars
4063                .slot(to)
4064                .ok_or_else(|| format!("unbound variable `{to}`"))?;
4065            let rel_slot = rel_var.as_ref().and_then(|rv| ctx.vars.slot(rv));
4066            let from_id = require_node(row, ctx.vars, from)?;
4067            let bound_to = match row.get(to_slot).and_then(|c| c.as_ref()) {
4068                Some(Cell::Node(id)) => Some(*id),
4069                Some(Cell::Rel(_) | Cell::Path(_) | Cell::Scalar(_)) => {
4070                    return Err(format!("variable `{to}` is not a node"))
4071                }
4072                None => None,
4073            };
4074            for e in expand(ctx.view, from_id, etypes.as_deref(), exp_dir) {
4075                if result.len() >= ctx.bound {
4076                    break;
4077                }
4078                if row_has_edge(row, &e) {
4079                    continue;
4080                }
4081                let nbr = neighbor(from_id, &e, *dir);
4082                if !ctx.view.visible(nbr) {
4083                    continue;
4084                }
4085                if let Some(want) = bound_to {
4086                    if nbr != want {
4087                        continue;
4088                    }
4089                }
4090                if !node_matches(
4091                    ctx.view,
4092                    ctx.vars,
4093                    row,
4094                    nbr,
4095                    to_label.as_deref(),
4096                    to_props,
4097                    ctx.params,
4098                )? {
4099                    continue;
4100                }
4101                let mut next = row.clone();
4102                if let Some(slot) = rel_slot {
4103                    next[slot] = Some(Cell::Rel(e));
4104                }
4105                if bound_to.is_none() {
4106                    next[to_slot] = Some(Cell::Node(nbr));
4107                }
4108                #[cfg(test)]
4109                record_expand_row();
4110                pull_rows(ctx, rest, &mut next, result)?;
4111            }
4112        }
4113        PlanOp::Filter { expr } => {
4114            if eval_expr(ctx.view, ctx.vars, row, expr, ctx.params, 0)? {
4115                pull_rows(ctx, rest, row, result)?;
4116            }
4117        }
4118        PlanOp::LookupProps { var, props } => {
4119            let id = require_node(row, ctx.vars, var)?;
4120            if node_matches(ctx.view, ctx.vars, row, id, None, props, ctx.params)? {
4121                pull_rows(ctx, rest, row, result)?;
4122            }
4123        }
4124        PlanOp::JoinBound { var, label, props } => {
4125            let id = require_node(row, ctx.vars, var)?;
4126            if node_matches(
4127                ctx.view,
4128                ctx.vars,
4129                row,
4130                id,
4131                label.as_deref(),
4132                props,
4133                ctx.params,
4134            )? {
4135                pull_rows(ctx, rest, row, result)?;
4136            }
4137        }
4138        // The ops below must never appear inside the producers slice that
4139        // pull_rows receives.  Explicitly reject each so that adding a new
4140        // PlanOp variant to the enum forces a compile-time decision here
4141        // rather than silently falling through and producing wrong results.
4142        PlanOp::Project { .. } => {
4143            return Err(
4144                "pull executor: Project reached pull_rows — plan is structurally malformed"
4145                    .to_string(),
4146            );
4147        }
4148        PlanOp::Distinct => {
4149            return Err(
4150                "pull executor: Distinct reached pull_rows — DISTINCT queries must use \
4151                 the staged path (row_bound returns None)"
4152                    .to_string(),
4153            );
4154        }
4155        PlanOp::OrderBy { .. } => {
4156            return Err(
4157                "pull executor: OrderBy reached pull_rows — queries with ORDER BY \
4158                 must use the staged path (row_bound returns None)"
4159                    .to_string(),
4160            );
4161        }
4162        PlanOp::Skip(_) => {
4163            return Err(
4164                "pull executor: Skip reached pull_rows — Skip must appear after Project"
4165                    .to_string(),
4166            );
4167        }
4168        PlanOp::Limit(_) => {
4169            return Err(
4170                "pull executor: Limit reached pull_rows — Limit must appear after Project"
4171                    .to_string(),
4172            );
4173        }
4174        PlanOp::Aggregate { .. } => {
4175            return Err(
4176                "pull executor: Aggregate reached pull_rows — aggregate plans must use \
4177                 the execute_aggregate path (routed before pull in execute_inner)"
4178                    .to_string(),
4179            );
4180        }
4181        // VarExpand and ShortestPath always take the staged path (row_bound()
4182        // returns None for plans containing these ops, so pull_rows is never
4183        // called with them in the producer slice).  This arm exists so that
4184        // adding new variants to PlanOp forces a compile-time decision here.
4185        PlanOp::VarExpand { .. } => {
4186            return Err(
4187                "pull executor: VarExpand reached pull_rows — variable-length path \
4188                 plans must use the staged path (row_bound returns None)"
4189                    .to_string(),
4190            );
4191        }
4192        PlanOp::ShortestPath { .. } => {
4193            return Err(
4194                "pull executor: ShortestPath reached pull_rows — shortestPath plans \
4195                 must use the staged path (row_bound returns None)"
4196                    .to_string(),
4197            );
4198        }
4199        PlanOp::GroupAggregate { .. } => {
4200            return Err(
4201                "pull executor: GroupAggregate reached pull_rows — grouped aggregate plans \
4202                 must use the execute_group_aggregate path (routed before pull in execute_inner)"
4203                    .to_string(),
4204            );
4205        }
4206        PlanOp::With { .. } => {
4207            return Err(
4208                "pull executor: With reached pull_rows — pipeline plans must use the staged path \
4209                 (row_bound returns None for plans containing With)"
4210                    .to_string(),
4211            );
4212        }
4213        PlanOp::Unwind { .. } => {
4214            return Err(
4215                "pull executor: Unwind reached pull_rows — pipeline plans must use the staged path \
4216                 (row_bound returns None for plans containing Unwind)"
4217                    .to_string(),
4218            );
4219        }
4220        PlanOp::LeftOuterApply { .. } => {
4221            return Err(
4222                "pull executor: LeftOuterApply reached pull_rows — OPTIONAL MATCH plans must use \
4223                 the staged path (row_bound returns None for plans containing LeftOuterApply)"
4224                    .to_string(),
4225            );
4226        }
4227    }
4228    Ok(())
4229}
4230
4231fn eval_expr(
4232    view: &GraphView,
4233    vars: &VarTable,
4234    row: &Row,
4235    expr: &Expr,
4236    params: &Params,
4237    depth: u32,
4238) -> Result<bool, String> {
4239    if depth > 256 {
4240        return Err("expression nesting too deep".into());
4241    }
4242    match expr {
4243        Expr::And(lhs, rhs) => {
4244            let l = eval_expr(view, vars, row, lhs, params, depth + 1)?;
4245            let r = eval_expr(view, vars, row, rhs, params, depth + 1)?;
4246            Ok(l && r)
4247        }
4248        Expr::Or(lhs, rhs) => {
4249            let l = eval_expr(view, vars, row, lhs, params, depth + 1)?;
4250            let r = eval_expr(view, vars, row, rhs, params, depth + 1)?;
4251            Ok(l || r)
4252        }
4253        Expr::Not(inner) => Ok(!eval_expr(view, vars, row, inner, params, depth + 1)?),
4254        Expr::Cmp { lhs, op, rhs } => {
4255            let l = resolve_operand(view, vars, row, lhs, params)?;
4256            let r = resolve_operand(view, vars, row, rhs, params)?;
4257            match (l, r) {
4258                (Some(a), Some(b)) => Ok(eval_cmp(op, &a, &b)),
4259                _ => Ok(false),
4260            }
4261        }
4262        Expr::Truthy(op) => {
4263            let val = resolve_operand(view, vars, row, op, params)?;
4264            Ok(match val {
4265                None => false,
4266                Some(Value::Bool(b)) => b,
4267                Some(Value::Int(n)) => n != 0,
4268                Some(Value::Float(f)) => f != 0.0,
4269                Some(Value::Str(s)) => !s.is_empty(),
4270                Some(Value::List(v)) => !v.is_empty(),
4271                Some(Value::Map(m)) => !m.is_empty(),
4272            })
4273        }
4274        Expr::IsNull(op) => {
4275            let val = resolve_operand(view, vars, row, op, params)?;
4276            Ok(val.is_none())
4277        }
4278        Expr::IsNotNull(op) => {
4279            let val = resolve_operand(view, vars, row, op, params)?;
4280            Ok(val.is_some())
4281        }
4282        Expr::In { expr, list } => eval_in(view, vars, row, expr, list, params),
4283    }
4284}
4285
4286fn eval_in(
4287    view: &GraphView,
4288    vars: &VarTable,
4289    row: &Row,
4290    expr: &Operand,
4291    list: &[Operand],
4292    params: &Params,
4293) -> Result<bool, String> {
4294    let Some(needle) = resolve_operand(view, vars, row, expr, params)? else {
4295        return Ok(false);
4296    };
4297    for item_op in list {
4298        match resolve_operand(view, vars, row, item_op, params)? {
4299            None => {}
4300            Some(Value::List(items)) => {
4301                for item in items {
4302                    if crate::filter::eval_cmp(&crate::filter::CmpOp::Eq, &needle, &item) {
4303                        return Ok(true);
4304                    }
4305                }
4306            }
4307            Some(item) if crate::filter::eval_cmp(&crate::filter::CmpOp::Eq, &needle, &item) => {
4308                return Ok(true);
4309            }
4310            Some(_) => {}
4311        }
4312    }
4313    Ok(false)
4314}
4315
4316/// Deduplicate projected rows. Numeric Int/Float unify matches grouping.
4317fn exec_distinct(table: &mut Projected) -> Result<(), String> {
4318    let cap = max_intermediate_rows();
4319    let mut seen: BTreeSet<Vec<Option<ValueKey>>> = BTreeSet::new();
4320    let mut out = Vec::with_capacity(table.rows.len().min(cap));
4321    for row in table.rows.drain(..) {
4322        let key: Vec<Option<ValueKey>> = row
4323            .iter()
4324            .map(|cell| cell.as_ref().and_then(group_key_normalize))
4325            .collect();
4326        if seen.insert(key) {
4327            if out.len() >= cap {
4328                return Err(row_cap_err(cap));
4329            }
4330            out.push(row);
4331        }
4332    }
4333    table.rows = out;
4334    Ok(())
4335}
4336
4337fn column_name(item: &RetItem) -> String {
4338    if let Some(alias) = &item.alias {
4339        return alias.clone();
4340    }
4341    // Agg column names are computed by the planner and stored on the plan op;
4342    // this branch is unreachable for well-formed plans but needed for
4343    // exhaustiveness.
4344    ret_val_label(&item.value).unwrap_or_else(|| match &item.value {
4345        RetVal::Agg { func, arg } => {
4346            let f = match func {
4347                AggFunc::Count => "COUNT",
4348                AggFunc::Sum => "SUM",
4349                AggFunc::Avg => "AVG",
4350                AggFunc::Min => "MIN",
4351                AggFunc::Max => "MAX",
4352                AggFunc::Collect => "COLLECT",
4353            };
4354            format!("{f}({})", agg_arg_label(arg))
4355        }
4356        _ => unreachable!("ret_val_label names every non-aggregate item"),
4357    })
4358}
4359
4360fn exec_project(
4361    view: &GraphView,
4362    vars: &VarTable,
4363    rows: &[Row],
4364    items: &[RetItem],
4365    params: &Params,
4366) -> Result<Projected, String> {
4367    let columns: Vec<String> = items.iter().map(column_name).collect();
4368    let mut out_rows = Vec::with_capacity(rows.len());
4369    for row in rows {
4370        let mut cells = Vec::with_capacity(items.len());
4371        for item in items {
4372            cells.push(project_item(view, vars, row, item, params)?);
4373        }
4374        out_rows.push(cells);
4375    }
4376    Ok(Projected {
4377        columns,
4378        rows: out_rows,
4379    })
4380}
4381
4382fn project_item(
4383    view: &GraphView,
4384    vars: &VarTable,
4385    row: &Row,
4386    item: &RetItem,
4387    params: &Params,
4388) -> Result<Option<Value>, String> {
4389    match &item.value {
4390        RetVal::Var(v) => {
4391            // Look up the slot; missing from VarTable entirely is a hard error.
4392            let slot = vars.slot(v).ok_or_else(|| format!("unbound variable `{v}`"))?;
4393            match row.get(slot).and_then(|c| c.as_ref()) {
4394                // Cell is None — variable is optionally null (from OPTIONAL MATCH).
4395                None => Ok(None),
4396                Some(Cell::Node(id)) => match view.ids.key_of(*id) {
4397                    Some(key) => Ok(Some(Value::Str(key.to_owned()))),
4398                    None => Err(format!("unknown node id {id}")),
4399                },
4400                // Scalar alias produced by a prior WITH stage.
4401                Some(Cell::Scalar(val)) => Ok(Some(val.clone())),
4402                Some(Cell::Rel(_)) => Err(format!(
4403                    "variable `{v}` is a relationship; return its properties ({v}.field) instead"
4404                )),
4405                Some(Cell::Path(hops)) => Ok(Some(Value::Int(*hops as i64))),
4406            }
4407        }
4408        RetVal::Prop { var, field } => resolve_prop(view, vars, row, var, field),
4409        // Agg items are never projected by exec_project (aggregate plans have no
4410        // Project op). This arm exists solely to satisfy the exhaustive match.
4411        RetVal::Agg { .. } => Err(
4412            "project_item: Agg variant reached exec_project — aggregate plans must not contain Project"
4413                .to_string(),
4414        ),
4415        RetVal::FuncCall { name, args } => eval_func(name, args, view, vars, row, params),
4416        RetVal::ScalarExpr(op) => resolve_operand(view, vars, row, op, params),
4417    }
4418}
4419
4420fn order_column(item: &OrderItem) -> String {
4421    match &item.target {
4422        OrderTarget::Alias(name) | OrderTarget::Var(name) => name.clone(),
4423        OrderTarget::Prop { var, field } => format!("{var}.{field}"),
4424    }
4425}
4426
4427fn exec_order_by(table: &mut Projected, items: &[OrderItem]) -> Result<(), String> {
4428    let mut keys = Vec::with_capacity(items.len());
4429    for item in items {
4430        let name = order_column(item);
4431        let idx = table
4432            .columns
4433            .iter()
4434            .position(|c| c == &name)
4435            .ok_or_else(|| format!("ORDER BY target `{name}` is not a projected column"))?;
4436        keys.push((idx, item.descending));
4437    }
4438    table.rows.sort_by(|a, b| {
4439        for &(idx, desc) in &keys {
4440            let c = cmp_optional(
4441                a.get(idx).and_then(|x| x.as_ref()),
4442                b.get(idx).and_then(|x| x.as_ref()),
4443                desc,
4444            );
4445            if c != std::cmp::Ordering::Equal {
4446                return c;
4447            }
4448        }
4449        std::cmp::Ordering::Equal
4450    });
4451    Ok(())
4452}
4453
4454/// Resolve a `LimitSkip` value to a concrete `u64` using the query params map.
4455///
4456/// `LimitSkip::Exact(n)` resolves immediately.  `LimitSkip::Param(name)` looks
4457/// up the named parameter and validates it is a non-negative integer.
4458fn resolve_ls(ls: &LimitSkip, params: &Params) -> Result<u64, String> {
4459    match ls {
4460        LimitSkip::Exact(n) => Ok(*n),
4461        LimitSkip::Param(name) => {
4462            let val = params
4463                .0
4464                .get(name)
4465                .ok_or_else(|| format!("missing parameter `{name}` (used in LIMIT/SKIP)"))?;
4466            match val {
4467                Value::Int(i) if *i >= 0 => Ok(*i as u64),
4468                Value::Int(i) => Err(format!(
4469                    "LIMIT/SKIP parameter `{name}` must be a non-negative integer, got {i}"
4470                )),
4471                other => Err(format!(
4472                    "LIMIT/SKIP parameter `{name}` must be an integer, got {other:?}"
4473                )),
4474            }
4475        }
4476    }
4477}
4478
4479fn apply_skip<T>(rows: &mut Vec<T>, n: u64) {
4480    let n = usize::try_from(n).unwrap_or(usize::MAX);
4481    if n >= rows.len() {
4482        rows.clear();
4483    } else {
4484        rows.drain(0..n);
4485    }
4486}
4487
4488fn apply_limit<T>(rows: &mut Vec<T>, n: u64) {
4489    let n = usize::try_from(n).unwrap_or(usize::MAX);
4490    rows.truncate(n);
4491}
4492
4493#[cfg(test)]
4494mod tests {
4495    use super::{execute, resolve_operand, Params, Row, VarTable};
4496    use crate::cypher::ast::{
4497        ArithOp, LimitSkip, Operand, OrderItem, OrderTarget, RetItem, RetVal,
4498    };
4499    use crate::cypher::plan::{plan, PlanOp};
4500    use crate::cypher::{lex, parse, RelDir};
4501    use crate::result::ResultSet;
4502    use crate::view::GraphView;
4503    use core_storage::v8::seam::{ColumnsView, EdgePropsView, TopologyView};
4504    use core_storage::{ColumnStore, EdgeProps, IdMap, Interner, Topology, Value};
4505    use proptest::prelude::*;
4506    use std::collections::BTreeMap;
4507
4508    struct Fx {
4509        ids: IdMap,
4510        syms: Interner,
4511        labels: Vec<u32>,
4512        props: ColumnStore,
4513        topo: Topology,
4514        eprops: EdgeProps,
4515    }
4516
4517    impl Fx {
4518        fn new() -> Self {
4519            Fx {
4520                ids: IdMap::new(),
4521                syms: Interner::new(),
4522                labels: vec![],
4523                props: ColumnStore::new(),
4524                topo: Topology::new(),
4525                eprops: EdgeProps::new(),
4526            }
4527        }
4528
4529        fn add(&mut self, label: &str, key: &str, props: Vec<(&str, Value)>) -> u32 {
4530            let id = self.ids.get_or_insert(key);
4531            let sym = self.syms.intern(label);
4532            self.labels.resize(id as usize + 1, u32::MAX);
4533            self.labels[id as usize] = sym;
4534            for (f, v) in props {
4535                self.props.set(id, f, v);
4536            }
4537            id
4538        }
4539
4540        fn edge(&mut self, etype: &str, src: u32, dst: u32, props: Vec<(&str, Value)>) {
4541            let et = self.syms.intern(etype);
4542            self.topo.add_edge(et, src, dst);
4543            for (f, v) in props {
4544                self.eprops.set(et, src, dst, f, v);
4545            }
4546        }
4547
4548        fn view(&self) -> GraphView<'_> {
4549            GraphView {
4550                ids: &self.ids,
4551                syms: &self.syms,
4552                labels: &self.labels,
4553                props: ColumnsView::owned(&self.props),
4554                topo: TopologyView::owned(&self.topo),
4555                edge_props: EdgePropsView::owned(&self.eprops),
4556                mask: None,
4557                prop_index: None,
4558            }
4559        }
4560
4561        fn view_indexed<'a>(
4562            &'a self,
4563            index: &'a core_storage::property_index::PropertyIndex,
4564        ) -> GraphView<'a> {
4565            GraphView {
4566                prop_index: Some(index),
4567                ..self.view()
4568            }
4569        }
4570    }
4571
4572    fn compile(src: &str) -> Vec<PlanOp> {
4573        plan(&parse(&lex(src).expect("lex")).expect("parse")).expect("plan")
4574    }
4575
4576    fn run(
4577        view: &GraphView,
4578        src: &str,
4579        params: &BTreeMap<String, Value>,
4580    ) -> Result<ResultSet, String> {
4581        execute(view, &compile(src), &Params(params))
4582    }
4583
4584    fn s(v: &str) -> Value {
4585        Value::Str(v.into())
4586    }
4587
4588    fn f(v: f64) -> Value {
4589        Value::Float(v)
4590    }
4591
4592    fn i(v: i64) -> Value {
4593        Value::Int(v)
4594    }
4595
4596    fn rows_of(rs: &ResultSet) -> Vec<Vec<Option<Value>>> {
4597        (0..rs.len()).map(|i| rs.row(i).to_vec()).collect()
4598    }
4599
4600    fn col(rs: &ResultSet, name: &str) -> Vec<Option<Value>> {
4601        (0..rs.len()).map(|i| rs.get(i, name).cloned()).collect()
4602    }
4603
4604    fn hop_graph() -> Fx {
4605        let mut fx = Fx::new();
4606        let ada = fx.add("Person", "ada", vec![]);
4607        let bob = fx.add("Person", "bob", vec![]);
4608        let cam = fx.add("Person", "cam", vec![]);
4609        let acme = fx.add("Company", "acme", vec![]);
4610        fx.edge("KNOWS", ada, bob, vec![]);
4611        fx.edge("KNOWS", ada, cam, vec![]);
4612        fx.edge("KNOWS", bob, cam, vec![]);
4613        fx.edge("LIKES", ada, acme, vec![]);
4614        fx
4615    }
4616
4617    fn undirected_graph() -> Fx {
4618        let mut fx = Fx::new();
4619        let a = fx.add("N", "a", vec![]);
4620        let b = fx.add("N", "b", vec![]);
4621        fx.edge("T", a, b, vec![("w", i(42))]);
4622        fx
4623    }
4624
4625    fn triangle() -> Fx {
4626        let mut fx = Fx::new();
4627        let a = fx.add("N", "a", vec![]);
4628        let b = fx.add("N", "b", vec![]);
4629        let c = fx.add("N", "c", vec![]);
4630        fx.edge("T", a, b, vec![("eid", i(1))]);
4631        fx.edge("T", b, c, vec![("eid", i(2))]);
4632        fx.edge("T", c, a, vec![("eid", i(3))]);
4633        fx
4634    }
4635
4636    fn single_edge() -> (Fx, u32, u32) {
4637        let mut fx = Fx::new();
4638        let a = fx.add("N", "a", vec![]);
4639        let b = fx.add("N", "b", vec![]);
4640        fx.edge("T", a, b, vec![]);
4641        (fx, a, b)
4642    }
4643
4644    /// Companies with scored INDUSTRY_ALIGNMENT / SPECIALTY_MATCH edges to t1.
4645    fn dogfood_graph() -> Fx {
4646        let mut fx = Fx::new();
4647        let t1 = fx.add("Talent", "t1", vec![("id", s("t1"))]);
4648        let acme = fx.add("Company", "acme", vec![]);
4649        let beta = fx.add("Company", "beta", vec![]);
4650        let gamma = fx.add("Company", "gamma", vec![]);
4651        let delta = fx.add("Company", "delta", vec![]);
4652        let echo = fx.add("Company", "echo", vec![]);
4653        let foxtrot = fx.add("Company", "foxtrot", vec![]);
4654        let zeta = fx.add("Company", "zeta", vec![]);
4655        fx.edge("INDUSTRY_ALIGNMENT", acme, t1, vec![("score", f(0.9))]);
4656        fx.edge("SPECIALTY_MATCH", acme, t1, vec![("score", f(0.8))]);
4657        fx.edge("INDUSTRY_ALIGNMENT", beta, t1, vec![("score", f(0.6))]);
4658        fx.edge("SPECIALTY_MATCH", beta, t1, vec![("score", f(0.7))]);
4659        fx.edge("INDUSTRY_ALIGNMENT", gamma, t1, vec![("score", f(0.4))]);
4660        fx.edge("SPECIALTY_MATCH", gamma, t1, vec![("score", f(0.9))]);
4661        fx.edge("INDUSTRY_ALIGNMENT", delta, t1, vec![("score", f(0.8))]);
4662        fx.edge("SPECIALTY_MATCH", delta, t1, vec![("score", f(0.3))]);
4663        fx.edge("INDUSTRY_ALIGNMENT", echo, t1, vec![("score", f(0.5))]);
4664        fx.edge("SPECIALTY_MATCH", echo, t1, vec![("score", f(0.5))]);
4665        fx.edge("INDUSTRY_ALIGNMENT", foxtrot, t1, vec![("score", f(0.95))]);
4666        fx.edge("INDUSTRY_ALIGNMENT", zeta, t1, vec![("score", f(0.9))]);
4667        fx.edge("SPECIALTY_MATCH", zeta, t1, vec![("score", f(0.6))]);
4668        fx
4669    }
4670
4671    const DOGFOOD: &str = "\
4672MATCH (t:Talent {id: $tid}) \
4673MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
4674MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
4675WHERE i.score >= 0.5 AND s.score >= 0.5 \
4676RETURN c, i.score AS industry, s.score AS specialty \
4677ORDER BY industry DESC, specialty DESC \
4678LIMIT 10";
4679
4680    fn tid_params() -> BTreeMap<String, Value> {
4681        let mut p = BTreeMap::new();
4682        p.insert("tid".into(), s("t1"));
4683        p
4684    }
4685
4686    #[test]
4687    fn single_hop_match_label_and_etype_filters() {
4688        let fx = hop_graph();
4689        let v = fx.view();
4690        let rs = run(
4691            &v,
4692            "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b",
4693            &BTreeMap::new(),
4694        )
4695        .expect("single-hop");
4696        assert_eq!(rs.columns(), &["a".to_string(), "b".to_string()]);
4697        assert_eq!(
4698            rows_of(&rs),
4699            vec![
4700                vec![Some(s("ada")), Some(s("bob"))],
4701                vec![Some(s("ada")), Some(s("cam"))],
4702                vec![Some(s("bob")), Some(s("cam"))],
4703            ]
4704        );
4705        // dest label filters out ada -LIKES-> acme; etype filters it too
4706        let likes = run(
4707            &v,
4708            "MATCH (a:Person)-[:LIKES]->(b:Company) RETURN a, b",
4709            &BTreeMap::new(),
4710        )
4711        .unwrap();
4712        assert_eq!(rows_of(&likes), vec![vec![Some(s("ada")), Some(s("acme"))]]);
4713        let no_combo = run(
4714            &v,
4715            "MATCH (a:Person)-[:KNOWS]->(b:Company) RETURN a, b",
4716            &BTreeMap::new(),
4717        )
4718        .unwrap();
4719        assert!(no_combo.is_empty());
4720    }
4721
4722    #[test]
4723    fn undirected_match_finds_both_orientations_and_binds_true_triple() {
4724        let fx = undirected_graph();
4725        let v = fx.view();
4726        // w lives only on the true (T, a, b) triple. A reversed (T, b, a)
4727        // binding would project None.
4728        let rs =
4729            run(&v, "MATCH (x)-[r:T]-(y) RETURN x, y, r.w", &BTreeMap::new()).expect("undirected");
4730        assert_eq!(
4731            rows_of(&rs),
4732            vec![
4733                vec![Some(s("a")), Some(s("b")), Some(i(42))],
4734                vec![Some(s("b")), Some(s("a")), Some(i(42))],
4735            ]
4736        );
4737
4738        let left = run(
4739            &v,
4740            "MATCH (y)<-[r:T]-(x) RETURN x, y, r.w",
4741            &BTreeMap::new(),
4742        )
4743        .unwrap();
4744        assert_eq!(
4745            rows_of(&left),
4746            vec![vec![Some(s("a")), Some(s("b")), Some(i(42))]]
4747        );
4748    }
4749
4750    #[test]
4751    fn relationship_uniqueness_triangle_and_two_hop_cycle() {
4752        let tri = triangle();
4753        let v = tri.view();
4754        let rs = run(
4755            &v,
4756            "MATCH (x)-[r1:T]->(y)-[r2:T]->(z) RETURN x, y, z, r1.eid, r2.eid",
4757            &BTreeMap::new(),
4758        )
4759        .expect("triangle 2-hop");
4760        assert_eq!(
4761            rows_of(&rs),
4762            vec![
4763                vec![
4764                    Some(s("a")),
4765                    Some(s("b")),
4766                    Some(s("c")),
4767                    Some(i(1)),
4768                    Some(i(2))
4769                ],
4770                vec![
4771                    Some(s("b")),
4772                    Some(s("c")),
4773                    Some(s("a")),
4774                    Some(i(2)),
4775                    Some(i(3))
4776                ],
4777                vec![
4778                    Some(s("c")),
4779                    Some(s("a")),
4780                    Some(s("b")),
4781                    Some(i(3)),
4782                    Some(i(1))
4783                ],
4784            ]
4785        );
4786        for row in rows_of(&rs) {
4787            assert_ne!(row[3], row[4], "r1 must never bind the same edge as r2");
4788        }
4789
4790        let (mut one, a, b) = single_edge();
4791        let v = one.view();
4792        let cycle = run(
4793            &v,
4794            "MATCH (x)-[:T]->(y)-[:T]->(x) RETURN x",
4795            &BTreeMap::new(),
4796        )
4797        .expect("2-hop cycle");
4798        assert!(
4799            cycle.is_empty(),
4800            "single directed edge cannot close a 2-hop cycle"
4801        );
4802
4803        // Undirected 2-hop back would reuse the only EdgeRef without uniqueness.
4804        let undirected_cycle = run(&v, "MATCH (x)-[:T]-(y)-[:T]-(x) RETURN x", &BTreeMap::new())
4805            .expect("undirected uniqueness");
4806        assert!(
4807            undirected_cycle.is_empty(),
4808            "relationship uniqueness must reject walking the same triple back"
4809        );
4810
4811        one.edge("T", b, a, vec![]);
4812        let v = one.view();
4813        let with_recip = run(
4814            &v,
4815            "MATCH (x)-[:T]->(y)-[:T]->(x) RETURN x",
4816            &BTreeMap::new(),
4817        )
4818        .unwrap();
4819        assert_eq!(col(&with_recip, "x"), vec![Some(s("a")), Some(s("b"))]);
4820    }
4821
4822    #[test]
4823    fn multi_match_join_bound_and_bound_destination_expand() {
4824        let fx = dogfood_graph();
4825        let v = fx.view();
4826        // No WHERE: any company with *both* edge types into the bound talent.
4827        // foxtrot has industry only → dropped by the second (bound-dest) expand.
4828        let rs = run(
4829            &v,
4830            "MATCH (t:Talent {id: $tid}) \
4831             MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
4832             MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
4833             RETURN c",
4834            &tid_params(),
4835        )
4836        .expect("join + bound dest");
4837        assert_eq!(
4838            col(&rs, "c"),
4839            vec![
4840                Some(s("acme")),
4841                Some(s("beta")),
4842                Some(s("gamma")),
4843                Some(s("delta")),
4844                Some(s("echo")),
4845                Some(s("zeta")),
4846            ]
4847        );
4848
4849        // Empty JoinBound (MATCH 2 start already bound, no label/props) keeps all.
4850        let keep = run(&v, "MATCH (c:Company) MATCH (c) RETURN c", &BTreeMap::new()).unwrap();
4851        assert_eq!(keep.len(), 7);
4852        // Label re-check on JoinBound drops everything.
4853        let drop = run(
4854            &v,
4855            "MATCH (c:Company) MATCH (c:Talent) RETURN c",
4856            &BTreeMap::new(),
4857        )
4858        .unwrap();
4859        assert!(drop.is_empty());
4860    }
4861
4862    #[test]
4863    fn scan_key_exec_does_not_use_label_scan() {
4864        let mut fx = Fx::new();
4865        fx.add("Person", "p1", vec![]);
4866        fx.add("Person", "p2", vec![]);
4867        fx.add("Person", "p3", vec![]);
4868        fx.add("Company", "c1", vec![]);
4869        let v = fx.view();
4870        let params = BTreeMap::new();
4871
4872        let fires_before = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
4873        let rs = run(&v, "MATCH (n:Person {id: 'p2'}) RETURN n", &params).expect("scan key");
4874        let fires_after = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
4875        assert!(
4876            fires_after > fires_before,
4877            "SCAN_KEY_FIRES must increment; before={fires_before} after={fires_after}"
4878        );
4879        assert_eq!(rows_of(&rs), vec![vec![Some(s("p2"))]]);
4880
4881        let miss = run(&v, "MATCH (n:Person {id: 'nope'}) RETURN n", &params).unwrap();
4882        assert!(
4883            miss.is_empty(),
4884            "missing key must be zero rows, not an error"
4885        );
4886
4887        let wrong = run(&v, "MATCH (n:Person {id: 'c1'}) RETURN n", &params).unwrap();
4888        assert!(
4889            wrong.is_empty(),
4890            "wrong label must be zero rows, not an error"
4891        );
4892    }
4893
4894    #[test]
4895    fn rel_var_edge_prop_filter() {
4896        let mut fx = Fx::new();
4897        let a = fx.add("N", "a", vec![]);
4898        let b = fx.add("N", "b", vec![]);
4899        let c = fx.add("N", "c", vec![]);
4900        fx.edge("T", a, b, vec![("w", f(0.7))]);
4901        fx.edge("T", a, c, vec![("w", f(0.3))]);
4902        let v = fx.view();
4903        let rs = run(
4904            &v,
4905            "MATCH (x)-[r:T]->(y) WHERE r.w >= 0.5 RETURN y, r.w",
4906            &BTreeMap::new(),
4907        )
4908        .expect("edge-prop filter");
4909        assert_eq!(rows_of(&rs), vec![vec![Some(s("b")), Some(f(0.7))]]);
4910        let fail = run(
4911            &v,
4912            "MATCH (x)-[r:T]->(y) WHERE r.w >= 0.8 RETURN y",
4913            &BTreeMap::new(),
4914        )
4915        .unwrap();
4916        assert!(fail.is_empty());
4917    }
4918
4919    #[test]
4920    fn params_present_resolve_missing_is_err_before_rows() {
4921        let fx = dogfood_graph();
4922        let v = fx.view();
4923        let hit =
4924            run(&v, "MATCH (t:Talent {id: $tid}) RETURN t", &tid_params()).expect("present param");
4925        assert_eq!(col(&hit, "t"), vec![Some(s("t1"))]);
4926
4927        // Unknown label would yield Ok(empty) if params were not walked first.
4928        let err = run(
4929            &v,
4930            "MATCH (t:NoSuchLabel {id: $tid}) RETURN t",
4931            &BTreeMap::new(),
4932        )
4933        .expect_err("missing param must be Err, not Ok(empty)");
4934        assert!(
4935            err.contains("tid")
4936                && (err.contains("param") || err.contains("Param") || err.contains("missing")),
4937            "missing-param error must name the parameter, got: {err}"
4938        );
4939
4940        let err = run(
4941            &v,
4942            "MATCH (t:Talent) WHERE t.id = $tid RETURN t",
4943            &BTreeMap::new(),
4944        )
4945        .expect_err("missing WHERE param");
4946        assert!(err.contains("tid"), "got: {err}");
4947    }
4948
4949    #[test]
4950    fn order_by_none_last_then_skip_limit() {
4951        let mut fx = Fx::new();
4952        fx.add("Person", "ada", vec![("age", i(30))]);
4953        fx.add("Person", "bob", vec![]); // missing age → None
4954        fx.add("Person", "cam", vec![("age", i(10))]);
4955        fx.add("Person", "dan", vec![("age", i(20))]);
4956        let v = fx.view();
4957
4958        let asc = run(
4959            &v,
4960            "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age",
4961            &BTreeMap::new(),
4962        )
4963        .unwrap();
4964        assert_eq!(
4965            rows_of(&asc),
4966            vec![
4967                vec![Some(s("cam")), Some(i(10))],
4968                vec![Some(s("dan")), Some(i(20))],
4969                vec![Some(s("ada")), Some(i(30))],
4970                vec![Some(s("bob")), None],
4971            ]
4972        );
4973
4974        let desc = run(
4975            &v,
4976            "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age DESC",
4977            &BTreeMap::new(),
4978        )
4979        .unwrap();
4980        assert_eq!(
4981            rows_of(&desc),
4982            vec![
4983                vec![Some(s("ada")), Some(i(30))],
4984                vec![Some(s("dan")), Some(i(20))],
4985                vec![Some(s("cam")), Some(i(10))],
4986                vec![Some(s("bob")), None],
4987            ]
4988        );
4989
4990        let skip_lim = run(
4991            &v,
4992            "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age SKIP 1 LIMIT 2",
4993            &BTreeMap::new(),
4994        )
4995        .unwrap();
4996        assert_eq!(
4997            rows_of(&skip_lim),
4998            vec![
4999                vec![Some(s("dan")), Some(i(20))],
5000                vec![Some(s("ada")), Some(i(30))],
5001            ]
5002        );
5003
5004        let desc_sl = run(
5005            &v,
5006            "MATCH (p:Person) RETURN p, p.age AS age ORDER BY age DESC SKIP 1 LIMIT 2",
5007            &BTreeMap::new(),
5008        )
5009        .unwrap();
5010        assert_eq!(
5011            rows_of(&desc_sl),
5012            vec![
5013                vec![Some(s("dan")), Some(i(20))],
5014                vec![Some(s("cam")), Some(i(10))],
5015            ]
5016        );
5017    }
5018
5019    #[test]
5020    fn unknown_label_and_etype_are_ok_empty() {
5021        let fx = hop_graph();
5022        let v = fx.view();
5023        let lab = run(&v, "MATCH (x:Nope) RETURN x", &BTreeMap::new()).expect("unknown label");
5024        assert!(lab.is_empty());
5025        let et = run(
5026            &v,
5027            "MATCH (a)-[:NO_SUCH_ETYPE]->(b) RETURN a",
5028            &BTreeMap::new(),
5029        )
5030        .expect("unknown etype");
5031        assert!(et.is_empty());
5032    }
5033
5034    #[test]
5035    fn execute_is_deterministic() {
5036        let fx = dogfood_graph();
5037        let v = fx.view();
5038        let p = tid_params();
5039        let a = run(&v, DOGFOOD, &p).expect("first");
5040        let b = run(&v, DOGFOOD, &p).expect("second");
5041        assert_eq!(a, b);
5042        let hop = hop_graph();
5043        let hv = hop.view();
5044        let q = "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b";
5045        assert_eq!(run(&hv, q, &BTreeMap::new()), run(&hv, q, &BTreeMap::new()));
5046    }
5047
5048    #[test]
5049    fn dogfood_pipeline_exact_rows() {
5050        let fx = dogfood_graph();
5051        let v = fx.view();
5052        let rs = run(&v, DOGFOOD, &tid_params()).expect("dogfood");
5053        assert_eq!(
5054            rs.columns(),
5055            &[
5056                "c".to_string(),
5057                "industry".to_string(),
5058                "specialty".to_string()
5059            ]
5060        );
5061        // industry DESC, specialty DESC; gamma (0.4) / delta (0.3) / foxtrot (no s) out.
5062        assert_eq!(
5063            rows_of(&rs),
5064            vec![
5065                vec![Some(s("acme")), Some(f(0.9)), Some(f(0.8))],
5066                vec![Some(s("zeta")), Some(f(0.9)), Some(f(0.6))],
5067                vec![Some(s("beta")), Some(f(0.6)), Some(f(0.7))],
5068                vec![Some(s("echo")), Some(f(0.5)), Some(f(0.5))],
5069            ]
5070        );
5071    }
5072
5073    #[test]
5074    fn unknown_var_in_op_is_err_not_panic() {
5075        let fx = hop_graph();
5076        let v = fx.view();
5077        let plan = vec![
5078            PlanOp::ScanLabel {
5079                var: "a".into(),
5080                label: None,
5081            },
5082            PlanOp::Expand {
5083                from: "zzz".into(),
5084                rel_var: Some("r".into()),
5085                etypes: vec![],
5086                dir: RelDir::Right,
5087                to: "b".into(),
5088                to_label: None,
5089                to_props: vec![],
5090            },
5091            PlanOp::Project {
5092                items: vec![RetItem {
5093                    value: RetVal::Var("a".into()),
5094                    alias: None,
5095                }],
5096            },
5097        ];
5098        let params = BTreeMap::new();
5099        let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5100            execute(&v, &plan, &Params(&params))
5101        }));
5102        assert!(caught.is_ok(), "execute panicked on unknown var");
5103        let err = caught.unwrap().expect_err("unknown var must be Err");
5104        assert!(
5105            err.contains("zzz") && err.to_ascii_lowercase().contains("unbound"),
5106            "got: {err}"
5107        );
5108
5109        let join = vec![
5110            PlanOp::JoinBound {
5111                var: "ghost".into(),
5112                label: None,
5113                props: vec![],
5114            },
5115            PlanOp::Project {
5116                items: vec![RetItem {
5117                    value: RetVal::Var("ghost".into()),
5118                    alias: None,
5119                }],
5120            },
5121        ];
5122        let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5123            execute(&v, &join, &Params(&params))
5124        }));
5125        assert!(caught.is_ok(), "execute panicked on JoinBound unknown var");
5126        assert!(caught.unwrap().is_err());
5127    }
5128
5129    #[test]
5130    fn dest_props_on_bound_expand_are_applied() {
5131        let fx = dogfood_graph();
5132        let v = fx.view();
5133        let rs = run(
5134            &v,
5135            "MATCH (t:Talent {id: $tid}) \
5136             MATCH (c:Company)-[r:INDUSTRY_ALIGNMENT]->(t:Talent {id: $tid}) \
5137             RETURN c",
5138            &tid_params(),
5139        )
5140        .unwrap();
5141        // foxtrot included (has industry); companies without the edge are not.
5142        assert_eq!(
5143            col(&rs, "c"),
5144            vec![
5145                Some(s("acme")),
5146                Some(s("beta")),
5147                Some(s("gamma")),
5148                Some(s("delta")),
5149                Some(s("echo")),
5150                Some(s("foxtrot")),
5151                Some(s("zeta")),
5152            ]
5153        );
5154        let miss = run(
5155            &v,
5156            "MATCH (t:Talent {id: $tid}) \
5157             MATCH (c:Company)-[r:INDUSTRY_ALIGNMENT]->(t {id: 'nope'}) \
5158             RETURN c",
5159            &tid_params(),
5160        )
5161        .unwrap();
5162        assert!(miss.is_empty());
5163    }
5164
5165    #[test]
5166    fn missing_node_prop_projects_none_and_pattern_misses() {
5167        let mut fx = Fx::new();
5168        fx.add("Person", "ada", vec![("age", i(30))]);
5169        fx.add("Person", "bob", vec![]);
5170        let v = fx.view();
5171        let rs = run(&v, "MATCH (p:Person) RETURN p.age", &BTreeMap::new()).unwrap();
5172        assert_eq!(col(&rs, "p.age"), vec![Some(i(30)), None]);
5173        let pat = run(&v, "MATCH (p:Person {age: 30}) RETURN p", &BTreeMap::new()).unwrap();
5174        assert_eq!(col(&pat, "p"), vec![Some(s("ada"))]);
5175    }
5176
5177    #[test]
5178    fn unlabeled_match_does_not_project_sentinel_ghost_key() {
5179        let mut fx = Fx::new();
5180        fx.add("Person", "ada", vec![]);
5181        // Hostile fixture: IdMap slot exists (key_of would yield "ghost")
5182        // but the label is the gap sentinel.
5183        fx.ids.get_or_insert("ghost");
5184        fx.labels.resize(fx.ids.len(), u32::MAX);
5185        fx.add("Person", "bob", vec![]);
5186        let v = fx.view();
5187        let rs = run(&v, "MATCH (n) RETURN n", &BTreeMap::new()).expect("unlabeled scan");
5188        assert_eq!(col(&rs, "n"), vec![Some(s("ada")), Some(s("bob"))]);
5189        assert!(
5190            !rows_of(&rs)
5191                .iter()
5192                .any(|row| row.iter().any(|c| *c == Some(s("ghost")))),
5193            "sentinel slot must not project a ghost key"
5194        );
5195    }
5196
5197    #[test]
5198    fn execute_never_panics_on_hostile_plans() {
5199        let fx = hop_graph();
5200        let v = fx.view();
5201        let params = BTreeMap::new();
5202        let hostile = vec![
5203            vec![],
5204            vec![PlanOp::Project { items: vec![] }],
5205            vec![PlanOp::OrderBy {
5206                items: vec![OrderItem {
5207                    target: OrderTarget::Alias("nope".into()),
5208                    descending: false,
5209                }],
5210            }],
5211            vec![PlanOp::Filter {
5212                expr: crate::cypher::ast::Expr::Cmp {
5213                    lhs: Operand::Prop {
5214                        var: "missing".into(),
5215                        field: "x".into(),
5216                    },
5217                    op: crate::filter::CmpOp::Eq,
5218                    rhs: Operand::Lit(i(1)),
5219                },
5220            }],
5221            vec![
5222                PlanOp::ScanLabel {
5223                    var: "a".into(),
5224                    label: None,
5225                },
5226                PlanOp::LookupProps {
5227                    var: "zzz".into(),
5228                    props: vec![("k".into(), Operand::Lit(i(1)))],
5229                },
5230            ],
5231            vec![
5232                PlanOp::Skip(LimitSkip::Exact(99)),
5233                PlanOp::Limit(LimitSkip::Exact(0)),
5234            ],
5235        ];
5236        for plan in hostile {
5237            let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5238                execute(&v, &plan, &Params(&params))
5239            }));
5240            assert!(caught.is_ok(), "execute panicked on hostile plan: {plan:?}");
5241        }
5242    }
5243
5244    #[test]
5245    fn unlabeled_and_labeled_scans_skip_tombstoned_ids() {
5246        let mut fx = Fx::new();
5247        let ada = fx.add("Person", "ada", vec![]);
5248        let bob = fx.add("Person", "bob", vec![]);
5249        fx.edge("KNOWS", ada, bob, vec![]);
5250        // Same state delete_node leaves: id retired, label sentinel, edges gone.
5251        fx.ids.delete("ada");
5252        fx.labels[ada as usize] = u32::MAX;
5253        let knows = fx.syms.get("KNOWS").unwrap();
5254        fx.topo.remove_edge(knows, ada, bob);
5255        let v = fx.view();
5256
5257        let labeled = run(&v, "MATCH (p:Person) RETURN p", &BTreeMap::new()).unwrap();
5258        assert_eq!(col(&labeled, "p"), vec![Some(s("bob"))]);
5259        let unlabeled = run(&v, "MATCH (n) RETURN n", &BTreeMap::new()).unwrap();
5260        assert_eq!(col(&unlabeled, "n"), vec![Some(s("bob"))]);
5261        let hop = run(&v, "MATCH (x)-[:KNOWS]->(y) RETURN x, y", &BTreeMap::new()).unwrap();
5262        assert!(
5263            hop.is_empty(),
5264            "expand cannot yield edges to a deleted node once topology is swept"
5265        );
5266    }
5267
5268    // ──────────────────────────────────────────────────────────────────────────
5269    // Randomised property test (I2): bounded == unbounded[SKIP..SKIP+LIMIT]
5270    // ──────────────────────────────────────────────────────────────────────────
5271
5272    proptest! {
5273        /// Randomised property test covering:
5274        ///
5275        /// - **Multi-hop** (1, 2, or 3 hops) — exercises nested pull_rows recursion.
5276        /// - **Optional WHERE filter** on a numeric prop `v` — bound must count
5277        ///   only post-filter rows.
5278        /// - **Cycle / shared-node topologies** — duplicate directed pairs and
5279        ///   reciprocal edges create paths where relationship-uniqueness rejects
5280        ///   some traversals; rejected rows must not count toward the bound.
5281        /// - **Randomized SKIP + LIMIT** — pull collects SKIP+LIMIT rows then
5282        ///   the wrapper discards the leading SKIP.
5283        ///
5284        /// Invariant: bounded[0..] == unbounded[skip..skip+limit] for all shapes.
5285        #[test]
5286        fn prop_bounded_equals_unbounded_slice(
5287            n_nodes     in 2u32..10u32,
5288            edge_pairs  in proptest::collection::vec(
5289                (any::<u32>(), any::<u32>()), 0..20usize
5290            ),
5291            // Extra reciprocal pairs to force uniqueness rejections on cycles.
5292            recip_pairs in proptest::collection::vec(
5293                (any::<u32>(), any::<u32>()), 0..8usize
5294            ),
5295            n_hops      in 1u32..4u32,   // 1, 2, or 3 hops
5296            use_filter  in any::<bool>(),
5297            threshold   in 0i64..8i64,   // filter: last-node.v > threshold
5298            limit       in 1u64..10u64,
5299            skip        in 0u64..4u64,
5300        ) {
5301            let mut fx = Fx::new();
5302            let mut node_ids = Vec::new();
5303            for idx in 0..n_nodes {
5304                // Every node carries a numeric prop `v` for the optional filter.
5305                let id = fx.add("N", &format!("n{idx}"), vec![("v", i(idx as i64 % 8))]);
5306                node_ids.push(id);
5307            }
5308            let n = node_ids.len();
5309
5310            // Primary edges (forward direction).
5311            for (si, di) in &edge_pairs {
5312                let si = (*si as usize) % n;
5313                let di = (*di as usize) % n;
5314                if si != di {
5315                    fx.edge("T", node_ids[si], node_ids[di], vec![]);
5316                }
5317            }
5318            // Reciprocal edges — create A→B + B→A pairs so multi-hop paths
5319            // have uniqueness-rejected candidates (walking back the same edge).
5320            for (si, di) in &recip_pairs {
5321                let si = (*si as usize) % n;
5322                let di = (*di as usize) % n;
5323                if si != di {
5324                    fx.edge("T", node_ids[si], node_ids[di], vec![]);
5325                    fx.edge("T", node_ids[di], node_ids[si], vec![]);
5326                }
5327            }
5328
5329            let v = fx.view();
5330            let params = BTreeMap::new();
5331
5332            // Build query for `n_hops` hops with optional WHERE on the last node.
5333            // Variable names: a→b→c→d for hops 1/2/3.
5334            let var_names = ["a", "b", "c", "d"];
5335            let hop_count = n_hops as usize;
5336            let mut pattern = format!("({}:N)", var_names[0]);
5337            for h in 0..hop_count {
5338                pattern.push_str(&format!("-[:T]->({}", var_names[h + 1]));
5339                // Label the intermediate and last nodes as :N only for last.
5340                if h + 1 == hop_count {
5341                    pattern.push_str(":N)");
5342                } else {
5343                    pattern.push(')');
5344                }
5345            }
5346            let last_var = var_names[hop_count];
5347            let where_clause = if use_filter {
5348                format!(" WHERE {last_var}.v > {threshold}")
5349            } else {
5350                String::new()
5351            };
5352            let ret_vars: Vec<&str> = var_names[..=hop_count].to_vec();
5353            let ret_clause = ret_vars.join(", ");
5354
5355            let full_q = format!("MATCH {pattern}{where_clause} RETURN {ret_clause}");
5356            let bounded_q = format!(
5357                "MATCH {pattern}{where_clause} RETURN {ret_clause} SKIP {skip} LIMIT {limit}"
5358            );
5359
5360            let full_plan = compile(&full_q);
5361            // Use a generous cap for the reference run so it never errors on
5362            // small graphs, even with cycles.
5363            let unbounded = super::with_max_intermediate_rows(100_000, || {
5364                super::execute_unbounded(&v, &full_plan, &Params(&params))
5365            });
5366            // If the reference itself errors (shouldn't happen at this scale),
5367            // skip the proptest case rather than failing.
5368            let unbounded = match unbounded {
5369                Ok(rs) => rs,
5370                Err(_) => return Ok(()),
5371            };
5372            let total = unbounded.len();
5373            let full_rows = rows_of(&unbounded);
5374
5375            let bounded = super::with_max_intermediate_rows(100_000, || {
5376                run(&v, &bounded_q, &params)
5377            }).expect("bounded must not error");
5378
5379            let s = (skip as usize).min(total);
5380            let e = (skip as usize + limit as usize).min(total);
5381            prop_assert_eq!(
5382                rows_of(&bounded),
5383                full_rows[s..e].to_vec(),
5384                "hops={} filter={} threshold={} SKIP {} LIMIT {}: \
5385                 bounded != unbounded[{}..{}]",
5386                hop_count, use_filter, threshold, skip, limit, s, e
5387            );
5388        }
5389    }
5390
5391    proptest! {
5392        /// Randomised property test pinning the fused ScanLabel+Filter fast path.
5393        ///
5394        /// Shape: `MATCH (n:N) WHERE n.v <op> <literal> RETURN n SKIP s LIMIT l`
5395        ///
5396        /// This shape places a `Filter { Cmp { Prop{n}, op, Lit } }` immediately
5397        /// after `ScanLabel { var: n }` in the plan, which triggers the fused
5398        /// detection in `pull_rows`.  The invariant is the same as
5399        /// `prop_bounded_equals_unbounded_slice`: bounded == unbounded[s..s+l].
5400        ///
5401        /// Coverage:
5402        ///  - All 6 CmpOps (=, <>, <, <=, >, >=)
5403        ///  - Nodes that have the prop (Int or Float) and nodes that are missing it
5404        ///  - Threshold values spanning match-all, match-none, and partial
5405        ///  - Randomised SKIP and LIMIT
5406        ///  - Boundary shape: compound AND (`WHERE n.v op lit AND n.v >= -999`)
5407        ///    which is semantically equivalent but bypasses fused detection
5408        ///    (Expr::And, not Expr::Cmp), exercising the generic fallback path
5409        #[test]
5410        fn prop_scan_filter_fused_equals_unbounded_slice(
5411            n_nodes     in 0u32..20u32,
5412            // Bit i set → node i has prop `v`.
5413            prop_mask   in any::<u32>(),
5414            // Bit i set → node i stores a Float value instead of Int.
5415            float_mask  in any::<u32>(),
5416            // Comparison threshold; range -1..8 spans all/none/partial matches
5417            // against node values in 0..7.
5418            threshold   in -1i64..8i64,
5419            // Index into the 6 CmpOps (Eq=0, Ne=1, Lt=2, Le=3, Gt=4, Ge=5).
5420            op_idx      in 0u32..6u32,
5421            skip        in 0u64..5u64,
5422            limit       in 1u64..8u64,
5423        ) {
5424            let op_str = match op_idx {
5425                0 => "=",
5426                1 => "<>",
5427                2 => "<",
5428                3 => "<=",
5429                4 => ">",
5430                _ => ">=",
5431            };
5432
5433            let mut fx = Fx::new();
5434            for idx in 0..n_nodes {
5435                let has_prop = (prop_mask >> (idx % 32)) & 1 == 1;
5436                let use_float = (float_mask >> (idx % 32)) & 1 == 1;
5437                // Node values cycle in 0..7 so threshold spans all/none/partial.
5438                let props: Vec<(&str, Value)> = if has_prop {
5439                    if use_float {
5440                        vec![("v", f(idx as f64 % 7.0))]
5441                    } else {
5442                        vec![("v", i(idx as i64 % 7))]
5443                    }
5444                } else {
5445                    vec![]
5446                };
5447                fx.add("N", &format!("n{idx}"), props);
5448            }
5449
5450            let v = fx.view();
5451            let params = BTreeMap::new();
5452
5453            // ── Fused path ───────────────────────────────────────────────────
5454            let full_q = format!("MATCH (n:N) WHERE n.v {op_str} {threshold} RETURN n");
5455            let bounded_q = format!(
5456                "MATCH (n:N) WHERE n.v {op_str} {threshold} RETURN n SKIP {skip} LIMIT {limit}"
5457            );
5458
5459            let full_plan = compile(&full_q);
5460            let unbounded = super::execute_unbounded(&v, &full_plan, &Params(&params));
5461            let unbounded = match unbounded {
5462                Ok(rs) => rs,
5463                Err(_) => return Ok(()),
5464            };
5465            let total = unbounded.len();
5466            let full_rows = rows_of(&unbounded);
5467
5468            // Snapshot counter before executing the fused-shape query.
5469            let fires_before =
5470                super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
5471            let bounded = run(&v, &bounded_q, &params).expect("fused bounded must not error");
5472            let fires_after =
5473                super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
5474
5475            // The planner must have emitted ScanLabel→Filter{Cmp} for this shape;
5476            // assert the fused arm actually executed (counter advanced).
5477            // Guard: with 0 nodes the label symbol is never interned, so pull_rows
5478            // exits before the fused detection — nothing to assert in that case.
5479            // Guard: op_idx==0 (Eq) is folded to IndexScan by the WHERE equality
5480            // fold pass — the fused ScanLabel+Filter shape is not produced, so the
5481            // FUSED counter does not advance; correctness is still verified below.
5482            if n_nodes > 0 && op_idx != 0 {
5483                prop_assert!(
5484                    fires_after > fires_before,
5485                    "fused arm did NOT fire for op={} threshold={} n_nodes={}: \
5486                     counter before={} after={}",
5487                    op_str,
5488                    threshold,
5489                    n_nodes,
5490                    fires_before,
5491                    fires_after
5492                );
5493            }
5494
5495            let s = (skip as usize).min(total);
5496            let e = (skip as usize + limit as usize).min(total);
5497            prop_assert_eq!(
5498                rows_of(&bounded),
5499                full_rows[s..e].to_vec(),
5500                "fused path: op={} threshold={} n_nodes={} SKIP {} LIMIT {}: \
5501                 bounded != unbounded[{}..{}]",
5502                op_str, threshold, n_nodes, skip, limit, s, e
5503            );
5504
5505            // ── Boundary: compound AND — misses fused detection → generic path ─
5506            // `AND n.v >= -999` is always true for our Int/Float range 0..7,
5507            // so the result set is identical — only the executor path differs.
5508            let compound_q = format!(
5509                "MATCH (n:N) WHERE n.v {} {} AND n.v >= -999 RETURN n SKIP {} LIMIT {}",
5510                op_str, threshold, skip, limit
5511            );
5512            let fires_before_compound =
5513                super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
5514            let compound_bounded =
5515                run(&v, &compound_q, &params).expect("compound-AND bounded must not error");
5516            let fires_after_compound =
5517                super::FUSED_SCAN_FIRES.load(std::sync::atomic::Ordering::Relaxed);
5518
5519            // Compound AND must NOT activate the fused arm.
5520            prop_assert_eq!(
5521                fires_after_compound,
5522                fires_before_compound,
5523                "fused arm fired for compound-AND shape (should use generic path): \
5524                 op={} threshold={} n_nodes={}",
5525                op_str,
5526                threshold,
5527                n_nodes
5528            );
5529            prop_assert_eq!(
5530                rows_of(&compound_bounded),
5531                full_rows[s..e].to_vec(),
5532                "compound-AND fallback: op={} threshold={} n_nodes={} SKIP {} LIMIT {}: \
5533                 result differs from fused",
5534                op_str, threshold, n_nodes, skip, limit
5535            );
5536        }
5537    }
5538
5539    // ──────────────────────────────────────────────────────────────────────────
5540    // C1 / C2: dense hop-1 with downstream filter
5541    //
5542    // The actual failing shape: 1 source → N leaves (N > cap), and a downstream
5543    // Filter that passes only a small fraction.  The per-stage approach (Round 1)
5544    // errors because hop-1 Expand runs to the full cap before the Filter sees
5545    // anything.  The pull-based approach cascades the bound: once `limit` rows
5546    // have passed the Filter, all upstream loops stop.
5547    // ──────────────────────────────────────────────────────────────────────────
5548
5549    #[test]
5550    fn dense_hop1_with_filter_survives_pull() {
5551        const LEAVES: usize = 120; // > CAP so staged always errors
5552        const CAP: usize = 100;
5553
5554        let mut fx = Fx::new();
5555        let src = fx.add("Src", "src", vec![]);
5556        for idx in 0..LEAVES {
5557            let leaf = fx.add("Leaf", &format!("l{idx}"), vec![("v", i(idx as i64))]);
5558            fx.edge("T", src, leaf, vec![]);
5559        }
5560        let v = fx.view();
5561        let params = BTreeMap::new();
5562
5563        // Staged (unbounded) path: expand produces 120 rows > cap=100 → error.
5564        let staged_err = super::with_max_intermediate_rows(CAP, || {
5565            super::execute_unbounded(
5566                &v,
5567                &compile("MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v >= 110 RETURN l, l.v"),
5568                &Params(&params),
5569            )
5570        });
5571        assert!(
5572            staged_err.is_err(),
5573            "staged path must error on 120 leaves with cap={CAP}"
5574        );
5575        assert!(
5576            staged_err
5577                .unwrap_err()
5578                .contains("intermediate result exceeds"),
5579            "wrong error message"
5580        );
5581
5582        // Pull-based (LIMIT 5): never materialises more than 5 rows → survives.
5583        // WHERE l.v >= 110 means only leaves 110..119 pass (10 survivors), so
5584        // 5 results are found well before all 120 leaves are expanded.
5585        let ok = super::with_max_intermediate_rows(CAP, || {
5586            run(
5587                &v,
5588                "MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v >= 110 RETURN l, l.v LIMIT 5",
5589                &params,
5590            )
5591        });
5592        let rs = ok.expect("pull-based must survive despite dense hop-1 exceeding cap");
5593        assert_eq!(rs.len(), 5, "LIMIT 5 must return exactly 5 rows");
5594
5595        // Verify all returned rows have l.v >= 110 (correct filter application).
5596        let vs: Vec<i64> = (0..rs.len())
5597            .filter_map(|i| match rs.get(i, "l.v") {
5598                Some(Value::Int(n)) => Some(*n),
5599                _ => None,
5600            })
5601            .collect();
5602        assert_eq!(vs.len(), 5, "all projected rows must have v");
5603        for v_val in &vs {
5604            assert!(*v_val >= 110, "filter must hold: v={v_val} is not >= 110");
5605        }
5606
5607        // Early-termination proof: use a filter that passes early leaves (v < 10)
5608        // so pull stops after visiting just 5 leaves, while staged visits all 120.
5609        // Ratio: 120 / 5 = 24× — well above the 10× threshold.
5610        let (pull_result, pull_produced) = super::with_expand_counter(|| {
5611            super::with_max_intermediate_rows(1_000_000, || {
5612                run(
5613                    &v,
5614                    "MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v < 10 RETURN l LIMIT 5",
5615                    &params,
5616                )
5617            })
5618        });
5619        pull_result.expect("pull must succeed without cap");
5620        // Pull visits leaves 0..4 (all pass v < 10, LIMIT 5 satisfied immediately).
5621        assert!(
5622            pull_produced <= 5,
5623            "pull expand count {pull_produced} should be ≤ 5 (stops after 5 passing leaves)"
5624        );
5625
5626        let (staged_result, staged_produced) = super::with_expand_counter(|| {
5627            super::with_max_intermediate_rows(1_000_000, || {
5628                super::execute_unbounded(
5629                    &v,
5630                    &compile("MATCH (s:Src)-[:T]->(l:Leaf) WHERE l.v < 10 RETURN l"),
5631                    &Params(&params),
5632                )
5633            })
5634        });
5635        staged_result.expect("staged must succeed with 1M cap");
5636        assert_eq!(
5637            staged_produced, LEAVES,
5638            "staged must expand all {LEAVES} leaves"
5639        );
5640
5641        assert!(
5642            staged_produced >= pull_produced * 10,
5643            "staged ({staged_produced}) must be ≥ 10× pull ({pull_produced})"
5644        );
5645    }
5646
5647    // ──────────────────────────────────────────────────────────────────────────
5648    // Harness-shape two-hop dense test
5649    //
5650    // Replicates the exact failure shape from the public benchmark table at
5651    // 1/100 scale (70 Talent + 20 Company with 3 industry categories).
5652    // IA edges are added directly (bypassing the rule engine) to reproduce the
5653    // dense edge structure that `Predicate::FieldEqual { field: "industry" }`
5654    // generates at full scale.
5655    //
5656    // Query: MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company)
5657    //               <-[:INDUSTRY_ALIGNMENT]-(t2:Talent)
5658    //        RETURN t, c, t2 LIMIT 10
5659    //
5660    // Before (staged with cap=100): hop-1 expands 70×~7=~466 rows > 100 → error
5661    // After  (pull-based):          finds 10 results, stops, returns correctly
5662    // ──────────────────────────────────────────────────────────────────────────
5663
5664    #[test]
5665    fn harness_shape_two_hop_dense_survives_pull() {
5666        const N_TALENT: usize = 70;
5667        const N_COMPANY: usize = 20;
5668        const N_INDUSTRY: usize = 3;
5669        const CAP: usize = 100;
5670        const LIMIT: usize = 10;
5671
5672        let mut fx = Fx::new();
5673
5674        // Build Talent nodes with industry tag.
5675        let mut talent_ids: Vec<u32> = Vec::new();
5676        let mut talent_industry: Vec<usize> = Vec::new();
5677        for i in 0..N_TALENT {
5678            let ind = i % N_INDUSTRY;
5679            let id = fx.add(
5680                "Talent",
5681                &format!("t{i}"),
5682                vec![("industry", s(&ind.to_string()))],
5683            );
5684            talent_ids.push(id);
5685            talent_industry.push(ind);
5686        }
5687
5688        // Build Company nodes with industry tag.
5689        let mut company_ids: Vec<u32> = Vec::new();
5690        let mut company_industry: Vec<usize> = Vec::new();
5691        for i in 0..N_COMPANY {
5692            let ind = i % N_INDUSTRY;
5693            let id = fx.add(
5694                "Company",
5695                &format!("c{i}"),
5696                vec![("industry", s(&ind.to_string()))],
5697            );
5698            company_ids.push(id);
5699            company_industry.push(ind);
5700        }
5701
5702        // INDUSTRY_ALIGNMENT: Talent → Company when same industry.
5703        for (ti, &tid) in talent_ids.iter().enumerate() {
5704            for (ci, &cid) in company_ids.iter().enumerate() {
5705                if talent_industry[ti] == company_industry[ci] {
5706                    fx.edge("INDUSTRY_ALIGNMENT", tid, cid, vec![]);
5707                }
5708            }
5709        }
5710
5711        let v = fx.view();
5712        let params = BTreeMap::new();
5713        let query = format!(
5714            "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company)\
5715             <-[:INDUSTRY_ALIGNMENT]-(t2:Talent) RETURN t, c, t2 LIMIT {LIMIT}"
5716        );
5717
5718        // Staged (unbounded) path: hop-1 expands 70×~7=~466 rows > cap=100 → error.
5719        const UNBOUNDED_Q: &str = "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company)\
5720             <-[:INDUSTRY_ALIGNMENT]-(t2:Talent) RETURN t, c, t2";
5721        let staged_err = super::with_max_intermediate_rows(CAP, || {
5722            super::execute_unbounded(&v, &compile(UNBOUNDED_Q), &Params(&params))
5723        });
5724        assert!(
5725            staged_err.is_err(),
5726            "staged must error with cap={CAP} on harness-shape graph"
5727        );
5728        assert!(
5729            staged_err
5730                .unwrap_err()
5731                .contains("intermediate result exceeds"),
5732            "wrong error"
5733        );
5734
5735        // Pull-based (LIMIT 10): cascades bound through both hops → completes.
5736        let ok = super::with_max_intermediate_rows(CAP, || run(&v, &query, &params));
5737        let rs = ok.expect("pull-based must complete on harness-shape with LIMIT 10");
5738        assert_eq!(rs.len(), LIMIT, "must return exactly {LIMIT} rows");
5739
5740        // Each result row (t, c, t2) must have matching industry across all 3 variables.
5741        // t and c share an IA edge (same industry); c and t2 share an IA edge too.
5742        // Since we can't directly query industry from the ResultSet without projecting it,
5743        // just verify the result is semantically plausible: 3 non-None columns per row.
5744        for i in 0..rs.len() {
5745            let row = rs.row(i);
5746            assert_eq!(row.len(), 3, "each row must have 3 columns (t, c, t2)");
5747            assert!(row.iter().all(|c| c.is_some()), "all cells must be Some");
5748        }
5749    }
5750
5751    #[test]
5752    fn intermediate_row_cap_errors_on_scan_and_expand() {
5753        let cap_msg = |n: usize| {
5754            format!(
5755                "intermediate result exceeds {n} rows; add a LIMIT or constrain patterns with shared variables"
5756            )
5757        };
5758
5759        let mut scan_fx = Fx::new();
5760        scan_fx.add("N", "a", vec![]);
5761        scan_fx.add("N", "b", vec![]);
5762        scan_fx.add("N", "c", vec![]);
5763        let sv = scan_fx.view();
5764        let scan_err = super::with_max_intermediate_rows(2, || {
5765            run(&sv, "MATCH (n:N) RETURN n", &BTreeMap::new())
5766        })
5767        .expect_err("3-row scan must exceed cap 2");
5768        assert_eq!(scan_err, cap_msg(2));
5769
5770        let mut exp_fx = Fx::new();
5771        let src = exp_fx.add("Src", "s", vec![]);
5772        let d1 = exp_fx.add("Dst", "d1", vec![]);
5773        let d2 = exp_fx.add("Dst", "d2", vec![]);
5774        exp_fx.edge("T", src, d1, vec![]);
5775        exp_fx.edge("T", src, d2, vec![]);
5776        let ev = exp_fx.view();
5777        // Scan of :Src is 1 row (under cap); expand to two dests would be 2.
5778        let exp_err = super::with_max_intermediate_rows(1, || {
5779            run(&ev, "MATCH (x:Src)-[:T]->(y) RETURN x, y", &BTreeMap::new())
5780        })
5781        .expect_err("2-row expand must exceed cap 1");
5782        assert_eq!(exp_err, cap_msg(1));
5783    }
5784
5785    // ──────────────────────────────────────────────────────────────────────────
5786    // LIMIT push-down: semantics, early-termination proof, and budget survival
5787    // ──────────────────────────────────────────────────────────────────────────
5788
5789    /// Property test: bounded execution (execute with push-down) produces the
5790    /// same rows as the reference unbounded path (execute_unbounded) sliced to
5791    /// the first LIMIT rows.  Exercises several LIMIT and SKIP+LIMIT values,
5792    /// including queries that have a Filter so the bound falls on Filter output,
5793    /// and queries with relationship-uniqueness rejections.
5794    #[test]
5795    fn bounded_matches_unbounded_slice_various_limits() {
5796        // ── single-hop, no Filter ──────────────────────────────────────────────
5797        let fx = hop_graph();
5798        let v = fx.view();
5799        let params = BTreeMap::new();
5800
5801        // Full 3-row reference (ada→bob, ada→cam, bob→cam).
5802        let full_plan = compile("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b");
5803        let full_rs = super::execute_unbounded(&v, &full_plan, &Params(&params)).unwrap();
5804        let full_rows = rows_of(&full_rs);
5805        assert_eq!(full_rows.len(), 3, "hop_graph has exactly 3 KNOWS paths");
5806
5807        for limit in [1u64, 2, 3, 10] {
5808            let q = format!("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b LIMIT {limit}");
5809            let rs = run(&v, &q, &params).unwrap();
5810            let expected_len = (limit as usize).min(full_rows.len());
5811            assert_eq!(
5812                rs.len(),
5813                expected_len,
5814                "LIMIT {limit}: expected {expected_len} rows, got {}",
5815                rs.len()
5816            );
5817            assert_eq!(
5818                rows_of(&rs),
5819                full_rows[..expected_len],
5820                "LIMIT {limit}: rows differ from reference slice"
5821            );
5822        }
5823
5824        // SKIP + LIMIT: bound = SKIP + LIMIT so slicing is correct.
5825        let skip_rs = run(
5826            &v,
5827            "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b SKIP 1 LIMIT 2",
5828            &params,
5829        )
5830        .unwrap();
5831        assert_eq!(rows_of(&skip_rs), full_rows[1..3]);
5832
5833        // ── with WHERE (bound falls on Filter, not Expand) ────────────────────
5834        let mut fx2 = Fx::new();
5835        fx2.add("N", "a", vec![("v", i(1))]);
5836        fx2.add("N", "b", vec![("v", i(2))]);
5837        fx2.add("N", "c", vec![("v", i(3))]);
5838        let v2 = fx2.view();
5839        // Full result in order: a(1), b(2), c(3).  After WHERE v > 1: b, c.
5840        let filter_full = super::execute_unbounded(
5841            &v2,
5842            &compile("MATCH (n:N) WHERE n.v > 1 RETURN n"),
5843            &Params(&params),
5844        )
5845        .unwrap();
5846        assert_eq!(filter_full.len(), 2);
5847
5848        let filter_lim = run(&v2, "MATCH (n:N) WHERE n.v > 1 RETURN n LIMIT 1", &params).unwrap();
5849        assert_eq!(filter_lim.len(), 1, "LIMIT 1 on filter query");
5850        assert_eq!(rows_of(&filter_lim), rows_of(&filter_full)[..1]);
5851
5852        // ── relationship-uniqueness rejections do not count toward the bound ──
5853        let tri = triangle();
5854        let tv = tri.view();
5855        // Triangle has 3 two-hop paths; uniqueness rejects 6 (reversed pairs).
5856        let tri_full = super::execute_unbounded(
5857            &tv,
5858            &compile("MATCH (x)-[r1:T]->(y)-[r2:T]->(z) RETURN x, y, z"),
5859            &Params(&params),
5860        )
5861        .unwrap();
5862        assert_eq!(tri_full.len(), 3, "triangle has 3 unique two-hop paths");
5863        for limit in [1u64, 2, 3, 5] {
5864            let q = format!("MATCH (x)-[r1:T]->(y)-[r2:T]->(z) RETURN x, y, z LIMIT {limit}");
5865            let rs = run(&tv, &q, &params).unwrap();
5866            let expected_len = (limit as usize).min(3);
5867            assert_eq!(
5868                rs.len(),
5869                expected_len,
5870                "triangle LIMIT {limit}: got {} rows",
5871                rs.len()
5872            );
5873            assert_eq!(
5874                rows_of(&rs),
5875                rows_of(&tri_full)[..expected_len],
5876                "triangle LIMIT {limit}: rows differ"
5877            );
5878        }
5879    }
5880
5881    /// Early-termination proof: on a star graph (1 hub → 500 leaves), a bounded
5882    /// query (LIMIT 5) must cause `exec_expand` to emit ≤ 5 rows, while an
5883    /// unbounded run emits all 500 (≥ 100× the bounded count).
5884    #[test]
5885    fn expand_terminates_early_with_row_bound() {
5886        const LEAVES: usize = 500;
5887        let mut fx = Fx::new();
5888        let hub = fx.add("Hub", "hub", vec![]);
5889        for i in 0..LEAVES {
5890            let leaf = fx.add("Leaf", &format!("leaf-{i}"), vec![]);
5891            fx.edge("T", hub, leaf, vec![]);
5892        }
5893        let v = fx.view();
5894        let params = BTreeMap::new();
5895        let full_plan = compile("MATCH (h:Hub)-[:T]->(x:Leaf) RETURN x");
5896
5897        // Bounded path via the regular execute() entry point (LIMIT 5 → bound 5).
5898        let (bounded_result, bounded_produced) = super::with_expand_counter(|| {
5899            run(&v, "MATCH (h:Hub)-[:T]->(x:Leaf) RETURN x LIMIT 5", &params)
5900        });
5901        let bounded_rs = bounded_result.unwrap();
5902        assert_eq!(bounded_rs.len(), 5, "LIMIT 5 must return exactly 5 rows");
5903        assert!(
5904            bounded_produced <= 5,
5905            "bounded: exec_expand emitted {bounded_produced} rows, expected ≤ 5"
5906        );
5907
5908        // Unbounded reference path via execute_unbounded (no push-down).
5909        let (unbounded_result, unbounded_produced) = super::with_expand_counter(|| {
5910            super::execute_unbounded(&v, &full_plan, &Params(&params))
5911        });
5912        let unbounded_rs = unbounded_result.unwrap();
5913        assert_eq!(
5914            unbounded_rs.len(),
5915            LEAVES,
5916            "unbounded must return all {LEAVES} rows"
5917        );
5918        assert_eq!(
5919            unbounded_produced, LEAVES,
5920            "unbounded: exec_expand must emit all {LEAVES} rows"
5921        );
5922
5923        // Early-termination ratio ≥ 100×.
5924        assert!(
5925            unbounded_produced >= bounded_produced * 100,
5926            "unbounded ({unbounded_produced}) must be ≥ 100× bounded ({bounded_produced})"
5927        );
5928    }
5929
5930    /// Budget-survival: a bounded query (LIMIT pushdown active) must complete
5931    /// without triggering the intermediate-row cap, even when the same query
5932    /// without pushdown (execute_unbounded) would exceed a reduced cap.
5933    ///
5934    /// This also verifies that the 1 M cap is still live for unbounded queries.
5935    #[test]
5936    fn bounded_query_survives_low_intermediate_row_cap() {
5937        // 20 leaf nodes: unbounded would emit 20 rows, exceeding a cap of 10.
5938        let mut fx = Fx::new();
5939        let src = fx.add("Src", "s", vec![]);
5940        for i in 0..20usize {
5941            let dst = fx.add("Dst", &format!("d{i}"), vec![]);
5942            fx.edge("T", src, dst, vec![]);
5943        }
5944        let v = fx.view();
5945        let params = BTreeMap::new();
5946        let full_plan = compile("MATCH (s:Src)-[:T]->(d:Dst) RETURN d");
5947
5948        // Unbounded hits the cap — 1 M budget check must remain live.
5949        let cap_err = super::with_max_intermediate_rows(10, || {
5950            super::execute_unbounded(&v, &full_plan, &Params(&params))
5951        });
5952        assert!(
5953            cap_err.is_err(),
5954            "unbounded must hit the intermediate-row cap"
5955        );
5956        assert!(
5957            cap_err.unwrap_err().contains("intermediate result exceeds"),
5958            "cap error message must be the budget message"
5959        );
5960
5961        // Bounded (LIMIT 5) stops before the cap — must complete successfully.
5962        let ok = super::with_max_intermediate_rows(10, || {
5963            run(&v, "MATCH (s:Src)-[:T]->(d:Dst) RETURN d LIMIT 5", &params)
5964        });
5965        assert_eq!(
5966            ok.unwrap().len(),
5967            5,
5968            "bounded (LIMIT 5) must complete with 5 rows, not a cap error"
5969        );
5970
5971        // Bounded with LIMIT exactly at cap also survives.
5972        let at_cap = super::with_max_intermediate_rows(10, || {
5973            run(&v, "MATCH (s:Src)-[:T]->(d:Dst) RETURN d LIMIT 10", &params)
5974        });
5975        assert_eq!(
5976            at_cap.unwrap().len(),
5977            10,
5978            "bounded at LIMIT==cap must complete with 10 rows"
5979        );
5980    }
5981
5982    // ──────────────────────────────────────────────────────────────────────────
5983    // Aggregate execution tests
5984    // ──────────────────────────────────────────────────────────────────────────
5985
5986    #[test]
5987    fn count_star_returns_total_node_count() {
5988        let mut fx = Fx::new();
5989        fx.add("Person", "ada", vec![]);
5990        fx.add("Person", "bob", vec![]);
5991        fx.add("Person", "cam", vec![]);
5992        let v = fx.view();
5993        let params = BTreeMap::new();
5994
5995        let rs = run(&v, "MATCH (n:Person) RETURN COUNT(*)", &params).expect("COUNT(*)");
5996        assert_eq!(rs.columns(), &["COUNT(*)".to_string()]);
5997        assert_eq!(rs.len(), 1);
5998        assert_eq!(rs.row(0), &[Some(i(3))]);
5999
6000        // Empty graph: COUNT(*) should return 0.
6001        let rs_empty = run(&v, "MATCH (n:Ghost) RETURN COUNT(*)", &params).expect("COUNT(*) empty");
6002        assert_eq!(rs_empty.row(0), &[Some(i(0))]);
6003    }
6004
6005    #[test]
6006    fn count_star_alias_sets_column_name() {
6007        let mut fx = Fx::new();
6008        fx.add("N", "a", vec![]);
6009        let v = fx.view();
6010        let params = BTreeMap::new();
6011        let rs = run(&v, "MATCH (n:N) RETURN COUNT(*) AS total", &params).expect("COUNT AS");
6012        assert_eq!(rs.columns(), &["total".to_string()]);
6013        assert_eq!(rs.row(0), &[Some(i(1))]);
6014    }
6015
6016    #[test]
6017    fn count_var_skips_null_node_bindings() {
6018        // COUNT(n) counts rows where n is bound. Since ScanLabel always binds n,
6019        // this matches COUNT(*) for nodes. Primarily documents the semantics.
6020        let mut fx = Fx::new();
6021        fx.add("N", "a", vec![]);
6022        fx.add("N", "b", vec![]);
6023        let v = fx.view();
6024        let params = BTreeMap::new();
6025        let rs = run(&v, "MATCH (n:N) RETURN COUNT(n)", &params).expect("COUNT(n)");
6026        assert_eq!(rs.columns(), &["COUNT(n)".to_string()]);
6027        assert_eq!(rs.row(0), &[Some(i(2))]);
6028    }
6029
6030    #[test]
6031    fn sum_numeric_prop_ignores_null_and_non_numeric() {
6032        let mut fx = Fx::new();
6033        fx.add("N", "a", vec![("v", i(10))]);
6034        fx.add("N", "b", vec![("v", i(20))]);
6035        fx.add("N", "c", vec![]); // missing prop — skipped
6036        let v = fx.view();
6037        let params = BTreeMap::new();
6038        let rs = run(&v, "MATCH (n:N) RETURN SUM(n.v)", &params).expect("SUM");
6039        assert_eq!(rs.columns(), &["SUM(n.v)".to_string()]);
6040        // 10 + 20 = 30 (null skipped); integers sum to an integer (row 158)
6041        assert_eq!(rs.row(0), &[Some(i(30))]);
6042
6043        // All props null → result is null.
6044        let rs_null = run(&v, "MATCH (n:N) RETURN SUM(n.missing)", &params).expect("SUM null");
6045        assert_eq!(rs_null.row(0), &[None]);
6046    }
6047
6048    #[test]
6049    fn avg_numeric_prop() {
6050        let mut fx = Fx::new();
6051        fx.add("N", "a", vec![("v", i(10))]);
6052        fx.add("N", "b", vec![("v", i(30))]);
6053        let v = fx.view();
6054        let params = BTreeMap::new();
6055        let rs = run(&v, "MATCH (n:N) RETURN AVG(n.v) AS avg_v", &params).expect("AVG");
6056        assert_eq!(rs.columns(), &["avg_v".to_string()]);
6057        // (10 + 30) / 2 = 20.0
6058        assert_eq!(rs.row(0), &[Some(f(20.0))]);
6059
6060        // Empty graph: AVG returns null.
6061        let rs_empty = run(&v, "MATCH (n:Ghost) RETURN AVG(n.v)", &params).expect("AVG empty");
6062        assert_eq!(rs_empty.row(0), &[None]);
6063    }
6064
6065    #[test]
6066    fn min_max_numeric_prop() {
6067        let mut fx = Fx::new();
6068        fx.add("N", "a", vec![("v", i(5))]);
6069        fx.add("N", "b", vec![("v", i(1))]);
6070        fx.add("N", "c", vec![("v", i(9))]);
6071        fx.add("N", "d", vec![]); // null skipped
6072        let v = fx.view();
6073        let params = BTreeMap::new();
6074
6075        let min_rs = run(&v, "MATCH (n:N) RETURN MIN(n.v)", &params).expect("MIN");
6076        assert_eq!(min_rs.row(0), &[Some(i(1))]);
6077
6078        let max_rs = run(&v, "MATCH (n:N) RETURN MAX(n.v)", &params).expect("MAX");
6079        assert_eq!(max_rs.row(0), &[Some(i(9))]);
6080    }
6081
6082    /// M-2: MIN/MAX with mixed Int and Float props.  The cmp_optional ordering
6083    /// places Int and Float by numeric value (cross-variant numeric comparison).
6084    #[test]
6085    fn min_max_mixed_int_float_props() {
6086        let mut fx = Fx::new();
6087        // Int 3, Float 1.5, Int 7, Float 2.0 — min=1.5 (Float), max=7 (Int).
6088        fx.add("N", "a", vec![("v", i(3))]);
6089        fx.add("N", "b", vec![("v", f(1.5))]);
6090        fx.add("N", "c", vec![("v", i(7))]);
6091        fx.add("N", "d", vec![("v", f(2.0))]);
6092        let v = fx.view();
6093        let params = BTreeMap::new();
6094
6095        let min_rs = run(&v, "MATCH (n:N) RETURN MIN(n.v)", &params).expect("MIN mixed");
6096        // 1.5 < 2.0 < 3 < 7 — minimum is Float(1.5).
6097        assert_eq!(min_rs.row(0), &[Some(f(1.5))]);
6098
6099        let max_rs = run(&v, "MATCH (n:N) RETURN MAX(n.v)", &params).expect("MAX mixed");
6100        // Maximum is Int(7).
6101        assert_eq!(max_rs.row(0), &[Some(i(7))]);
6102    }
6103
6104    /// I-1: LIMIT, SKIP, and ORDER BY are silently dropped for aggregate
6105    /// queries (always one result row).  Pin both boundary values.
6106    #[test]
6107    fn aggregate_limit_skip_order_by_are_no_ops() {
6108        let mut fx = Fx::new();
6109        fx.add("N", "a", vec![]);
6110        fx.add("N", "b", vec![]);
6111        fx.add("N", "c", vec![]);
6112        let v = fx.view();
6113        let params = BTreeMap::new();
6114
6115        // LIMIT 5 — aggregate always returns exactly 1 row regardless.
6116        let rs_lim5 =
6117            run(&v, "MATCH (n:N) RETURN COUNT(*) LIMIT 5", &params).expect("COUNT(*) LIMIT 5");
6118        assert_eq!(
6119            rs_lim5.len(),
6120            1,
6121            "aggregate with LIMIT 5 must still return 1 row"
6122        );
6123        assert_eq!(rs_lim5.row(0), &[Some(i(3))]);
6124
6125        // LIMIT 0 — even LIMIT 0 does not suppress the aggregate row.
6126        let rs_lim0 =
6127            run(&v, "MATCH (n:N) RETURN COUNT(*) LIMIT 0", &params).expect("COUNT(*) LIMIT 0");
6128        assert_eq!(
6129            rs_lim0.len(),
6130            1,
6131            "aggregate with LIMIT 0 must still return 1 row"
6132        );
6133        assert_eq!(rs_lim0.row(0), &[Some(i(3))]);
6134
6135        // SKIP 100 — does not discard the single result row.
6136        let rs_skip =
6137            run(&v, "MATCH (n:N) RETURN COUNT(*) SKIP 100", &params).expect("COUNT(*) SKIP 100");
6138        assert_eq!(
6139            rs_skip.len(),
6140            1,
6141            "aggregate with large SKIP must still return 1 row"
6142        );
6143
6144        // ORDER BY is a no-op on a single-row result (but must not panic).
6145        // Note: the planner drops ORDER BY for aggregates; verify that the plan
6146        // compiles without error and returns the correct count.
6147        let rs_ord = plan_src("MATCH (n:N) RETURN COUNT(*) ORDER BY n");
6148        // ORDER BY on aggregate: planner drops ORDER BY, so this should plan OK.
6149        // (The planner exits early after emitting Aggregate, so ORDER BY is ignored.)
6150        assert!(
6151            rs_ord.is_ok(),
6152            "COUNT(*) ORDER BY should plan without error (ORDER BY dropped)"
6153        );
6154        let plan_ops = rs_ord.unwrap();
6155        // Must not contain an OrderBy op — it was dropped.
6156        assert!(
6157            !plan_ops
6158                .iter()
6159                .any(|op| matches!(op, crate::cypher::plan::PlanOp::OrderBy { .. })),
6160            "aggregate plan must not contain OrderBy"
6161        );
6162    }
6163
6164    #[test]
6165    fn count_star_no_budget_cap_applies() {
6166        // COUNT(*) with a dense graph that would error the staged path.
6167        // The aggregate path must complete without hitting the cap.
6168        let mut fx = Fx::new();
6169        let src = fx.add("Src", "s", vec![]);
6170        for i in 0..30usize {
6171            let dst = fx.add("Dst", &format!("d{i}"), vec![]);
6172            fx.edge("T", src, dst, vec![]);
6173        }
6174        let v = fx.view();
6175        let params = BTreeMap::new();
6176
6177        // Staged path errors on 30 nodes > cap 10.
6178        let cap_err =
6179            super::with_max_intermediate_rows(10, || run(&v, "MATCH (n:Dst) RETURN n", &params));
6180        assert!(
6181            cap_err.is_err(),
6182            "staged path must error on 30 nodes with cap=10"
6183        );
6184
6185        // COUNT(*) does not apply the cap — must complete and return 30.
6186        let agg_ok = super::with_max_intermediate_rows(10, || {
6187            run(&v, "MATCH (n:Dst) RETURN COUNT(*)", &params)
6188        })
6189        .expect("aggregate must not hit the intermediate-row cap");
6190        assert_eq!(
6191            agg_ok.row(0),
6192            &[Some(i(30))],
6193            "COUNT(*) must count all 30 nodes regardless of cap"
6194        );
6195    }
6196
6197    fn plan_src(src: &str) -> Result<Vec<crate::cypher::plan::PlanOp>, String> {
6198        use crate::cypher::{lex, parse, plan};
6199        let toks = lex(src).map_err(|e| format!("lex: {e}"))?;
6200        let ast = parse(&toks).map_err(|e| format!("parse: {e}"))?;
6201        plan(&ast).map_err(|e| format!("plan: {e}"))
6202    }
6203
6204    /// Updated from the v1 pin: grouped aggregation is now supported.
6205    /// Verifies plan routing and that the only remaining plan-error is SUM(*).
6206    #[test]
6207    fn grouped_aggregation_plan_routing() {
6208        use crate::cypher::plan::PlanOp;
6209
6210        // RETURN a, COUNT(*) — grouped aggregation now routes to GroupAggregate.
6211        let ops = plan_src("MATCH (a:N) RETURN a, COUNT(*)")
6212            .expect("grouped aggregation must now succeed");
6213        assert!(
6214            ops.iter()
6215                .any(|op| matches!(op, PlanOp::GroupAggregate { .. })),
6216            "grouped aggregation plan must contain GroupAggregate op, got: {ops:?}"
6217        );
6218
6219        // Multiple aggregates without group keys also routes to GroupAggregate.
6220        let ops2 = plan_src("MATCH (a:N) RETURN COUNT(*), COUNT(a)")
6221            .expect("multi-aggregate must now succeed");
6222        assert!(
6223            ops2.iter()
6224                .any(|op| matches!(op, PlanOp::GroupAggregate { .. })),
6225            "multi-aggregate plan must contain GroupAggregate op, got: {ops2:?}"
6226        );
6227
6228        // SUM(*) is still a plan error: Star is invalid for SUM.
6229        let err3 = plan_src("MATCH (a:N) RETURN SUM(*)").expect_err("SUM(*) must be plan error");
6230        assert!(
6231            err3.to_ascii_lowercase().contains("sum") || err3.to_ascii_lowercase().contains("*"),
6232            "error must mention SUM or *, got: {err3}"
6233        );
6234    }
6235
6236    // ─── Grouped aggregation execution tests ─────────────────────────────────
6237
6238    #[test]
6239    fn grouped_single_key_count() {
6240        // Graph: 3 nodes with "t" prop — two "X", one "Y".
6241        let mut fx = Fx::new();
6242        fx.add("N", "a", vec![("t", s("X"))]);
6243        fx.add("N", "b", vec![("t", s("X"))]);
6244        fx.add("N", "c", vec![("t", s("Y"))]);
6245        let v = fx.view();
6246        let params = BTreeMap::new();
6247
6248        let rs = run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", &params)
6249            .expect("single-key grouped COUNT must succeed");
6250        assert_eq!(
6251            rs.columns(),
6252            &["n.t".to_string(), "cnt".to_string()],
6253            "columns must match RETURN clause"
6254        );
6255        assert_eq!(rs.len(), 2, "must produce exactly 2 groups (X and Y)");
6256
6257        // Find each group regardless of row order.
6258        let find = |label: &Value| (0..rs.len()).find(|&i| rs.row(i)[0].as_ref() == Some(label));
6259        let xi = find(&s("X")).expect("group X must exist");
6260        let yi = find(&s("Y")).expect("group Y must exist");
6261        assert_eq!(rs.row(xi)[1], Some(i(2)), "X group count must be 2");
6262        assert_eq!(rs.row(yi)[1], Some(i(1)), "Y group count must be 1");
6263    }
6264
6265    #[test]
6266    fn grouped_two_keys_sum_avg() {
6267        // Four nodes with two categorical props and a numeric value.
6268        let mut fx = Fx::new();
6269        fx.add(
6270            "N",
6271            "a",
6272            vec![("cat", s("A")), ("sub", s("1")), ("v", i(10))],
6273        );
6274        fx.add(
6275            "N",
6276            "b",
6277            vec![("cat", s("A")), ("sub", s("1")), ("v", i(20))],
6278        );
6279        fx.add(
6280            "N",
6281            "c",
6282            vec![("cat", s("A")), ("sub", s("2")), ("v", i(5))],
6283        );
6284        fx.add(
6285            "N",
6286            "d",
6287            vec![("cat", s("B")), ("sub", s("1")), ("v", i(100))],
6288        );
6289        let v = fx.view();
6290        let params = BTreeMap::new();
6291
6292        let rs = run(
6293            &v,
6294            "MATCH (n:N) RETURN n.cat, n.sub, SUM(n.v) AS total, AVG(n.v) AS avg_v",
6295            &params,
6296        )
6297        .expect("two-key SUM + AVG must succeed");
6298        assert_eq!(
6299            rs.columns(),
6300            &[
6301                "n.cat".to_string(),
6302                "n.sub".to_string(),
6303                "total".to_string(),
6304                "avg_v".to_string()
6305            ]
6306        );
6307        assert_eq!(rs.len(), 3, "must produce 3 groups: (A,1), (A,2), (B,1)");
6308
6309        let find = |cat: &Value, sub: &Value| {
6310            (0..rs.len())
6311                .find(|&i| rs.row(i)[0].as_ref() == Some(cat) && rs.row(i)[1].as_ref() == Some(sub))
6312        };
6313        let a1 = find(&s("A"), &s("1")).expect("group (A,1) must exist");
6314        assert_eq!(rs.row(a1)[2], Some(i(30)), "(A,1) SUM must be 30");
6315        assert_eq!(rs.row(a1)[3], Some(f(15.0)), "(A,1) AVG must be 15.0");
6316
6317        let a2 = find(&s("A"), &s("2")).expect("group (A,2) must exist");
6318        assert_eq!(rs.row(a2)[2], Some(i(5)), "(A,2) SUM must be 5");
6319
6320        let b1 = find(&s("B"), &s("1")).expect("group (B,1) must exist");
6321        assert_eq!(rs.row(b1)[2], Some(i(100)), "(B,1) SUM must be 100");
6322        assert_eq!(rs.row(b1)[3], Some(f(100.0)), "(B,1) AVG must be 100.0");
6323    }
6324
6325    #[test]
6326    fn grouped_order_by_count_desc_limit() {
6327        // 5 categories with different node counts: D=4, A=3, B=2, C=1, E=1.
6328        let mut fx = Fx::new();
6329        fx.add("N", "a1", vec![("cat", s("A"))]);
6330        fx.add("N", "a2", vec![("cat", s("A"))]);
6331        fx.add("N", "a3", vec![("cat", s("A"))]);
6332        fx.add("N", "b1", vec![("cat", s("B"))]);
6333        fx.add("N", "b2", vec![("cat", s("B"))]);
6334        fx.add("N", "c1", vec![("cat", s("C"))]);
6335        fx.add("N", "d1", vec![("cat", s("D"))]);
6336        fx.add("N", "d2", vec![("cat", s("D"))]);
6337        fx.add("N", "d3", vec![("cat", s("D"))]);
6338        fx.add("N", "d4", vec![("cat", s("D"))]);
6339        fx.add("N", "e1", vec![("cat", s("E"))]);
6340        let v = fx.view();
6341        let params = BTreeMap::new();
6342
6343        let rs = run(
6344            &v,
6345            "MATCH (n:N) RETURN n.cat, COUNT(*) AS cnt ORDER BY cnt DESC LIMIT 3",
6346            &params,
6347        )
6348        .expect("ORDER BY count DESC LIMIT 3 must succeed");
6349        assert_eq!(rs.len(), 3, "LIMIT 3 must return exactly 3 groups");
6350        // Top 3: D(4), A(3), B(2) — descending order.
6351        assert_eq!(rs.row(0)[1], Some(i(4)), "row 0 must be count 4");
6352        assert_eq!(rs.row(0)[0], Some(s("D")), "row 0 must be category D");
6353        assert_eq!(rs.row(1)[1], Some(i(3)), "row 1 must be count 3");
6354        assert_eq!(rs.row(1)[0], Some(s("A")), "row 1 must be category A");
6355        assert_eq!(rs.row(2)[1], Some(i(2)), "row 2 must be count 2");
6356        assert_eq!(rs.row(2)[0], Some(s("B")), "row 2 must be category B");
6357
6358        // Also verify row_bound is None for this plan (LIMIT must not be pushed).
6359        let plan_ops =
6360            plan_src("MATCH (n:N) RETURN n.cat, COUNT(*) AS cnt ORDER BY cnt DESC LIMIT 3")
6361                .expect("plan must succeed");
6362        assert_eq!(
6363            crate::cypher::plan::row_bound(&plan_ops),
6364            None,
6365            "GroupAggregate plan with LIMIT must have row_bound = None"
6366        );
6367    }
6368
6369    #[test]
6370    fn grouped_empty_input_yields_zero_groups() {
6371        let fx = Fx::new(); // empty graph
6372        let v = fx.view();
6373        let params = BTreeMap::new();
6374
6375        let rs = run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", &params)
6376            .expect("grouped aggregate on empty graph must succeed");
6377        assert_eq!(rs.len(), 0, "empty input must yield zero groups");
6378        assert_eq!(
6379            rs.columns(),
6380            &["n.t".to_string(), "cnt".to_string()],
6381            "columns must still be present even with zero rows"
6382        );
6383    }
6384
6385    #[test]
6386    fn grouped_null_key_groups_together() {
6387        // openCypher semantics: NULL group keys group together.
6388        let mut fx = Fx::new();
6389        fx.add("N", "a", vec![("t", s("X"))]);
6390        fx.add("N", "b", vec![]); // no "t" prop → null key
6391        fx.add("N", "c", vec![]); // null key — groups with b
6392        fx.add("N", "d", vec![("t", s("Y"))]);
6393        let v = fx.view();
6394        let params = BTreeMap::new();
6395
6396        let rs = run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", &params)
6397            .expect("null-key grouped aggregate must succeed");
6398        assert_eq!(rs.len(), 3, "must produce 3 groups: X, null, Y");
6399
6400        // Locate the null group: row where n.t column is None (null).
6401        let null_row = (0..rs.len())
6402            .find(|&i| rs.row(i)[0].is_none())
6403            .expect("null group must be present");
6404        assert_eq!(
6405            rs.row(null_row)[1],
6406            Some(i(2)),
6407            "null group must count 2 rows (b and c)"
6408        );
6409
6410        let x_row = (0..rs.len())
6411            .find(|&i| rs.row(i)[0] == Some(s("X")))
6412            .expect("X group must exist");
6413        assert_eq!(rs.row(x_row)[1], Some(i(1)));
6414
6415        let y_row = (0..rs.len())
6416            .find(|&i| rs.row(i)[0] == Some(s("Y")))
6417            .expect("Y group must exist");
6418        assert_eq!(rs.row(y_row)[1], Some(i(1)));
6419    }
6420
6421    #[test]
6422    fn grouped_cap_error_on_high_cardinality() {
6423        // Use with_max_groups to cap at 2 groups, then run a query that would
6424        // produce 3 groups → must return the named cap error.
6425        let mut fx = Fx::new();
6426        fx.add("N", "a", vec![("t", s("A"))]);
6427        fx.add("N", "b", vec![("t", s("B"))]);
6428        fx.add("N", "c", vec![("t", s("C"))]);
6429        let v = fx.view();
6430        let params = BTreeMap::new();
6431
6432        let err = super::with_max_groups(2, || {
6433            run(&v, "MATCH (n:N) RETURN n.t, COUNT(*) AS cnt", &params)
6434        })
6435        .expect_err("must error when group count exceeds cap");
6436        assert!(
6437            err.to_ascii_lowercase().contains("group count"),
6438            "error must mention group count, got: {err}"
6439        );
6440    }
6441
6442    #[test]
6443    fn multi_aggregate_no_keys() {
6444        // RETURN COUNT(*), COUNT(n) — multiple aggregates, no group keys.
6445        // Routes to GroupAggregate with empty keys.
6446        let mut fx = Fx::new();
6447        fx.add("N", "a", vec![]);
6448        fx.add("N", "b", vec![]);
6449        let v = fx.view();
6450        let params = BTreeMap::new();
6451
6452        let rs = run(&v, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", &params)
6453            .expect("multi-aggregate no keys must succeed");
6454        assert_eq!(rs.len(), 1, "must produce exactly one result row");
6455        assert_eq!(rs.row(0)[0], Some(i(2)), "COUNT(*) must be 2");
6456        assert_eq!(rs.row(0)[1], Some(i(2)), "COUNT(n) must be 2");
6457    }
6458
6459    #[test]
6460    fn aggregate_over_hop_counts_edges() {
6461        let fx = hop_graph();
6462        let v = fx.view();
6463        let params = BTreeMap::new();
6464        // hop_graph has 3 KNOWS edges
6465        let rs = run(
6466            &v,
6467            "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN COUNT(*)",
6468            &params,
6469        )
6470        .expect("COUNT(*) on hop graph");
6471        assert_eq!(rs.row(0), &[Some(i(3))]);
6472    }
6473
6474    // ──────────────────────────────────────────────────────────────────────────
6475    // C-1 fix tests: empty-input no-keys multi-aggregate must return 1 row
6476    // ──────────────────────────────────────────────────────────────────────────
6477
6478    #[test]
6479    fn multi_aggregate_no_keys_empty_graph() {
6480        // C-1: RETURN COUNT(*), COUNT(n) on an empty graph must produce exactly
6481        // one row (COUNT=0, COUNT=0), not zero rows.  Routes to GroupAggregate
6482        // (keys=[], aggs=[Count, Count]).  Verifies parity with the single-agg
6483        // fast path, which also returns one row on empty input.
6484        let fx = Fx::new(); // empty — no nodes
6485        let v = fx.view();
6486        let params = BTreeMap::new();
6487
6488        let rs = run(&v, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", &params)
6489            .expect("empty-graph multi-agg must succeed");
6490        assert_eq!(rs.len(), 1, "must produce exactly 1 row on empty input");
6491        assert_eq!(
6492            rs.row(0)[0],
6493            Some(i(0)),
6494            "COUNT(*) on empty graph must be 0"
6495        );
6496        assert_eq!(
6497            rs.row(0)[1],
6498            Some(i(0)),
6499            "COUNT(n) on empty graph must be 0"
6500        );
6501    }
6502
6503    #[test]
6504    fn fast_path_and_group_path_agree_on_empty_input() {
6505        // Equivalence pin: the single-agg fast path (Aggregate) and the
6506        // multi-agg GroupAggregate path must agree on COUNT for empty input.
6507        // Single-agg fast path: RETURN COUNT(*) → execute_aggregate.
6508        // Group path: RETURN COUNT(*), COUNT(n) → execute_group_aggregate.
6509        // Both must report COUNT = 0 on an empty graph.
6510        let fx = Fx::new();
6511        let v = fx.view();
6512        let params = BTreeMap::new();
6513
6514        let fast = run(&v, "MATCH (n:N) RETURN COUNT(*)", &params)
6515            .expect("fast-path COUNT on empty graph");
6516        assert_eq!(fast.len(), 1, "fast path: 1 row on empty input");
6517        let fast_count = fast.row(0)[0].clone();
6518
6519        let grouped = run(&v, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", &params)
6520            .expect("group-path COUNT on empty graph");
6521        assert_eq!(grouped.len(), 1, "group path: 1 row on empty input");
6522        let group_count = grouped.row(0)[0].clone();
6523
6524        assert_eq!(
6525            fast_count, group_count,
6526            "fast path and group path must agree on COUNT(*) for empty input"
6527        );
6528
6529        // Same check on non-empty input: both paths should see count = 2.
6530        let mut fx2 = Fx::new();
6531        fx2.add("N", "x", vec![]);
6532        fx2.add("N", "y", vec![]);
6533        let v2 = fx2.view();
6534
6535        let fast2 = run(&v2, "MATCH (n:N) RETURN COUNT(*)", &params)
6536            .expect("fast-path COUNT on 2-node graph");
6537        assert_eq!(fast2.row(0)[0], Some(i(2)), "fast path: COUNT(*) = 2");
6538
6539        let grouped2 = run(&v2, "MATCH (n:N) RETURN COUNT(*), COUNT(n)", &params)
6540            .expect("group-path COUNT on 2-node graph");
6541        assert_eq!(grouped2.row(0)[0], Some(i(2)), "group path: COUNT(*) = 2");
6542        assert_eq!(grouped2.row(0)[1], Some(i(2)), "group path: COUNT(n) = 2");
6543    }
6544
6545    // ──────────────────────────────────────────────────────────────────────────
6546    // I-1 fix tests: Int/Float group-key unification
6547    // ──────────────────────────────────────────────────────────────────────────
6548
6549    #[test]
6550    fn grouped_int_float_key_unification() {
6551        // openCypher equality: 1 = 1.0.  Nodes with score=1 (Int) and score=1.0
6552        // (Float) must land in the same group after group_key_normalize maps
6553        // both to FloatBits.  Result: one group with count=2.
6554        //
6555        // Display value: first-seen wins.  Node "a" (Int(1)) is scanned first
6556        // (lower id), so the key column must display as Int(1), not Float(1.0).
6557        let mut fx = Fx::new();
6558        fx.add("N", "a", vec![("score", i(1))]); // scanned first → first-seen
6559        fx.add("N", "b", vec![("score", f(1.0))]);
6560        let v = fx.view();
6561        let params = BTreeMap::new();
6562
6563        let rs = run(&v, "MATCH (n:N) RETURN n.score, COUNT(*) AS cnt", &params)
6564            .expect("int/float unification must succeed");
6565        assert_eq!(
6566            rs.len(),
6567            1,
6568            "Int(1) and Float(1.0) must group together into 1 group"
6569        );
6570        // Key column must display the first-seen original value (Int), not Float.
6571        assert_eq!(
6572            rs.row(0)[0],
6573            Some(i(1)),
6574            "key column must display Int(1) (first-seen)"
6575        );
6576        assert_eq!(rs.row(0)[1], Some(i(2)), "unified group must have count=2");
6577    }
6578
6579    #[test]
6580    fn distinct_collapses_duplicate_projected_values() {
6581        let mut fx = Fx::new();
6582        fx.add("N", "a1", vec![("city", s("Austin"))]);
6583        fx.add("N", "a2", vec![("city", s("Austin"))]);
6584        let v = fx.view();
6585        let params = BTreeMap::new();
6586        let rs = run(&v, "MATCH (n:N) RETURN DISTINCT n.city", &params).unwrap();
6587        assert_eq!(rs.len(), 1);
6588        assert_eq!(rs.row(0)[0], Some(s("Austin")));
6589    }
6590
6591    #[test]
6592    fn distinct_int_float_unify() {
6593        let mut fx = Fx::new();
6594        fx.add("N", "a", vec![("score", i(1))]);
6595        fx.add("N", "b", vec![("score", f(1.0))]);
6596        let v = fx.view();
6597        let params = BTreeMap::new();
6598        let rs = run(&v, "MATCH (n:N) RETURN DISTINCT n.score", &params).unwrap();
6599        assert_eq!(
6600            rs.len(),
6601            1,
6602            "Int(1) and Float(1.0) must DISTINCT as one row"
6603        );
6604        assert_eq!(rs.row(0)[0], Some(i(1)), "first-seen Int(1) wins display");
6605    }
6606
6607    #[test]
6608    fn distinct_caps_at_intermediate_row_budget() {
6609        let mut fx = Fx::new();
6610        fx.add("N", "a", vec![("city", s("Austin"))]);
6611        fx.add("N", "b", vec![("city", s("Paris"))]);
6612        let v = fx.view();
6613        let params = BTreeMap::new();
6614        let err = super::with_max_intermediate_rows(1, || {
6615            run(&v, "MATCH (n:N) RETURN DISTINCT n.city", &params)
6616        })
6617        .expect_err("two distinct cities must exceed cap=1");
6618        assert!(
6619            err.contains("1") || err.contains("row"),
6620            "cap error must mention the budget, got: {err}"
6621        );
6622    }
6623
6624    #[test]
6625    fn where_in_list_filters_rows() {
6626        let mut fx = Fx::new();
6627        fx.add("N", "a", vec![("city", s("Austin"))]);
6628        fx.add("N", "p", vec![("city", s("Paris"))]);
6629        fx.add("N", "l", vec![("city", s("London"))]);
6630        let v = fx.view();
6631        let mut params = BTreeMap::new();
6632        params.insert("c".into(), s("Paris"));
6633        let rs = run(
6634            &v,
6635            "MATCH (n:N) WHERE n.city IN ['Austin', $c] RETURN n.city",
6636            &params,
6637        )
6638        .unwrap();
6639        assert_eq!(rs.len(), 2);
6640    }
6641
6642    #[test]
6643    fn pure_int_keys_display_as_int() {
6644        // N-1 regression: group keys that are integers must not be upcast to
6645        // Float in the output row.  Previously group_key_normalize converted
6646        // Int→FloatBits and value_key_to_value reconstructed Float(42.0) from
6647        // the key; now the original Value is stored and reused for display.
6648        let mut fx = Fx::new();
6649        fx.add("N", "a", vec![("age", i(10))]);
6650        fx.add("N", "b", vec![("age", i(20))]);
6651        fx.add("N", "c", vec![("age", i(10))]);
6652        let v = fx.view();
6653        let params = BTreeMap::new();
6654
6655        let rs = run(
6656            &v,
6657            "MATCH (n:N) RETURN n.age, COUNT(*) AS cnt ORDER BY n.age",
6658            &params,
6659        )
6660        .expect("pure-Int group keys must succeed");
6661        assert_eq!(rs.len(), 2, "must have 2 groups: age=10 and age=20");
6662        // Key column must be Int, not Float.
6663        assert_eq!(
6664            rs.row(0)[0],
6665            Some(i(10)),
6666            "age=10 key column must display as Int(10)"
6667        );
6668        assert_eq!(rs.row(0)[1], Some(i(2)), "age=10 group has 2 nodes");
6669        assert_eq!(
6670            rs.row(1)[0],
6671            Some(i(20)),
6672            "age=20 key column must display as Int(20)"
6673        );
6674        assert_eq!(rs.row(1)[1], Some(i(1)), "age=20 group has 1 node");
6675    }
6676
6677    // ──────────────────────────────────────────────────────────────────────────
6678    // M-1: VarExpand + GroupAggregate staged-path integration test
6679    // ──────────────────────────────────────────────────────────────────────────
6680
6681    #[test]
6682    fn var_expand_group_aggregate_staged_path() {
6683        // Combines variable-length MATCH with a grouped RETURN — exercises the
6684        // staged-path GroupAggregate arm that groups over materialised VarExpand
6685        // rows.
6686        //
6687        // Graph: chain a -T-> b -T-> c  (3 nodes, 2 edges)
6688        // MATCH (x)-[*1..2]->(y) RETURN y.key, COUNT(*)
6689        //   1-hop results: (x=a, y=b), (x=b, y=c)       → b gets 1, c gets 1
6690        //   2-hop results: (x=a, y=c)                    → c gets 1 more
6691        //   So: b → 1, c → 2.
6692        let mut fx = Fx::new();
6693        let a = fx.add("N", "a", vec![("key", s("a"))]);
6694        let b = fx.add("N", "b", vec![("key", s("b"))]);
6695        let c = fx.add("N", "c", vec![("key", s("c"))]);
6696        fx.edge("T", a, b, vec![]);
6697        fx.edge("T", b, c, vec![]);
6698        let v = fx.view();
6699        let params = BTreeMap::new();
6700
6701        let rs = run(
6702            &v,
6703            "MATCH (x:N)-[*1..2]->(y:N) RETURN y.key, COUNT(*) AS cnt ORDER BY y.key",
6704            &params,
6705        )
6706        .expect("VarExpand + GroupAggregate must succeed");
6707
6708        assert_eq!(rs.len(), 2, "must have 2 destination groups: b and c");
6709        assert_eq!(rs.row(0)[0], Some(s("b")), "first group key must be 'b'");
6710        assert_eq!(rs.row(0)[1], Some(i(1)), "b is reached via 1 path");
6711        assert_eq!(rs.row(1)[0], Some(s("c")), "second group key must be 'c'");
6712        assert_eq!(
6713            rs.row(1)[1],
6714            Some(i(2)),
6715            "c is reached via 2 paths (1-hop and 2-hop)"
6716        );
6717    }
6718
6719    // ──────────────────────────────────────────────────────────────────────────
6720    // pull_rows defense-in-depth: VarExpand and ShortestPath Err arms
6721    // These arms exist so that adding PlanOp variants forces a compile-time
6722    // decision in pull_rows.  Routing prevention is tested separately; these
6723    // tests verify the arms themselves are not dead code.
6724    // ──────────────────────────────────────────────────────────────────────────
6725
6726    #[test]
6727    fn pull_rows_var_expand_arm_returns_named_err() {
6728        let fx = Fx::new();
6729        let view = fx.view();
6730        let vars = super::VarTable {
6731            names: vec!["a".into(), "b".into()],
6732        };
6733        let project_items: Vec<crate::cypher::ast::RetItem> = vec![];
6734        let empty_params = BTreeMap::new();
6735        let params = super::Params(&empty_params);
6736        let ctx = super::PullCtx {
6737            view: &view,
6738            vars: &vars,
6739            project_items: &project_items,
6740            params: &params,
6741            bound: 100,
6742        };
6743        let ops = vec![PlanOp::VarExpand {
6744            from: "a".into(),
6745            rel_var: None,
6746            etypes: vec![],
6747            dir: crate::cypher::RelDir::Right,
6748            to: "b".into(),
6749            min: 1,
6750            max: 3,
6751        }];
6752        let mut row = vec![None; vars.names.len()];
6753        let mut result = Vec::new();
6754        let err = super::pull_rows(&ctx, &ops, &mut row, &mut result)
6755            .expect_err("VarExpand must Err in pull_rows");
6756        assert!(
6757            err.contains("VarExpand") && err.contains("pull executor"),
6758            "error must name VarExpand and pull executor, got: {err}"
6759        );
6760    }
6761
6762    #[test]
6763    fn pull_rows_shortest_path_arm_returns_named_err() {
6764        let fx = Fx::new();
6765        let view = fx.view();
6766        let vars = super::VarTable {
6767            names: vec!["a".into(), "b".into()],
6768        };
6769        let project_items: Vec<crate::cypher::ast::RetItem> = vec![];
6770        let empty_params = BTreeMap::new();
6771        let params = super::Params(&empty_params);
6772        let ctx = super::PullCtx {
6773            view: &view,
6774            vars: &vars,
6775            project_items: &project_items,
6776            params: &params,
6777            bound: 100,
6778        };
6779        let ops = vec![PlanOp::ShortestPath {
6780            from: "a".into(),
6781            rel_var: None,
6782            etypes: vec![],
6783            dir: crate::cypher::RelDir::Right,
6784            to: "b".into(),
6785            max_hops: 5,
6786        }];
6787        let mut row = vec![None; vars.names.len()];
6788        let mut result = Vec::new();
6789        let err = super::pull_rows(&ctx, &ops, &mut row, &mut result)
6790            .expect_err("ShortestPath must Err in pull_rows");
6791        assert!(
6792            err.contains("ShortestPath") && err.contains("pull executor"),
6793            "error must name ShortestPath and pull executor, got: {err}"
6794        );
6795    }
6796
6797    // ── WITH / UNWIND pipeline tests ─────────────────────────────────────────
6798
6799    /// Helper: build a graph with cities for pipeline tests.
6800    fn city_graph() -> Fx {
6801        let mut fx = Fx::new();
6802        fx.add(
6803            "Person",
6804            "alice",
6805            vec![("city", s("Boston")), ("age", i(30))],
6806        );
6807        fx.add("Person", "bob", vec![("city", s("Boston")), ("age", i(25))]);
6808        fx.add(
6809            "Person",
6810            "carol",
6811            vec![("city", s("Austin")), ("age", i(35))],
6812        );
6813        fx.add(
6814            "Person",
6815            "dave",
6816            vec![("city", s("Austin")), ("age", i(28))],
6817        );
6818        fx.add("Person", "eve", vec![("city", s("Boston")), ("age", i(22))]);
6819        fx
6820    }
6821
6822    /// HAVING idiom: WITH a, COUNT(*) AS c WHERE c > 2.
6823    /// Boston has 3 people, Austin has 2. WHERE c > 2 keeps only Boston.
6824    #[test]
6825    fn with_aggregate_having_filters_groups() {
6826        let fx = city_graph();
6827        let view = fx.view();
6828        let params = BTreeMap::new();
6829        let rs = run(
6830            &view,
6831            "MATCH (p:Person) WITH p.city AS city, COUNT(*) AS cnt WHERE cnt > 2 RETURN city, cnt",
6832            &params,
6833        )
6834        .expect("WITH HAVING query must succeed");
6835        assert_eq!(rs.len(), 1, "only Boston group has cnt > 2");
6836        assert_eq!(rs.get(0, "city"), Some(&s("Boston")));
6837        assert_eq!(rs.get(0, "cnt"), Some(&i(3)));
6838    }
6839
6840    /// WITH ORDER BY / LIMIT then RETURN.
6841    #[test]
6842    fn with_order_limit_then_return() {
6843        let fx = city_graph();
6844        let view = fx.view();
6845        let params = BTreeMap::new();
6846        // Get top 2 oldest people via WITH … ORDER BY age DESC LIMIT 2 RETURN name.
6847        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)
6848            .expect("WITH ORDER LIMIT must succeed");
6849        assert_eq!(rs.len(), 2, "LIMIT 2");
6850        // carol=35 is first, alice=30 is second (or bob=25, dave=28 depending on tie)
6851        let ages: Vec<Option<Value>> = (0..rs.len()).map(|i| rs.get(i, "age").cloned()).collect();
6852        assert_eq!(ages[0], Some(i(35)), "oldest person first");
6853        assert_eq!(ages[1], Some(i(30)), "second oldest");
6854    }
6855
6856    /// Chained WITH stages.
6857    #[test]
6858    fn chained_with_stages() {
6859        let fx = city_graph();
6860        let view = fx.view();
6861        let params = BTreeMap::new();
6862        // First WITH: project city alias. Second WITH: filter by city.
6863        let rs = run(&view,
6864            "MATCH (p:Person) WITH p.city AS city, p.age AS age WITH city, age WHERE age > 25 RETURN city, age",
6865            &params).expect("chained WITH must succeed");
6866        // alice=30, carol=35, dave=28 have age > 25; bob=25, eve=22 excluded
6867        assert_eq!(rs.len(), 3, "3 people with age > 25");
6868    }
6869
6870    /// MATCH re-entry after WITH using a bound variable.
6871    #[test]
6872    fn with_then_match_reentry() {
6873        let mut fx = Fx::new();
6874        let alice = fx.add("Person", "alice", vec![]);
6875        let bob = fx.add("Person", "bob", vec![]);
6876        let corp = fx.add("Company", "acme", vec![]);
6877        fx.edge("WORKS_AT", alice, corp, vec![("years", i(5))]);
6878        fx.edge("WORKS_AT", bob, corp, vec![("years", i(3))]);
6879        let view = fx.view();
6880        let params = BTreeMap::new();
6881        // Find person, re-enter MATCH via their company.
6882        let rs = run(&view,
6883            "MATCH (p:Person)-[r:WORKS_AT]->(c:Company) WITH p, c MATCH (c)-[r2:WORKS_AT]-(colleague:Person) RETURN p, colleague",
6884            &params).expect("WITH MATCH re-entry must succeed");
6885        // alice→acme→bob, bob→acme→alice each appear (2 people × 2 colleagues)
6886        assert!(rs.len() >= 2, "cross join via company: got {}", rs.len());
6887    }
6888
6889    /// UNWIND literal list produces one row per element.
6890    #[test]
6891    fn unwind_literal_list_produces_rows() {
6892        let fx = city_graph();
6893        let view = fx.view();
6894        let params = BTreeMap::new();
6895        let rs = run(
6896            &view,
6897            "MATCH (p:Person) WHERE p.city = 'Boston' UNWIND [1, 2, 3] AS x RETURN p, x",
6898            &params,
6899        )
6900        .expect("UNWIND literal must succeed");
6901        // 3 Boston people × 3 elements = 9 rows
6902        assert_eq!(rs.len(), 9, "UNWIND [1,2,3] × 3 Boston people = 9 rows");
6903        // All x values should include 1, 2, and 3.
6904        let xs: Vec<Option<Value>> = (0..rs.len())
6905            .map(|row_i| rs.get(row_i, "x").cloned())
6906            .collect();
6907        assert!(xs.contains(&Some(i(1))));
6908        assert!(xs.contains(&Some(i(2))));
6909        assert!(xs.contains(&Some(i(3))));
6910    }
6911
6912    /// UNWIND of a list-valued property.
6913    #[test]
6914    fn unwind_list_property() {
6915        let mut fx = Fx::new();
6916        fx.add(
6917            "Tag",
6918            "post1",
6919            vec![("tags", Value::List(vec![s("rust"), s("graph")]))],
6920        );
6921        fx.add("Tag", "post2", vec![("tags", Value::List(vec![s("db")]))]);
6922        let view = fx.view();
6923        let params = BTreeMap::new();
6924        let rs = run(
6925            &view,
6926            "MATCH (p:Tag) UNWIND p.tags AS tag RETURN p, tag",
6927            &params,
6928        )
6929        .expect("UNWIND property must succeed");
6930        assert_eq!(rs.len(), 3, "2+1 tag elements");
6931    }
6932
6933    /// UNWIND null / empty list → 0 rows (openCypher).
6934    #[test]
6935    fn unwind_empty_list_yields_zero_rows() {
6936        let fx = city_graph();
6937        let view = fx.view();
6938        let params = BTreeMap::new();
6939        let rs = run(
6940            &view,
6941            "MATCH (p:Person) WHERE p.city = 'Boston' UNWIND [] AS x RETURN x",
6942            &params,
6943        )
6944        .expect("UNWIND [] must succeed with 0 rows");
6945        assert_eq!(rs.len(), 0, "UNWIND [] should produce 0 rows");
6946    }
6947
6948    /// UNWIND of a non-list → named error.
6949    #[test]
6950    fn unwind_non_list_is_named_error() {
6951        let fx = city_graph();
6952        let view = fx.view();
6953        let params = BTreeMap::new();
6954        // p.city is a Str, not a List.
6955        let err = run(
6956            &view,
6957            "MATCH (p:Person) WHERE p.city = 'Boston' UNWIND p.city AS x RETURN x",
6958            &params,
6959        )
6960        .expect_err("UNWIND non-list must error");
6961        assert!(
6962            err.contains("UNWIND") && err.contains("list"),
6963            "error must mention UNWIND and list: {err}"
6964        );
6965    }
6966
6967    /// UNWIND cross-product that trips the intermediate-row budget.
6968    #[test]
6969    fn unwind_cross_product_trips_budget() {
6970        let mut fx = Fx::new();
6971        // 10 nodes, each UNWIND 10 elements → 100 rows; set cap to 5.
6972        for i in 0..10 {
6973            fx.add("N", &format!("n{i}"), vec![]);
6974        }
6975        let view = fx.view();
6976        let params = BTreeMap::new();
6977        let err = super::with_max_intermediate_rows(5, || {
6978            run(
6979                &view,
6980                "MATCH (n:N) UNWIND [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] AS x RETURN n, x",
6981                &params,
6982            )
6983        })
6984        .expect_err("must hit budget");
6985        assert!(
6986            err.contains("intermediate result exceeds"),
6987            "error must mention intermediate result limit: {err}"
6988        );
6989    }
6990
6991    /// Routing pins: plans with WITH take staged path (row_bound returns None);
6992    /// non-WITH plans are unchanged.
6993    #[test]
6994    fn routing_with_plan_uses_staged_path() {
6995        use crate::cypher::plan::row_bound;
6996        let ops_with = compile("MATCH (a) WITH a RETURN a");
6997        assert_eq!(
6998            row_bound(&ops_with),
6999            None,
7000            "WITH plan must have row_bound=None"
7001        );
7002
7003        let ops_unwind = compile("MATCH (a) UNWIND [1, 2] AS x RETURN a");
7004        assert_eq!(
7005            row_bound(&ops_unwind),
7006            None,
7007            "UNWIND plan must have row_bound=None"
7008        );
7009
7010        // Non-WITH plan with LIMIT still uses pull path.
7011        let ops_plain = compile("MATCH (a) RETURN a LIMIT 5");
7012        assert!(
7013            row_bound(&ops_plain).is_some(),
7014            "plain LIMIT plan must have a row bound"
7015        );
7016    }
7017
7018    /// I-1: UNWIND of a null/absent property → 0 rows for that node; other nodes
7019    /// with valid lists still expand normally.
7020    #[test]
7021    fn unwind_null_property_yields_zero_rows_for_that_node() {
7022        let mut fx = Fx::new();
7023        // post1 has a list property; post2 has no `tags` property at all (null).
7024        fx.add(
7025            "Post",
7026            "post1",
7027            vec![("tags", Value::List(vec![s("rust"), s("graph")]))],
7028        );
7029        fx.add("Post", "post2", vec![("other", Value::Str("hello".into()))]);
7030        let view = fx.view();
7031        let params = BTreeMap::new();
7032        let rs = run(
7033            &view,
7034            "MATCH (p:Post) UNWIND p.tags AS tag RETURN tag",
7035            &params,
7036        )
7037        .expect("UNWIND null property must succeed (not error)");
7038        // post1 contributes 2 rows; post2 contributes 0 rows (null → skip).
7039        assert_eq!(
7040            rs.len(),
7041            2,
7042            "null property must yield 0 rows; list property yields N rows"
7043        );
7044        let tag0 = rs.get(0, "tag");
7045        let tag1 = rs.get(1, "tag");
7046        let tags = [tag0, tag1];
7047        assert!(tags.contains(&Some(&s("rust"))), "rust tag present");
7048        assert!(tags.contains(&Some(&s("graph"))), "graph tag present");
7049    }
7050
7051    /// I-2a: WHERE before UNWIND (pre-expansion filter — existing grammar).
7052    #[test]
7053    fn where_before_unwind_filters_nodes() {
7054        let mut fx = Fx::new();
7055        fx.add(
7056            "P",
7057            "a",
7058            vec![
7059                ("group", Value::Str("keep".into())),
7060                ("items", Value::List(vec![Value::Int(1), Value::Int(2)])),
7061            ],
7062        );
7063        fx.add(
7064            "P",
7065            "b",
7066            vec![
7067                ("group", Value::Str("drop".into())),
7068                ("items", Value::List(vec![Value::Int(3), Value::Int(4)])),
7069            ],
7070        );
7071        let view = fx.view();
7072        let params = BTreeMap::new();
7073        // WHERE filters before UNWIND: only node `a` (group=keep) expands.
7074        let rs = run(
7075            &view,
7076            "MATCH (n:P) WHERE n.group = 'keep' UNWIND n.items AS x RETURN x",
7077            &params,
7078        )
7079        .expect("WHERE before UNWIND must succeed");
7080        assert_eq!(rs.len(), 2, "only matching node expands; 2 items");
7081    }
7082
7083    /// I-2b: UNWIND then WHERE (post-expansion filter — new grammar support).
7084    #[test]
7085    fn where_after_unwind_filters_expanded_rows() {
7086        let mut fx = Fx::new();
7087        fx.add(
7088            "N",
7089            "n1",
7090            vec![(
7091                "vals",
7092                Value::List(vec![Value::Int(1), Value::Int(3), Value::Int(5)]),
7093            )],
7094        );
7095        let view = fx.view();
7096        let params = BTreeMap::new();
7097        // WHERE after UNWIND: filters by the alias `x`; keeps only x > 2.
7098        let rs = run(
7099            &view,
7100            "MATCH (n:N) UNWIND n.vals AS x WHERE x > 2 RETURN x",
7101            &params,
7102        )
7103        .expect("WHERE after UNWIND must succeed");
7104        assert_eq!(rs.len(), 2, "x=3 and x=5 pass; x=1 filtered out");
7105        let v0 = rs.get(0, "x").cloned();
7106        let v1 = rs.get(1, "x").cloned();
7107        let vals = [v0, v1];
7108        assert!(vals.contains(&Some(Value::Int(3))), "x=3 present");
7109        assert!(vals.contains(&Some(Value::Int(5))), "x=5 present");
7110    }
7111
7112    /// I-3: ORDER BY with a typo'd alias in aggregate WITH → named error at plan time.
7113    #[test]
7114    fn aggregate_with_order_by_unknown_alias_is_named_error() {
7115        use crate::cypher::{lex, parser::parse, plan::plan};
7116        // `cntt` is a typo; the real column is `cnt` — must fail at planning.
7117        let src = "MATCH (p:Person) WITH p.city AS city, COUNT(*) AS cnt ORDER BY cntt DESC RETURN city, cnt";
7118        let plan_result = plan(&parse(&lex(src).expect("lex")).expect("parse"));
7119        let err = plan_result
7120            .expect_err("typo'd ORDER BY alias in aggregate WITH must be a named plan error");
7121        assert!(
7122            err.contains("cntt") || err.contains("unbound"),
7123            "error must mention the unknown alias: {err}"
7124        );
7125    }
7126
7127    /// I-3 non-aggregate path: ORDER BY with unknown alias → named error at plan time.
7128    #[test]
7129    fn non_aggregate_with_order_by_unknown_alias_is_named_error() {
7130        use crate::cypher::{lex, parser::parse, plan::plan};
7131        let src = "MATCH (p:Person) WITH p, p.age AS age ORDER BY nope DESC RETURN p.city AS city";
7132        let plan_result = plan(&parse(&lex(src).expect("lex")).expect("parse"));
7133        let err = plan_result
7134            .expect_err("typo'd ORDER BY alias in non-aggregate WITH must be a named plan error");
7135        assert!(
7136            err.contains("nope") || err.contains("unbound"),
7137            "error must mention the unknown alias: {err}"
7138        );
7139    }
7140
7141    /// Round-2 pin 1: post-UNWIND WHERE referencing a pre-MATCH-bound variable.
7142    /// Verifies scope is correctly available across the UNWIND boundary —
7143    /// a regression that drops `n` from scope would either error or return all rows.
7144    #[test]
7145    fn post_unwind_where_references_pre_match_variable() {
7146        let mut fx = Fx::new();
7147        // n1: threshold=3, list=[1,2,3,4,5] → keep 4,5 (x > threshold)
7148        fx.add(
7149            "N",
7150            "n1",
7151            vec![
7152                ("threshold", Value::Int(3)),
7153                (
7154                    "xs",
7155                    Value::List(vec![
7156                        Value::Int(1),
7157                        Value::Int(2),
7158                        Value::Int(3),
7159                        Value::Int(4),
7160                        Value::Int(5),
7161                    ]),
7162                ),
7163            ],
7164        );
7165        // n2: threshold=10, list=[1,2] → keep nothing
7166        fx.add(
7167            "N",
7168            "n2",
7169            vec![
7170                ("threshold", Value::Int(10)),
7171                ("xs", Value::List(vec![Value::Int(1), Value::Int(2)])),
7172            ],
7173        );
7174        let view = fx.view();
7175        let params = BTreeMap::new();
7176        let rs = run(
7177            &view,
7178            "MATCH (n:N) UNWIND n.xs AS x WHERE x > n.threshold RETURN x",
7179            &params,
7180        )
7181        .expect("post-UNWIND WHERE referencing MATCH variable must succeed");
7182        // n1 contributes x=4 and x=5; n2 contributes nothing.
7183        assert_eq!(rs.len(), 2, "exactly 2 rows: x=4 and x=5 from n1");
7184        let v0 = rs.get(0, "x").cloned();
7185        let v1 = rs.get(1, "x").cloned();
7186        let got = [v0, v1];
7187        assert!(got.contains(&Some(Value::Int(4))), "x=4 present");
7188        assert!(got.contains(&Some(Value::Int(5))), "x=5 present");
7189    }
7190
7191    /// Round-2 pin 2: ORDER BY on an aggregate alias INSIDE a WITH stage.
7192    /// Verifies compile_with_stage emits OrderBy that exec_order_by_rows
7193    /// applies to Cell::Scalar group rows — the outer q.order_by path is separate.
7194    #[test]
7195    fn with_stage_aggregate_order_by_descending() {
7196        // Boston: 3 people (alice age=30, bob age=25, eve age=22)
7197        // Austin: 2 people (carol age=35, dave age=28)
7198        let fx = city_graph();
7199        let view = fx.view();
7200        let params = BTreeMap::new();
7201        let rs = run(
7202            &view,
7203            "MATCH (p:Person) WITH p.city AS city, COUNT(*) AS cnt ORDER BY cnt DESC RETURN city, cnt",
7204            &params,
7205        )
7206        .expect("aggregate WITH ORDER BY must succeed");
7207        assert_eq!(rs.len(), 2, "two city groups");
7208        // With ORDER BY cnt DESC: Boston (3) first, Austin (2) second.
7209        assert_eq!(
7210            rs.get(0, "cnt"),
7211            Some(&Value::Int(3)),
7212            "first row must be the group with cnt=3 (Boston)"
7213        );
7214        assert_eq!(
7215            rs.get(1, "cnt"),
7216            Some(&Value::Int(2)),
7217            "second row must be the group with cnt=2 (Austin)"
7218        );
7219    }
7220
7221    /// Round-2 pin 3: UNWIND-then-WITH composition.
7222    /// Verifies the pipeline correctly threads UNWIND-expanded rows into a
7223    /// downstream WITH stage (both simple pass-through and aggregation).
7224    #[test]
7225    fn unwind_then_with_composition() {
7226        let mut fx = Fx::new();
7227        fx.add(
7228            "Doc",
7229            "d1",
7230            vec![(
7231                "scores",
7232                Value::List(vec![Value::Int(10), Value::Int(20), Value::Int(30)]),
7233            )],
7234        );
7235        fx.add(
7236            "Doc",
7237            "d2",
7238            vec![("scores", Value::List(vec![Value::Int(5), Value::Int(15)]))],
7239        );
7240        let view = fx.view();
7241        let params = BTreeMap::new();
7242
7243        // Simple pass-through: UNWIND then WITH x (non-aggregate).
7244        let rs = run(
7245            &view,
7246            "MATCH (n:Doc) UNWIND n.scores AS x WITH x RETURN x",
7247            &params,
7248        )
7249        .expect("UNWIND then WITH pass-through must succeed");
7250        assert_eq!(rs.len(), 5, "3 + 2 = 5 expanded rows carried through WITH");
7251
7252        // Aggregation over UNWIND: COUNT all expanded values.
7253        let rs2 = run(
7254            &view,
7255            "MATCH (n:Doc) UNWIND n.scores AS x WITH COUNT(*) AS total RETURN total",
7256            &params,
7257        )
7258        .expect("UNWIND then aggregate WITH must succeed");
7259        assert_eq!(rs2.len(), 1, "single aggregate row");
7260        assert_eq!(
7261            rs2.get(0, "total"),
7262            Some(&Value::Int(5)),
7263            "total must be 5 (3+2 expanded rows)"
7264        );
7265    }
7266
7267    /// Regression: i64::MIN / -1 must not panic (checked_div saturates to i64::MAX).
7268    /// Before the fix, the BinArith Div branch used plain `a / b` which panics on overflow
7269    /// in both debug and release. ArithOp::Div is not reachable via the current parser
7270    /// (the lexer rejects '/' as an illegal character), so this test calls resolve_operand
7271    /// directly to pin the exact overflow case.
7272    #[test]
7273    fn binarith_div_min_over_neg1_does_not_panic() {
7274        let fx = Fx::new();
7275        let view = fx.view();
7276        let vars = VarTable { names: vec![] };
7277        let row: Row = vec![];
7278        let params = BTreeMap::new();
7279
7280        // Construct: i64::MIN / -1  (overflows — checked_div returns None → saturates to i64::MAX)
7281        let operand = Operand::BinArith {
7282            op: ArithOp::Div,
7283            left: Box::new(Operand::Lit(Value::Int(i64::MIN))),
7284            right: Box::new(Operand::Lit(Value::Int(-1))),
7285        };
7286
7287        let result = resolve_operand(&view, &vars, &row, &operand, &Params(&params));
7288        assert!(
7289            result.is_ok(),
7290            "overflow division must not return Err: {result:?}"
7291        );
7292        assert_eq!(
7293            result.unwrap(),
7294            Some(Value::Int(i64::MAX)),
7295            "i64::MIN / -1 must saturate to i64::MAX, not panic"
7296        );
7297    }
7298
7299    /// Regression: collect_operand was missing the BinArith arm, so $params inside
7300    /// arithmetic expressions were invisible to pre-flight validation.  A query like
7301    /// `RETURN abs($missing - 1)` with no $missing supplied must fail at pre-flight
7302    /// with a named-param error, not silently return null.
7303    #[test]
7304    fn binarith_missing_param_caught_at_preflight() {
7305        let fx = Fx::new();
7306        let view = fx.view();
7307        // Parser requires MATCH; bare RETURN is not supported.
7308        // The param is inside a BinArith sub-expression wrapped in a scalar
7309        // function call (`abs`): collect_operand must recurse into BinArith
7310        // left/right to surface $missing at pre-flight time.
7311        let err = run(
7312            &view,
7313            "MATCH (n:X) RETURN abs($missing - 1)",
7314            &BTreeMap::new(),
7315        )
7316        .expect_err("missing param inside BinArith must be caught at preflight");
7317        assert!(
7318            err.contains("missing"),
7319            "error must name the param 'missing', got: {err}"
7320        );
7321    }
7322
7323    // ── IS NULL / IS NOT NULL evaluation ─────────────────────────────────────
7324
7325    /// `WHERE n.missing IS NULL` must return nodes that lack the property.
7326    #[test]
7327    fn is_null_filters_absent_prop() {
7328        let mut fx = Fx::new();
7329        fx.add("Person", "alice", vec![("age", Value::Int(30))]);
7330        fx.add("Person", "bob", vec![]); // no `age` prop
7331        let view = fx.view();
7332        let rs = run(
7333            &view,
7334            "MATCH (n:Person) WHERE n.age IS NULL RETURN n",
7335            &BTreeMap::new(),
7336        )
7337        .unwrap();
7338        assert_eq!(rs.len(), 1, "only bob lacks age");
7339        assert_eq!(rs.get(0, "n"), Some(&s("bob")));
7340    }
7341
7342    /// `WHERE n.prop IS NOT NULL` must return nodes that have the property.
7343    #[test]
7344    fn is_not_null_filters_present_prop() {
7345        let mut fx = Fx::new();
7346        fx.add("Person", "alice", vec![("age", Value::Int(30))]);
7347        fx.add("Person", "bob", vec![]); // no `age` prop
7348        let view = fx.view();
7349        let rs = run(
7350            &view,
7351            "MATCH (n:Person) WHERE n.age IS NOT NULL RETURN n",
7352            &BTreeMap::new(),
7353        )
7354        .unwrap();
7355        assert_eq!(rs.len(), 1, "only alice has age");
7356        assert_eq!(rs.get(0, "n"), Some(&s("alice")));
7357    }
7358
7359    /// Anti-join idiom: OPTIONAL MATCH (a)-[:T]->(b) WHERE b IS NULL
7360    /// returns nodes that have no outgoing T edge.
7361    #[test]
7362    fn optional_match_is_null_anti_join() {
7363        let mut fx = Fx::new();
7364        let alice = fx.add("Person", "alice", vec![]);
7365        let bob = fx.add("Person", "bob", vec![]);
7366        let carol = fx.add("Person", "carol", vec![]);
7367        fx.edge("KNOWS", alice, carol, vec![]); // alice → carol
7368        fx.edge("KNOWS", carol, bob, vec![]); // carol → bob
7369                                              // bob has no outgoing KNOWS edge; alice and carol both do.
7370        let view = fx.view();
7371        let rs = run(
7372            &view,
7373            "MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b) WITH a, b WHERE b IS NULL RETURN a",
7374            &BTreeMap::new(),
7375        )
7376        .unwrap();
7377        assert_eq!(rs.len(), 1, "only bob has no outgoing KNOWS edge");
7378        assert_eq!(rs.get(0, "a"), Some(&s("bob")));
7379    }
7380
7381    /// IS NULL composes with AND correctly.
7382    #[test]
7383    fn is_null_combined_with_and_exec() {
7384        let mut fx = Fx::new();
7385        fx.add("N", "a", vec![("x", Value::Int(1))]);
7386        fx.add("N", "b", vec![("x", Value::Int(2)), ("y", Value::Int(9))]);
7387        fx.add("N", "c", vec![]); // no x, no y
7388        let view = fx.view();
7389        // WHERE n.y IS NULL AND n.x IS NOT NULL → only a (x=1, no y)
7390        let rs = run(
7391            &view,
7392            "MATCH (n:N) WHERE n.y IS NULL AND n.x IS NOT NULL RETURN n",
7393            &BTreeMap::new(),
7394        )
7395        .unwrap();
7396        assert_eq!(rs.len(), 1);
7397        assert_eq!(rs.get(0, "n"), Some(&s("a")));
7398    }
7399
7400    // ── Arithmetic evaluation ─────────────────────────────────────────────────
7401
7402    /// `n.age + 1` in RETURN produces ScalarExpr result per row.
7403    #[test]
7404    fn arithmetic_add_in_return() {
7405        let mut fx = Fx::new();
7406        fx.add("Person", "alice", vec![("age", Value::Int(29))]);
7407        let view = fx.view();
7408        let rs = run(
7409            &view,
7410            "MATCH (n:Person) RETURN n.age + 1 AS adjusted",
7411            &BTreeMap::new(),
7412        )
7413        .unwrap();
7414        assert_eq!(rs.len(), 1);
7415        assert_eq!(rs.get(0, "adjusted"), Some(&Value::Int(30)));
7416    }
7417
7418    /// Parentheses override default precedence: (1+2)*3 = 9, not 1+(2*3)=7.
7419    #[test]
7420    fn arithmetic_precedence_parens_pin() {
7421        let mut fx = Fx::new();
7422        fx.add("N", "x", vec![]);
7423        let view = fx.view();
7424        let rs = run(
7425            &view,
7426            "MATCH (n:N) RETURN (1 + 2) * 3 AS r",
7427            &BTreeMap::new(),
7428        )
7429        .unwrap();
7430        assert_eq!(rs.len(), 1);
7431        assert_eq!(rs.get(0, "r"), Some(&Value::Int(9)));
7432    }
7433
7434    /// Without parens, 1+2*3 = 7 (multiplication over addition).
7435    #[test]
7436    fn arithmetic_precedence_mul_over_add_pin() {
7437        let mut fx = Fx::new();
7438        fx.add("N", "x", vec![]);
7439        let view = fx.view();
7440        let rs = run(&view, "MATCH (n:N) RETURN 1 + 2 * 3 AS r", &BTreeMap::new()).unwrap();
7441        assert_eq!(rs.len(), 1);
7442        assert_eq!(rs.get(0, "r"), Some(&Value::Int(7)));
7443    }
7444
7445    /// Division by zero in an arithmetic expression returns an error (not panic).
7446    #[test]
7447    fn arithmetic_div_by_zero_returns_error() {
7448        let mut fx = Fx::new();
7449        fx.add("N", "x", vec![]);
7450        let view = fx.view();
7451        let err = run(
7452            &view,
7453            "MATCH (n:N) WHERE 1 / 0 > 0 RETURN n",
7454            &BTreeMap::new(),
7455        )
7456        .expect_err("division by zero must error");
7457        assert!(
7458            err.contains("division by zero"),
7459            "error must mention division by zero, got: {err}"
7460        );
7461    }
7462
7463    /// Null propagation: arithmetic on a null operand yields null (filter excludes row).
7464    #[test]
7465    fn arithmetic_null_propagates() {
7466        let mut fx = Fx::new();
7467        fx.add("N", "x", vec![]); // no `val` prop → null
7468        let view = fx.view();
7469        // WHERE n.val + 1 > 0 — null propagates → false → no rows
7470        let rs = run(
7471            &view,
7472            "MATCH (n:N) WHERE n.val + 1 > 0 RETURN n",
7473            &BTreeMap::new(),
7474        )
7475        .unwrap();
7476        assert_eq!(rs.len(), 0, "null arithmetic must not match");
7477    }
7478
7479    /// Arithmetic in a WHERE comparison: `n.age + 1 > 5` filters correctly.
7480    #[test]
7481    fn case_when_expression_in_return() {
7482        let mut fx = Fx::new();
7483        fx.add("N", "a", vec![("id", s("a")), ("age", i(20))]);
7484        fx.add("N", "b", vec![("id", s("b")), ("age", i(65))]);
7485        let v = fx.view();
7486        let rs = run(
7487            &v,
7488            "MATCH (n:N) RETURN n.id AS id, \
7489             CASE WHEN n.age >= 65 THEN 'senior' ELSE 'other' END AS band",
7490            &BTreeMap::new(),
7491        )
7492        .unwrap();
7493        let band_of = |who: &str| {
7494            (0..rs.len())
7495                .find(|&i| rs.get(i, "id") == Some(&s(who)))
7496                .and_then(|i| rs.get(i, "band").cloned())
7497        };
7498        assert_eq!(band_of("a"), Some(s("other")));
7499        assert_eq!(band_of("b"), Some(s("senior")));
7500    }
7501
7502    #[test]
7503    fn case_when_no_else_yields_null() {
7504        let mut fx = Fx::new();
7505        fx.add("N", "a", vec![("age", i(20))]);
7506        let v = fx.view();
7507        let rs = run(
7508            &v,
7509            "MATCH (n:N) RETURN CASE WHEN n.age >= 65 THEN 'senior' END AS band",
7510            &BTreeMap::new(),
7511        )
7512        .unwrap();
7513        assert_eq!(rs.get(0, "band"), None);
7514    }
7515
7516    #[test]
7517    fn multi_relationship_type_pattern_matches_either() {
7518        let mut fx = Fx::new();
7519        let a = fx.add("N", "a", vec![("id", s("a"))]);
7520        let b = fx.add("N", "b", vec![("id", s("b"))]);
7521        let c = fx.add("N", "c", vec![("id", s("c"))]);
7522        let d = fx.add("N", "d", vec![("id", s("d"))]);
7523        fx.edge("KNOWS", a, b, vec![]);
7524        fx.edge("LIKES", a, c, vec![]);
7525        fx.edge("HATES", a, d, vec![]);
7526        let v = fx.view();
7527        // a -[:KNOWS|:LIKES]-> should reach b and c, not d.
7528        let rs = run(
7529            &v,
7530            "MATCH (a:N {id: 'a'})-[r:KNOWS|:LIKES]->(x) RETURN x",
7531            &BTreeMap::new(),
7532        )
7533        .unwrap();
7534        assert_eq!(rs.len(), 2, "KNOWS|LIKES reaches exactly b and c");
7535    }
7536
7537    #[test]
7538    fn collect_grouped_gathers_values_per_group() {
7539        let mut fx = Fx::new();
7540        fx.add("P", "a", vec![("city", s("austin")), ("name", s("Ann"))]);
7541        fx.add("P", "b", vec![("city", s("austin")), ("name", s("Bob"))]);
7542        fx.add("P", "c", vec![("city", s("boston")), ("name", s("Cy"))]);
7543        let v = fx.view();
7544        let rs = run(
7545            &v,
7546            "MATCH (n:P) RETURN n.city AS city, collect(n.name) AS names",
7547            &BTreeMap::new(),
7548        )
7549        .unwrap();
7550        assert_eq!(rs.len(), 2, "two city groups");
7551        // Locate the austin row and check its collected names.
7552        let austin = (0..rs.len())
7553            .find(|&i| rs.get(i, "city") == Some(&s("austin")))
7554            .expect("austin group present");
7555        assert_eq!(
7556            rs.get(austin, "names"),
7557            Some(&Value::List(vec![s("Ann"), s("Bob")]))
7558        );
7559    }
7560
7561    #[test]
7562    fn collect_ungrouped_gathers_all_into_one_list() {
7563        let mut fx = Fx::new();
7564        fx.add("P", "a", vec![("name", s("Ann"))]);
7565        fx.add("P", "b", vec![("name", s("Bob"))]);
7566        let v = fx.view();
7567        let rs = run(
7568            &v,
7569            "MATCH (n:P) RETURN collect(n.name) AS names",
7570            &BTreeMap::new(),
7571        )
7572        .unwrap();
7573        assert_eq!(rs.len(), 1);
7574        assert_eq!(
7575            rs.get(0, "names"),
7576            Some(&Value::List(vec![s("Ann"), s("Bob")]))
7577        );
7578    }
7579
7580    #[test]
7581    fn string_predicate_functions_in_where() {
7582        let mut fx = Fx::new();
7583        fx.add("N", "a", vec![("email", s("alice@acme.com"))]);
7584        fx.add("N", "b", vec![("email", s("bob@other.org"))]);
7585        let v = fx.view();
7586        let p = BTreeMap::new();
7587        assert_eq!(
7588            run(
7589                &v,
7590                "MATCH (n:N) WHERE endsWith(n.email, '.com') RETURN n",
7591                &p
7592            )
7593            .unwrap()
7594            .len(),
7595            1
7596        );
7597        assert_eq!(
7598            run(
7599                &v,
7600                "MATCH (n:N) WHERE startsWith(n.email, 'bob') RETURN n",
7601                &p
7602            )
7603            .unwrap()
7604            .len(),
7605            1
7606        );
7607        assert_eq!(
7608            run(
7609                &v,
7610                "MATCH (n:N) WHERE contains(n.email, 'acme') RETURN n",
7611                &p
7612            )
7613            .unwrap()
7614            .len(),
7615            1
7616        );
7617    }
7618
7619    #[test]
7620    fn coercion_functions_in_return() {
7621        let mut fx = Fx::new();
7622        fx.add("N", "a", vec![("s", s("42")), ("n", i(7)), ("g", f(3.9))]);
7623        let v = fx.view();
7624        let rs = run(
7625            &v,
7626            "MATCH (n:N) RETURN toInteger(n.s) AS ti, toFloat(n.n) AS tf, toString(n.g) AS ts",
7627            &BTreeMap::new(),
7628        )
7629        .unwrap();
7630        assert_eq!(rs.get(0, "ti"), Some(&Value::Int(42)));
7631        assert_eq!(rs.get(0, "tf"), Some(&Value::Float(7.0)));
7632        assert_eq!(rs.get(0, "ts"), Some(&Value::Str("3.9".into())));
7633    }
7634
7635    #[test]
7636    fn to_integer_unparseable_string_is_null() {
7637        let mut fx = Fx::new();
7638        fx.add("N", "a", vec![("s", s("not-a-number"))]);
7639        let v = fx.view();
7640        let rs = run(
7641            &v,
7642            "MATCH (n:N) RETURN toInteger(n.s) AS ti",
7643            &BTreeMap::new(),
7644        )
7645        .unwrap();
7646        assert_eq!(rs.get(0, "ti"), None);
7647    }
7648
7649    #[test]
7650    fn index_scan_uses_index_and_matches_fallback() {
7651        use core_storage::property_index::PropertyIndex;
7652        use std::sync::atomic::Ordering;
7653
7654        let mut fx = Fx::new();
7655        let a = fx.add("Person", "a", vec![("city", s("austin"))]);
7656        let _b = fx.add("Person", "b", vec![("city", s("boston"))]);
7657        let c = fx.add("Person", "c", vec![("city", s("austin"))]);
7658
7659        let mut pi = PropertyIndex::new();
7660        pi.enable("Person", "city");
7661        pi.set("Person", "city", a, &s("austin"));
7662        pi.set("Person", "city", 1, &s("boston"));
7663        pi.set("Person", "city", c, &s("austin"));
7664
7665        let q = "MATCH (n:Person {city: 'austin'}) RETURN n";
7666
7667        // Indexed view: the IndexScan fast path must fire and return both nodes.
7668        let before = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7669        let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7670        let after = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7671        assert!(after > before, "IndexScan must take the indexed path");
7672        assert_eq!(indexed.len(), 2);
7673
7674        // Unindexed view (prop_index None): same result via scan+filter fallback,
7675        // and the counter must NOT advance.
7676        let before2 = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7677        let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7678        let after2 = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7679        assert_eq!(after2, before2, "fallback must not touch the index counter");
7680        assert_eq!(
7681            fallback.len(),
7682            indexed.len(),
7683            "fallback matches indexed result"
7684        );
7685    }
7686
7687    #[test]
7688    fn arithmetic_in_where_comparison() {
7689        let mut fx = Fx::new();
7690        fx.add("Person", "alice", vec![("age", Value::Int(5))]); // 5+1=6 > 5 → match
7691        fx.add("Person", "bob", vec![("age", Value::Int(4))]); // 4+1=5 not > 5 → skip
7692        let view = fx.view();
7693        let rs = run(
7694            &view,
7695            "MATCH (n:Person) WHERE n.age + 1 > 5 RETURN n",
7696            &BTreeMap::new(),
7697        )
7698        .unwrap();
7699        assert_eq!(rs.len(), 1);
7700        assert_eq!(rs.get(0, "n"), Some(&s("alice")));
7701    }
7702
7703    // --- WHERE equality fold executor equivalence tests (T1) ---
7704
7705    /// Folded WHERE equality uses index when available; fallback returns same count.
7706    #[test]
7707    fn where_fold_indexed_matches_fallback() {
7708        use core_storage::property_index::PropertyIndex;
7709        use std::sync::atomic::Ordering;
7710
7711        let mut fx = Fx::new();
7712        let a = fx.add("Person", "alice", vec![("city", s("austin"))]);
7713        let b = fx.add("Person", "bob", vec![("city", s("boston"))]);
7714        let c = fx.add("Person", "carol", vec![("city", s("austin"))]);
7715
7716        let mut pi = PropertyIndex::new();
7717        pi.enable("Person", "city");
7718        pi.set("Person", "city", a, &s("austin"));
7719        pi.set("Person", "city", b, &s("boston"));
7720        pi.set("Person", "city", c, &s("austin"));
7721
7722        let q = "MATCH (n:Person) WHERE n.city = 'austin' RETURN n";
7723
7724        // Indexed path must fire and return both austin nodes.
7725        let before = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7726        let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7727        let after = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7728        assert!(
7729            after > before,
7730            "WHERE equality fold must take the indexed path"
7731        );
7732        assert_eq!(indexed.len(), 2);
7733
7734        // Fallback (no index) must return byte-identical rows in the same order.
7735        let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7736        assert_eq!(
7737            rows_of(&fallback),
7738            rows_of(&indexed),
7739            "fallback must return identical rows, not just the same count"
7740        );
7741    }
7742
7743    /// Folded WHERE equality that matches no nodes returns empty result.
7744    #[test]
7745    fn where_fold_miss_returns_empty() {
7746        let mut fx = Fx::new();
7747        fx.add("Person", "alice", vec![("city", s("austin"))]);
7748        let q = "MATCH (n:Person) WHERE n.city = 'berlin' RETURN n";
7749        let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7750        assert_eq!(rs.len(), 0);
7751    }
7752
7753    /// WHERE equality with a $param folds to IndexScan and uses the index.
7754    #[test]
7755    fn where_fold_param_uses_index() {
7756        use core_storage::property_index::PropertyIndex;
7757        use std::sync::atomic::Ordering;
7758
7759        let mut fx = Fx::new();
7760        let a = fx.add("Person", "alice", vec![("city", s("austin"))]);
7761        let b = fx.add("Person", "bob", vec![("city", s("boston"))]);
7762
7763        let mut pi = PropertyIndex::new();
7764        pi.enable("Person", "city");
7765        pi.set("Person", "city", a, &s("austin"));
7766        pi.set("Person", "city", b, &s("boston"));
7767
7768        let q = "MATCH (n:Person) WHERE n.city = $c RETURN n";
7769        let mut params = BTreeMap::new();
7770        params.insert("c".to_string(), s("austin"));
7771
7772        let before = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7773        let rs = run(&fx.view_indexed(&pi), q, &params).unwrap();
7774        let after = super::INDEX_SCAN_FIRES.load(Ordering::Relaxed);
7775        assert!(after > before, "$param WHERE equality must use index");
7776        assert_eq!(rs.len(), 1);
7777    }
7778
7779    /// Residual AND predicate is applied after the IndexScan fold.
7780    #[test]
7781    fn where_fold_residual_filter_applied() {
7782        let mut fx = Fx::new();
7783        fx.add(
7784            "Person",
7785            "young-austin",
7786            vec![("city", s("austin")), ("age", Value::Int(20))],
7787        );
7788        fx.add(
7789            "Person",
7790            "old-austin",
7791            vec![("city", s("austin")), ("age", Value::Int(40))],
7792        );
7793        fx.add(
7794            "Person",
7795            "boston",
7796            vec![("city", s("boston")), ("age", Value::Int(20))],
7797        );
7798
7799        let q = "MATCH (n:Person) WHERE n.city = 'austin' AND n.age > 30 RETURN n";
7800        let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7801        assert_eq!(rs.len(), 1, "only old-austin should match city+age filter");
7802        assert_eq!(rs.get(0, "n"), Some(&s("old-austin")));
7803    }
7804
7805    /// IndexScan for an unindexed field falls back to scan+filter; result is correct.
7806    /// Counter correctness for the fallback path is already covered by the
7807    /// canonical `index_scan_uses_index_and_matches_fallback` test; this test
7808    /// focuses on result equivalence when WHERE folds to IndexScan on a field
7809    /// that has no declared index.
7810    #[test]
7811    fn where_fold_unindexed_field_fallback() {
7812        let mut fx = Fx::new();
7813        fx.add("Person", "a", vec![("notindexed", s("x"))]);
7814        fx.add("Person", "b", vec![("notindexed", s("y"))]);
7815        let rs = run(
7816            &fx.view(),
7817            "MATCH (n:Person) WHERE n.notindexed = 'x' RETURN n",
7818            &BTreeMap::new(),
7819        )
7820        .unwrap();
7821        assert_eq!(rs.len(), 1);
7822        assert_eq!(rs.get(0, "n"), Some(&s("a")));
7823    }
7824
7825    // --- IndexIntersect executor equivalence tests (T2) ---
7826
7827    /// Both fields indexed: IndexIntersect fires and returns only the node matching both.
7828    #[test]
7829    fn index_intersect_both_indexed_fires() {
7830        use core_storage::property_index::PropertyIndex;
7831        use std::sync::atomic::Ordering;
7832
7833        let mut fx = Fx::new();
7834        // alice: city=austin, age=30 — both match
7835        let a = fx.add(
7836            "Person",
7837            "alice",
7838            vec![("city", s("austin")), ("age", Value::Int(30))],
7839        );
7840        // bob: city=austin, age=25 — city matches, age misses
7841        let b = fx.add(
7842            "Person",
7843            "bob",
7844            vec![("city", s("austin")), ("age", Value::Int(25))],
7845        );
7846        // carol: city=boston, age=30 — age matches, city misses
7847        let c = fx.add(
7848            "Person",
7849            "carol",
7850            vec![("city", s("boston")), ("age", Value::Int(30))],
7851        );
7852
7853        let mut pi = PropertyIndex::new();
7854        pi.enable("Person", "city");
7855        pi.enable("Person", "age");
7856        pi.set("Person", "city", a, &s("austin"));
7857        pi.set("Person", "city", b, &s("austin"));
7858        pi.set("Person", "city", c, &s("boston"));
7859        pi.set("Person", "age", a, &Value::Int(30));
7860        pi.set("Person", "age", b, &Value::Int(25));
7861        pi.set("Person", "age", c, &Value::Int(30));
7862
7863        let q = "MATCH (n:Person) WHERE n.city = 'austin' AND n.age = 30 RETURN n";
7864
7865        let before = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7866        let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7867        let after = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7868        assert!(
7869            after > before,
7870            "IndexIntersect must advance counter on indexed path"
7871        );
7872        assert_eq!(indexed.len(), 1);
7873        assert_eq!(indexed.get(0, "n"), Some(&s("alice")));
7874
7875        // Fallback (no index) returns the same row.
7876        let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7877        assert_eq!(
7878            rows_of(&fallback),
7879            rows_of(&indexed),
7880            "fallback must return identical rows"
7881        );
7882    }
7883
7884    /// One indexed field + one unindexed: indexed path fires; unindexed field is post-filtered.
7885    #[test]
7886    fn index_intersect_one_indexed_one_not() {
7887        use core_storage::property_index::PropertyIndex;
7888        use std::sync::atomic::Ordering;
7889
7890        let mut fx = Fx::new();
7891        let a = fx.add(
7892            "Person",
7893            "alice",
7894            vec![("city", s("austin")), ("role", s("eng"))],
7895        );
7896        let b = fx.add(
7897            "Person",
7898            "bob",
7899            vec![("city", s("austin")), ("role", s("mgr"))],
7900        );
7901        let _c = fx.add(
7902            "Person",
7903            "carol",
7904            vec![("city", s("boston")), ("role", s("eng"))],
7905        );
7906
7907        // Only city is indexed; role is not.
7908        let mut pi = PropertyIndex::new();
7909        pi.enable("Person", "city");
7910        pi.set("Person", "city", a, &s("austin"));
7911        pi.set("Person", "city", b, &s("austin"));
7912
7913        let q = "MATCH (n:Person) WHERE n.city = 'austin' AND n.role = 'eng' RETURN n";
7914
7915        let before = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7916        let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7917        let after = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
7918        assert!(
7919            after > before,
7920            "IndexIntersect must fire when at least one field is indexed"
7921        );
7922        assert_eq!(indexed.len(), 1);
7923        assert_eq!(indexed.get(0, "n"), Some(&s("alice")));
7924
7925        let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7926        assert_eq!(
7927            rows_of(&fallback),
7928            rows_of(&indexed),
7929            "fallback must return identical rows"
7930        );
7931    }
7932
7933    /// No indexed fields: full scan fallback returns the correct node.
7934    /// Counter isolation is not asserted here because parallel tests share the
7935    /// global INDEX_INTERSECT_FIRES atomic; the fires-on-indexed path is already
7936    /// covered by `index_intersect_both_indexed_fires`.
7937    #[test]
7938    fn index_intersect_no_indexed_fallback() {
7939        let mut fx = Fx::new();
7940        fx.add("Person", "alice", vec![("x", s("1")), ("y", s("a"))]);
7941        fx.add("Person", "bob", vec![("x", s("1")), ("y", s("b"))]);
7942        fx.add("Person", "carol", vec![("x", s("2")), ("y", s("a"))]);
7943
7944        let q = "MATCH (n:Person) WHERE n.x = '1' AND n.y = 'a' RETURN n";
7945        let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
7946        assert_eq!(rs.len(), 1);
7947        assert_eq!(rs.get(0, "n"), Some(&s("alice")));
7948    }
7949
7950    /// Two-field intersect with no nodes matching both returns empty.
7951    #[test]
7952    fn index_intersect_empty_intersection() {
7953        use core_storage::property_index::PropertyIndex;
7954
7955        let mut fx = Fx::new();
7956        let a = fx.add(
7957            "Person",
7958            "alice",
7959            vec![("city", s("austin")), ("age", Value::Int(30))],
7960        );
7961        let b = fx.add(
7962            "Person",
7963            "bob",
7964            vec![("city", s("boston")), ("age", Value::Int(25))],
7965        );
7966
7967        let mut pi = PropertyIndex::new();
7968        pi.enable("Person", "city");
7969        pi.enable("Person", "age");
7970        pi.set("Person", "city", a, &s("austin"));
7971        pi.set("Person", "city", b, &s("boston"));
7972        pi.set("Person", "age", a, &Value::Int(30));
7973        pi.set("Person", "age", b, &Value::Int(25));
7974
7975        // Requesting city=boston AND age=30 — no node has both.
7976        let q = "MATCH (n:Person) WHERE n.city = 'boston' AND n.age = 30 RETURN n";
7977        let rs = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
7978        assert_eq!(rs.len(), 0);
7979    }
7980
7981    /// IndexIntersect equality using $params resolves at runtime and fires correctly.
7982    #[test]
7983    fn index_intersect_with_params() {
7984        use core_storage::property_index::PropertyIndex;
7985        use std::sync::atomic::Ordering;
7986
7987        let mut fx = Fx::new();
7988        let a = fx.add(
7989            "Person",
7990            "alice",
7991            vec![("city", s("austin")), ("age", Value::Int(30))],
7992        );
7993        let b = fx.add(
7994            "Person",
7995            "bob",
7996            vec![("city", s("boston")), ("age", Value::Int(30))],
7997        );
7998
7999        let mut pi = PropertyIndex::new();
8000        pi.enable("Person", "city");
8001        pi.enable("Person", "age");
8002        pi.set("Person", "city", a, &s("austin"));
8003        pi.set("Person", "city", b, &s("boston"));
8004        pi.set("Person", "age", a, &Value::Int(30));
8005        pi.set("Person", "age", b, &Value::Int(30));
8006
8007        let q = "MATCH (n:Person) WHERE n.city = $c AND n.age = $a RETURN n";
8008        let mut params = BTreeMap::new();
8009        params.insert("c".to_string(), s("austin"));
8010        params.insert("a".to_string(), Value::Int(30));
8011
8012        let before = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
8013        let indexed = run(&fx.view_indexed(&pi), q, &params).unwrap();
8014        let after = super::INDEX_INTERSECT_FIRES.load(Ordering::Relaxed);
8015        assert!(after > before, "$param intersect must fire indexed path");
8016        assert_eq!(indexed.len(), 1);
8017        assert_eq!(indexed.get(0, "n"), Some(&s("alice")));
8018
8019        let fallback = run(&fx.view(), q, &params).unwrap();
8020        assert_eq!(
8021            rows_of(&fallback),
8022            rows_of(&indexed),
8023            "fallback must return identical rows"
8024        );
8025    }
8026
8027    /// Three-field intersect with two indexed fields returns the one node matching all three.
8028    #[test]
8029    fn index_intersect_three_fields() {
8030        use core_storage::property_index::PropertyIndex;
8031
8032        let mut fx = Fx::new();
8033        // alice: all three match
8034        let a = fx.add(
8035            "Person",
8036            "alice",
8037            vec![
8038                ("city", s("austin")),
8039                ("age", Value::Int(30)),
8040                ("role", s("eng")),
8041            ],
8042        );
8043        // bob: city+age match, role misses
8044        let b = fx.add(
8045            "Person",
8046            "bob",
8047            vec![
8048                ("city", s("austin")),
8049                ("age", Value::Int(30)),
8050                ("role", s("mgr")),
8051            ],
8052        );
8053
8054        let mut pi = PropertyIndex::new();
8055        pi.enable("Person", "city");
8056        pi.enable("Person", "age");
8057        // role intentionally not indexed
8058        pi.set("Person", "city", a, &s("austin"));
8059        pi.set("Person", "city", b, &s("austin"));
8060        pi.set("Person", "age", a, &Value::Int(30));
8061        pi.set("Person", "age", b, &Value::Int(30));
8062
8063        let q =
8064            "MATCH (n:Person) WHERE n.city = 'austin' AND n.age = 30 AND n.role = 'eng' RETURN n";
8065        let indexed = run(&fx.view_indexed(&pi), q, &BTreeMap::new()).unwrap();
8066        assert_eq!(indexed.len(), 1);
8067        assert_eq!(indexed.get(0, "n"), Some(&s("alice")));
8068
8069        let fallback = run(&fx.view(), q, &BTreeMap::new()).unwrap();
8070        assert_eq!(
8071            rows_of(&fallback),
8072            rows_of(&indexed),
8073            "fallback must return identical rows"
8074        );
8075    }
8076    // ── Task 17: the Cypher an assistant assumes exists ──────────────────────
8077    //
8078    // Every query below came out of a benchmark transcript where the agent
8079    // wrote it, got null or a parse error, and spent its turn budget
8080    // recovering. The fixtures are the association store's shape: talents
8081    // joined to companies by three rule-derived edge types.
8082
8083    /// Talents and companies joined by three edge types.
8084    ///
8085    /// `c1` is reached by all three from both published talents; `c2` by only
8086    /// two from `t1`; `c3` by all three but from a talent below the
8087    /// experience floor. An intersection query must return `c1` alone.
8088    fn assoc_graph() -> Fx {
8089        let mut fx = Fx::new();
8090        let t1 = fx.add(
8091            "Talent",
8092            "t1",
8093            vec![
8094                ("status", s("published")),
8095                ("years_of_experience", i(12)),
8096                (
8097                    "specialties",
8098                    Value::List(vec![s("hospitality"), s("retail")]),
8099                ),
8100                ("location", Value::List(vec![f(40.71), f(-74.01)])),
8101            ],
8102        );
8103        let t2 = fx.add(
8104            "Talent",
8105            "t2",
8106            vec![
8107                ("status", s("published")),
8108                ("years_of_experience", i(11)),
8109                ("location", Value::List(vec![f(41.88), f(-87.63)])),
8110            ],
8111        );
8112        let t3 = fx.add(
8113            "Talent",
8114            "t3",
8115            vec![("status", s("published")), ("years_of_experience", i(3))],
8116        );
8117        let c1 = fx.add("Company", "c1", vec![("name", s("Acme Design Works"))]);
8118        let c2 = fx.add("Company", "c2", vec![("name", s("Beta Studio"))]);
8119        let c3 = fx.add("Company", "c3", vec![("name", s("Gamma Works"))]);
8120        for (t, c) in [(t1, c1), (t2, c1), (t3, c3)] {
8121            fx.edge("INDUSTRY_ALIGNMENT", t, c, vec![]);
8122            fx.edge("SPECIALTY_MATCH", t, c, vec![]);
8123            fx.edge("LOCATION_FIT", t, c, vec![]);
8124        }
8125        // c2 is joined by two of the three types only.
8126        fx.edge("INDUSTRY_ALIGNMENT", t1, c2, vec![]);
8127        fx.edge("SPECIALTY_MATCH", t1, c2, vec![]);
8128        fx
8129    }
8130
8131    /// `n.key` is the node's key after a plain MATCH — it used to be null,
8132    /// and `RETURN c.key` is what every agent writes.
8133    #[test]
8134    fn node_key_reads_as_a_property() {
8135        let fx = assoc_graph();
8136        let rs = run(
8137            &fx.view(),
8138            "MATCH (n:Company) RETURN n.key",
8139            &BTreeMap::new(),
8140        )
8141        .unwrap();
8142        assert_eq!(
8143            rows_of(&rs),
8144            vec![
8145                vec![Some(s("c1"))],
8146                vec![Some(s("c2"))],
8147                vec![Some(s("c3"))]
8148            ]
8149        );
8150    }
8151
8152    /// The same after a WITH aggregation with a HAVING filter — the shape the
8153    /// benchmark's multihop cells died on, where the grouping key used to be
8154    /// flattened to a scalar and both `c.key` and `key(c)` stopped working.
8155    #[test]
8156    fn node_key_survives_a_with_aggregation() {
8157        let fx = assoc_graph();
8158        let rs = run(
8159            &fx.view(),
8160            "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company) \
8161             WITH c, count(*) AS n WHERE n >= 1 RETURN c.key, key(c), n",
8162            &BTreeMap::new(),
8163        )
8164        .unwrap();
8165        assert_eq!(
8166            rows_of(&rs),
8167            vec![
8168                vec![Some(s("c1")), Some(s("c1")), Some(i(2))],
8169                vec![Some(s("c2")), Some(s("c2")), Some(i(1))],
8170                vec![Some(s("c3")), Some(s("c3")), Some(i(1))],
8171            ]
8172        );
8173    }
8174
8175    /// A stored property named `key` wins over the node's identity.
8176    #[test]
8177    fn stored_key_property_wins_over_node_key() {
8178        let mut fx = Fx::new();
8179        fx.add("N", "a", vec![("key", s("stored"))]);
8180        let rs = run(&fx.view(), "MATCH (n:N) RETURN n.key", &BTreeMap::new()).unwrap();
8181        assert_eq!(rows_of(&rs), vec![vec![Some(s("stored"))]]);
8182    }
8183
8184    /// `n.id` / `id(n)` fall back to the id-map key when no stored `id` exists.
8185    #[test]
8186    fn n_id_falls_back_to_key_when_unstored() {
8187        let mut fx = Fx::new();
8188        fx.add("Person", "alice", vec![]);
8189        let rs = run(
8190            &fx.view(),
8191            "MATCH (n:Person) WHERE n.id = 'alice' RETURN n.id, id(n), n.key",
8192            &BTreeMap::new(),
8193        )
8194        .unwrap();
8195        assert_eq!(
8196            rows_of(&rs),
8197            vec![vec![Some(s("alice")), Some(s("alice")), Some(s("alice"))]]
8198        );
8199    }
8200
8201    /// A stored `id` property wins over the node's key, including in WHERE.
8202    ///
8203    /// Every assertion here also held before `n.id` resolved at all — an
8204    /// unstored `n.id` was null, so `WHERE n.id = 'k'` was empty for the wrong
8205    /// reason. What gives the test its teeth is the second node: `fallback`
8206    /// stores no `id`, so it is reachable only once `n.id` falls back to the
8207    /// key, and it must *not* be dragged in by the stored-wins hit.
8208    #[test]
8209    fn stored_id_property_wins_over_key() {
8210        let mut fx = Fx::new();
8211        fx.add("N", "k", vec![("id", s("other"))]);
8212        fx.add("N", "fallback", vec![]);
8213        let v = fx.view();
8214        let by_key = run(
8215            &v,
8216            "MATCH (n:N) WHERE n.id = 'fallback' RETURN key(n) AS k",
8217            &BTreeMap::new(),
8218        )
8219        .unwrap();
8220        assert_eq!(
8221            col(&by_key, "k"),
8222            vec![Some(s("fallback"))],
8223            "a node with no stored id is found by its key, and the stored-id \
8224             node is not swept in with it"
8225        );
8226        let miss = run(
8227            &v,
8228            "MATCH (n:N) WHERE n.id = 'k' RETURN n.id",
8229            &BTreeMap::new(),
8230        )
8231        .unwrap();
8232        assert!(
8233            miss.is_empty(),
8234            "stored id must win: WHERE n.id = key is empty"
8235        );
8236        let hit = run(
8237            &v,
8238            "MATCH (n:N) WHERE n.id = 'other' RETURN n.id",
8239            &BTreeMap::new(),
8240        )
8241        .unwrap();
8242        assert_eq!(rows_of(&hit), vec![vec![Some(s("other"))]]);
8243        let projected = run(&v, "MATCH (n:N) RETURN n.id AS i", &BTreeMap::new()).unwrap();
8244        let mut got: Vec<String> = col(&projected, "i")
8245            .into_iter()
8246            .map(|v| match v {
8247                Some(Value::Str(s)) => s,
8248                other => panic!("expected a string, got {other:?}"),
8249            })
8250            .collect();
8251        got.sort();
8252        assert_eq!(
8253            got,
8254            vec!["fallback".to_string(), "other".to_string()],
8255            "projection is stored-wins per node: the stored id for one, the \
8256             key fallback for the other"
8257        );
8258    }
8259
8260    /// `id()` on a non-node is a named error of the same class as `key()`.
8261    #[test]
8262    fn id_function_rejects_non_node() {
8263        let (fx, _, _) = single_edge();
8264        let v = fx.view();
8265        let key_err = run(&v, "MATCH (a)-[r:T]->(b) RETURN key(r)", &BTreeMap::new())
8266            .expect_err("key() on a relationship must error");
8267        let id_err = run(&v, "MATCH (a)-[r:T]->(b) RETURN id(r)", &BTreeMap::new())
8268            .expect_err("id() on a relationship must error");
8269        assert!(
8270            key_err.contains("not a node"),
8271            "key() error class: {key_err}"
8272        );
8273        assert!(id_err.contains("not a node"), "id() error class: {id_err}");
8274        assert!(
8275            id_err.contains("id()"),
8276            "id() error must name itself: {id_err}"
8277        );
8278    }
8279
8280    /// `WHERE n.id = lit` on an unstored id is a ScanKey, not a label scan.
8281    #[test]
8282    fn where_n_id_eq_literal_uses_scan_key() {
8283        let mut fx = Fx::new();
8284        fx.add("Person", "alice", vec![]);
8285        let fires_before = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
8286        let rs = run(
8287            &fx.view(),
8288            "MATCH (n:Person) WHERE n.id = 'alice' RETURN n",
8289            &BTreeMap::new(),
8290        )
8291        .unwrap();
8292        let fires_after = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
8293        assert!(
8294            fires_after > fires_before,
8295            "SCAN_KEY_FIRES must increment; before={fires_before} after={fires_after}"
8296        );
8297        assert_eq!(rows_of(&rs), vec![vec![Some(s("alice"))]]);
8298    }
8299
8300    /// `WHERE n.key = $k` uses ScanKey.
8301    #[test]
8302    fn where_n_key_eq_param_uses_scan_key() {
8303        let mut fx = Fx::new();
8304        fx.add("Person", "alice", vec![]);
8305        let mut params = BTreeMap::new();
8306        params.insert("k".to_string(), s("alice"));
8307        let fires_before = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
8308        let rs = run(
8309            &fx.view(),
8310            "MATCH (n:Person) WHERE n.key = $k RETURN n",
8311            &params,
8312        )
8313        .unwrap();
8314        let fires_after = super::SCAN_KEY_FIRES.load(std::sync::atomic::Ordering::Relaxed);
8315        assert!(
8316            fires_after > fires_before,
8317            "SCAN_KEY_FIRES must increment; before={fires_before} after={fires_after}"
8318        );
8319        assert_eq!(rows_of(&rs), vec![vec![Some(s("alice"))]]);
8320    }
8321
8322    /// `key(n)` is always the id-map key, never a stored property of that name.
8323    /// A node carrying `key = 'K'` as a property must NOT match `key(n) = 'K'`
8324    /// when its own key is something else — the identity-eq fold must not
8325    /// apply the `n.key` stored-wins rule to the function form.
8326    #[test]
8327    fn where_key_func_ignores_a_stored_key_property() {
8328        let mut fx = Fx::new();
8329        fx.add("N", "a", vec![("key", s("K"))]);
8330        fx.add("N", "K", vec![]);
8331        let rs = run(
8332            &fx.view(),
8333            "MATCH (n:N) WHERE key(n) = 'K' RETURN key(n) AS k",
8334            &BTreeMap::new(),
8335        )
8336        .unwrap();
8337        let got: Vec<Option<Value>> = col(&rs, "k");
8338        assert_eq!(
8339            got,
8340            vec![Some(s("K"))],
8341            "key(n) is the id-map key: only the node keyed K matches, not the \
8342             node whose stored `key` property is K"
8343        );
8344    }
8345
8346    /// `id(n)` is always the id-map key, so a stored `id` property must not
8347    /// hide a node from `WHERE id(n) = <its own key>`.
8348    #[test]
8349    fn where_id_func_ignores_a_stored_id_property() {
8350        let mut fx = Fx::new();
8351        fx.add("N", "k", vec![("id", s("other"))]);
8352        let rs = run(
8353            &fx.view(),
8354            "MATCH (n:N) WHERE id(n) = 'k' RETURN id(n) AS k",
8355            &BTreeMap::new(),
8356        )
8357        .unwrap();
8358        assert_eq!(
8359            col(&rs, "k"),
8360            vec![Some(s("k"))],
8361            "id(n) is the id-map key: a stored `id` property must not suppress \
8362             the match"
8363        );
8364    }
8365
8366    /// The property form is stored-wins, and the fold answers with the union:
8367    /// the node whose *stored* `id` is the value, plus the node whose *key* is
8368    /// the value and which stores no `id`. Spec §5.2.
8369    #[test]
8370    fn where_n_id_respects_stored_wins() {
8371        let mut fx = Fx::new();
8372        fx.add("N", "k", vec![("id", s("other"))]);
8373        fx.add("N", "other", vec![]);
8374        let rs = run(
8375            &fx.view(),
8376            "MATCH (n:N) WHERE n.id = 'other' RETURN key(n) AS k",
8377            &BTreeMap::new(),
8378        )
8379        .unwrap();
8380        let mut got: Vec<String> = col(&rs, "k")
8381            .into_iter()
8382            .map(|v| match v {
8383                Some(Value::Str(s)) => s,
8384                other => panic!("expected a string key, got {other:?}"),
8385            })
8386            .collect();
8387        got.sort();
8388        assert_eq!(
8389            got,
8390            vec!["k".to_string(), "other".to_string()],
8391            "stored-wins union: the stored-id node and the key-fallback node"
8392        );
8393    }
8394
8395    /// `labels(n)` returns the node's label list; `n.label` is the scalar
8396    /// spelling. `labels()` used to be an unknown function.
8397    #[test]
8398    fn labels_and_label_read_the_node_label() {
8399        let fx = assoc_graph();
8400        let rs = run(
8401            &fx.view(),
8402            "MATCH (n:Company) RETURN labels(n), n.label LIMIT 1",
8403            &BTreeMap::new(),
8404        )
8405        .unwrap();
8406        assert_eq!(
8407            rows_of(&rs),
8408            vec![vec![
8409                Some(Value::List(vec![s("Company")])),
8410                Some(s("Company"))
8411            ]]
8412        );
8413    }
8414
8415    /// An unknown function names itself in the error rather than reading null.
8416    #[test]
8417    fn unknown_function_is_a_named_error() {
8418        let fx = assoc_graph();
8419        let err = run(
8420            &fx.view(),
8421            "MATCH (n:Company) RETURN nodes(n)",
8422            &BTreeMap::new(),
8423        )
8424        .expect_err("unknown function must error");
8425        assert!(err.contains("unknown function `nodes`"), "{err}");
8426        assert!(
8427            err.contains("labels"),
8428            "error must list what is supported: {err}"
8429        );
8430    }
8431
8432    /// Infix `STARTS WITH` / `ENDS WITH` / `CONTAINS` — all three were parse
8433    /// errors, and each cost a round trip in the benchmark.
8434    #[test]
8435    fn infix_string_predicates_filter() {
8436        let fx = assoc_graph();
8437        let p = BTreeMap::new();
8438        for (q, want) in [
8439            (
8440                "MATCH (c:Company) WHERE c.name STARTS WITH 'Acme' RETURN c.key",
8441                vec!["c1"],
8442            ),
8443            (
8444                "MATCH (c:Company) WHERE c.name ENDS WITH 'Works' RETURN c.key",
8445                vec!["c1", "c3"],
8446            ),
8447            (
8448                "MATCH (c:Company) WHERE c.name CONTAINS 'Studio' RETURN c.key",
8449                vec!["c2"],
8450            ),
8451            (
8452                "MATCH (c:Company) WHERE NOT c.name CONTAINS 'Works' RETURN c.key",
8453                vec!["c2"],
8454            ),
8455        ] {
8456            let rs = run(&fx.view(), q, &p).unwrap_or_else(|e| panic!("{q}: {e}"));
8457            let got: Vec<String> = (0..rs.len())
8458                .map(|r| match rs.row(r)[0].clone() {
8459                    Some(Value::Str(k)) => k,
8460                    other => panic!("{q}: {other:?}"),
8461                })
8462                .collect();
8463            assert_eq!(got, want, "{q}");
8464        }
8465    }
8466
8467    /// A missing or non-string property makes an infix predicate false, not
8468    /// an error — same null handling as the `startsWith(a, b)` spelling.
8469    #[test]
8470    fn infix_string_predicate_on_missing_property_is_false() {
8471        let fx = assoc_graph();
8472        let rs = run(
8473            &fx.view(),
8474            "MATCH (t:Talent) WHERE t.name STARTS WITH 'x' RETURN t.key",
8475            &BTreeMap::new(),
8476        )
8477        .unwrap();
8478        assert_eq!(rows_of(&rs), Vec::<Vec<Option<Value>>>::new());
8479    }
8480
8481    /// `STARTS` without `WITH` is a named parse error, not a silent
8482    /// reinterpretation of the word as a variable.
8483    #[test]
8484    fn starts_without_with_is_a_named_error() {
8485        let err = parse(&lex("MATCH (c:Company) WHERE c.name STARTS 'Acme' RETURN c").unwrap())
8486            .expect_err("must not parse");
8487        assert!(err.contains("expected WITH after STARTS"), "{err}");
8488    }
8489
8490    /// `n.location[0]` — bracket indexing was a parse error, which forced the
8491    /// benchmark's geo cells to page the whole table and filter by hand.
8492    #[test]
8493    fn list_subscript_reads_one_element() {
8494        let fx = assoc_graph();
8495        let rs = run(
8496            &fx.view(),
8497            "MATCH (t:Talent) WHERE t.key = 't1' \
8498             RETURN t.location[0] AS lat, t.location[1] AS lon, \
8499                    t.location[-1] AS last, t.location[7] AS oob, t.status[0] AS notalist",
8500            &BTreeMap::new(),
8501        )
8502        .unwrap();
8503        assert_eq!(
8504            rows_of(&rs),
8505            vec![vec![
8506                Some(f(40.71)),
8507                Some(f(-74.01)),
8508                Some(f(-74.01)),
8509                None,
8510                None
8511            ]]
8512        );
8513    }
8514
8515    /// `n.key` filters, orders and matches — not only projects. Each of
8516    /// these goes down a different path in the executor (index-scan fallback,
8517    /// bounded pull scan, raw-row ORDER BY, inline pattern property).
8518    #[test]
8519    fn node_key_works_in_every_position() {
8520        let fx = assoc_graph();
8521        let p = BTreeMap::new();
8522        for (q, want) in [
8523            (
8524                "MATCH (c:Company) WHERE c.key = 'c2' RETURN c.key",
8525                vec!["c2"],
8526            ),
8527            (
8528                "MATCH (c:Company) WHERE c.key <> 'c1' RETURN c.key LIMIT 1",
8529                vec!["c2"],
8530            ),
8531            (
8532                "MATCH (c:Company) WITH c ORDER BY c.key DESC RETURN c.key LIMIT 1",
8533                vec!["c3"],
8534            ),
8535            ("MATCH (c:Company {key: 'c3'}) RETURN c.key", vec!["c3"]),
8536            (
8537                "MATCH (c:Company) WHERE c.label = 'Company' RETURN c.key LIMIT 1",
8538                vec!["c1"],
8539            ),
8540        ] {
8541            let rs = run(&fx.view(), q, &p).unwrap_or_else(|e| panic!("{q}: {e}"));
8542            let got: Vec<String> = (0..rs.len())
8543                .map(|r| match rs.row(r)[0].clone() {
8544                    Some(Value::Str(k)) => k,
8545                    other => panic!("{q}: {other:?}"),
8546                })
8547                .collect();
8548            assert_eq!(got, want, "{q}");
8549        }
8550    }
8551
8552    /// A subscript is usable in WHERE, not only in RETURN.
8553    #[test]
8554    fn list_subscript_filters() {
8555        let fx = assoc_graph();
8556        let rs = run(
8557            &fx.view(),
8558            "MATCH (t:Talent) WHERE t.location[0] > 41.0 RETURN t.key",
8559            &BTreeMap::new(),
8560        )
8561        .unwrap();
8562        assert_eq!(rows_of(&rs), vec![vec![Some(s("t2"))]]);
8563    }
8564
8565    /// `count(DISTINCT t)` counts each talent once however many rows it
8566    /// produced. Without it, an alternation or a multi-pattern match
8567    /// multiplies the count by the number of matching edge types.
8568    #[test]
8569    fn count_distinct_counts_each_binding_once() {
8570        let fx = assoc_graph();
8571        let q = "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT|:SPECIALTY_MATCH]->(c:Company) \
8572                 WITH c, count(t) AS raw, count(DISTINCT t) AS uniq WHERE raw >= 1 \
8573                 RETURN c.key, raw, uniq";
8574        let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
8575        assert_eq!(
8576            rows_of(&rs),
8577            vec![
8578                vec![Some(s("c1")), Some(i(4)), Some(i(2))],
8579                vec![Some(s("c2")), Some(i(2)), Some(i(1))],
8580                vec![Some(s("c3")), Some(i(2)), Some(i(1))],
8581            ]
8582        );
8583    }
8584
8585    /// `count(DISTINCT r)` on a **relationship** variable counts edges, not
8586    /// nothing.
8587    ///
8588    /// A relationship cell has no key, and the DISTINCT gate used to read
8589    /// that as "no value", which drops the row: the answer was `0` on a graph
8590    /// full of edges, and silently — no error, no null, a number that looks
8591    /// like an answer. An edge is identified by its type and its two
8592    /// endpoints, so on a graph where no pair is joined twice by one type
8593    /// `count(DISTINCT r)` is `count(r)`.
8594    #[test]
8595    fn count_distinct_on_a_relationship_counts_edges() {
8596        let fx = assoc_graph();
8597        let rs = run(
8598            &fx.view(),
8599            "MATCH (a)-[r]->(b) RETURN count(r) AS raw, count(DISTINCT r) AS uniq",
8600            &BTreeMap::new(),
8601        )
8602        .unwrap();
8603        let row = &rows_of(&rs)[0];
8604        assert_eq!(
8605            row[0], row[1],
8606            "no pair is joined twice by one type: {row:?}"
8607        );
8608        assert_ne!(row[1], Some(i(0)), "a graph full of edges counts them");
8609    }
8610
8611    /// And the same alternation that multiplies rows for a node variable
8612    /// still yields one key per edge, so `DISTINCT` over `r` is the edge
8613    /// count of the two types rather than the row count.
8614    #[test]
8615    fn count_distinct_on_a_relationship_survives_an_alternation() {
8616        let fx = assoc_graph();
8617        let rs = run(
8618            &fx.view(),
8619            "MATCH (t:Talent)-[r:INDUSTRY_ALIGNMENT|:SPECIALTY_MATCH]->(c:Company) \
8620             RETURN count(r) AS raw, count(DISTINCT r) AS uniq",
8621            &BTreeMap::new(),
8622        )
8623        .unwrap();
8624        let row = &rows_of(&rs)[0];
8625        assert_eq!(row[0], row[1], "every row bound a different edge: {row:?}");
8626    }
8627
8628    /// `collect(DISTINCT …)` dedupes the same way.
8629    #[test]
8630    fn collect_distinct_dedupes() {
8631        let fx = assoc_graph();
8632        let rs = run(
8633            &fx.view(),
8634            "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT|:SPECIALTY_MATCH]->(c:Company) \
8635             WHERE c.key = 'c1' WITH collect(DISTINCT t.status) AS st RETURN st",
8636            &BTreeMap::new(),
8637        )
8638        .unwrap();
8639        assert_eq!(
8640            rows_of(&rs),
8641            vec![vec![Some(Value::List(vec![s("published")]))]]
8642        );
8643    }
8644
8645    /// `DISTINCT *` is rejected at parse time rather than silently ignored.
8646    #[test]
8647    fn count_distinct_star_is_rejected() {
8648        let err =
8649            parse(&lex("MATCH (n) RETURN count(DISTINCT *)").unwrap()).expect_err("must not parse");
8650        assert!(
8651            err.contains("DISTINCT * is not a valid aggregate argument"),
8652            "{err}"
8653        );
8654    }
8655
8656    /// A variable actually named `distinct` still parses as an argument.
8657    #[test]
8658    fn distinct_is_still_usable_as_a_variable_name() {
8659        let q = parse(&lex("MATCH (distinct) RETURN count(distinct)").unwrap()).unwrap();
8660        assert_eq!(
8661            q.returns[0].value,
8662            RetVal::Agg {
8663                func: crate::cypher::ast::AggFunc::Count,
8664                arg: crate::cypher::ast::AggArg::Var("distinct".into()),
8665            }
8666        );
8667    }
8668
8669    /// Comma-separated patterns in one MATCH bind the same variables across
8670    /// every pattern — the intersection shape every multihop question needs.
8671    /// A company joined by only two of the three types must not survive.
8672    #[test]
8673    fn comma_patterns_intersect_on_shared_variables() {
8674        let fx = assoc_graph();
8675        let q = "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company), \
8676                       (t)-[:SPECIALTY_MATCH]->(c), \
8677                       (t)-[:LOCATION_FIT]->(c) \
8678                 WHERE t.status = 'published' AND t.years_of_experience >= 10 \
8679                 WITH c, count(DISTINCT t) AS n WHERE n >= 2 \
8680                 RETURN c.key, n ORDER BY n DESC";
8681        let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
8682        assert_eq!(rows_of(&rs), vec![vec![Some(s("c1")), Some(i(2))]]);
8683    }
8684
8685    /// The same query with the threshold dropped lists every company joined
8686    /// by all three types, and only those: `c2` (two types) is absent, `c3`
8687    /// (three types, under-experienced talent) is filtered by the WHERE.
8688    #[test]
8689    fn comma_patterns_exclude_partial_matches() {
8690        let fx = assoc_graph();
8691        let q = "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company), \
8692                       (t)-[:SPECIALTY_MATCH]->(c), \
8693                       (t)-[:LOCATION_FIT]->(c) \
8694                 WHERE t.years_of_experience >= 10 \
8695                 WITH c, count(DISTINCT t) AS n RETURN c.key, n";
8696        let rs = run(&fx.view(), q, &BTreeMap::new()).unwrap();
8697        assert_eq!(rows_of(&rs), vec![vec![Some(s("c1")), Some(i(2))]]);
8698    }
8699
8700    /// Comma-separated patterns and a run of separate MATCH clauses parse to
8701    /// the same query.
8702    #[test]
8703    fn comma_patterns_equal_separate_match_clauses() {
8704        let commas =
8705            parse(&lex("MATCH (a:A)-[:X]->(b:B), (a)-[:Y]->(b) RETURN a.key").unwrap()).unwrap();
8706        let clauses =
8707            parse(&lex("MATCH (a:A)-[:X]->(b:B) MATCH (a)-[:Y]->(b) RETURN a.key").unwrap())
8708                .unwrap();
8709        assert_eq!(commas.matches, clauses.matches);
8710    }
8711
8712    // ── Task 17 fix round 1 ──────────────────────────────────────────────
8713
8714    /// An aggregate `WITH` with no `WHERE` after it still projects what the
8715    /// RETURN asked for.
8716    ///
8717    /// The streaming group-aggregate path emits the group table itself and
8718    /// ignores every op after the aggregate but ORDER BY / SKIP / LIMIT, so a
8719    /// `Project` was silently dropped: this query came back as columns `c`
8720    /// and `n` carrying the group key and the raw count, with `n * 2` never
8721    /// evaluated. Adding a `WHERE` after the `WITH` routed it elsewhere and
8722    /// was correct, which is what made it look like a query-shape problem.
8723    #[test]
8724    fn aggregate_with_projects_without_a_having_clause() {
8725        let fx = assoc_graph();
8726        let rs = run(
8727            &fx.view(),
8728            "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company) \
8729             WITH c, count(t) AS n RETURN c.name, n * 2 AS dbl",
8730            &BTreeMap::new(),
8731        )
8732        .unwrap();
8733        assert_eq!(rs.columns(), ["c.name", "dbl"]);
8734        assert_eq!(
8735            rows_of(&rs),
8736            vec![
8737                vec![Some(s("Acme Design Works")), Some(i(4))],
8738                vec![Some(s("Beta Studio")), Some(i(2))],
8739                vec![Some(s("Gamma Works")), Some(i(2))],
8740            ]
8741        );
8742    }
8743
8744    /// The same, projecting `key(c)` — the columns are named for what was
8745    /// asked, not for the grouping variable.
8746    #[test]
8747    fn aggregate_with_names_its_projected_columns() {
8748        let fx = assoc_graph();
8749        let rs = run(
8750            &fx.view(),
8751            "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company) \
8752             WITH c, count(t) AS n RETURN key(c), n",
8753            &BTreeMap::new(),
8754        )
8755        .unwrap();
8756        assert_eq!(rs.columns(), ["key(c)", "n"]);
8757        assert_eq!(
8758            rows_of(&rs),
8759            vec![
8760                vec![Some(s("c1")), Some(i(2))],
8761                vec![Some(s("c2")), Some(i(1))],
8762                vec![Some(s("c3")), Some(i(1))],
8763            ]
8764        );
8765    }
8766
8767    /// `RETURN c` after an aggregate `WITH` still returns the key string, and
8768    /// only that column.
8769    #[test]
8770    fn aggregate_with_returning_the_bare_node_keeps_the_key() {
8771        let fx = assoc_graph();
8772        let rs = run(
8773            &fx.view(),
8774            "MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company) \
8775             WITH c, count(t) AS n RETURN c",
8776            &BTreeMap::new(),
8777        )
8778        .unwrap();
8779        assert_eq!(rs.columns(), ["c"]);
8780        assert_eq!(
8781            rows_of(&rs),
8782            vec![
8783                vec![Some(s("c1"))],
8784                vec![Some(s("c2"))],
8785                vec![Some(s("c3"))]
8786            ]
8787        );
8788    }
8789
8790    /// A plain aggregate with no `WITH` keeps the streaming path and its
8791    /// column names — the fix must not move it.
8792    #[test]
8793    fn a_plain_aggregate_is_unchanged() {
8794        let fx = assoc_graph();
8795        let rs = run(
8796            &fx.view(),
8797            "MATCH (c:Company) RETURN c.name, count(*)",
8798            &BTreeMap::new(),
8799        )
8800        .unwrap();
8801        assert_eq!(rs.columns(), ["c.name", "COUNT(*)"]);
8802        assert_eq!(rs.len(), 3);
8803    }
8804
8805    /// Two unaliased subscripts of the same list are two columns, each named
8806    /// for what it reads. They used to collide on `<expr>` and fail the
8807    /// planner's duplicate-column check before the query ever ran.
8808    #[test]
8809    fn two_subscripts_of_one_list_are_two_named_columns() {
8810        let fx = assoc_graph();
8811        let rs = run(
8812            &fx.view(),
8813            "MATCH (t:Talent) RETURN t.location[0], t.location[1]",
8814            &BTreeMap::new(),
8815        )
8816        .unwrap();
8817        assert_eq!(rs.columns(), ["t.location[0]", "t.location[1]"]);
8818        assert_eq!(
8819            rows_of(&rs),
8820            vec![
8821                vec![Some(f(40.71)), Some(f(-74.01))],
8822                vec![Some(f(41.88)), Some(f(-87.63))],
8823                // t3 has no location at all.
8824                vec![None, None],
8825            ]
8826        );
8827    }
8828
8829    /// A subscript carries through a `WITH` stage — the case that breaks if
8830    /// the name the executor projects and the one `collect_vars` interns ever
8831    /// disagree, both aliased and left to name itself.
8832    #[test]
8833    fn a_subscript_carries_through_a_with_stage() {
8834        let fx = assoc_graph();
8835        let rs = run(
8836            &fx.view(),
8837            "MATCH (t:Talent) WHERE t.location[0] > 41.0 \
8838             WITH t, t.location[0] AS lat, t.location[1] \
8839             RETURN key(t), lat, t.location[1]",
8840            &BTreeMap::new(),
8841        )
8842        .unwrap();
8843        assert_eq!(rs.columns(), ["key(t)", "lat", "t.location[1]"]);
8844        assert_eq!(
8845            rows_of(&rs),
8846            vec![vec![Some(s("t2")), Some(f(41.88)), Some(f(-87.63))]]
8847        );
8848    }
8849
8850    /// A subscript inside a function call is named the same way, and a
8851    /// non-literal index falls back to the operand's own label.
8852    #[test]
8853    fn subscript_column_names_cover_nested_and_computed_forms() {
8854        use crate::cypher::ast::operand_label;
8855        let prop = || Operand::Prop {
8856            var: "t".into(),
8857            field: "location".into(),
8858        };
8859        let at = |idx: Operand| Operand::Index {
8860            base: Box::new(prop()),
8861            index: Box::new(idx),
8862        };
8863        assert_eq!(operand_label(&at(Operand::Lit(i(0)))), "t.location[0]");
8864        assert_eq!(operand_label(&at(Operand::Lit(i(-1)))), "t.location[-1]");
8865        assert_eq!(
8866            operand_label(&at(Operand::Param("k".into()))),
8867            "t.location[$k]"
8868        );
8869        assert_eq!(
8870            operand_label(&at(Operand::Var("j".into()))),
8871            "t.location[j]"
8872        );
8873        assert_eq!(
8874            operand_label(&Operand::Index {
8875                base: Box::new(at(Operand::Lit(i(0)))),
8876                index: Box::new(Operand::Lit(i(1))),
8877            }),
8878            "t.location[0][1]"
8879        );
8880    }
8881
8882    // ── Task 17 fix round 2 ─────────────────────────────────────────────
8883    //
8884    // `WITH … WHERE …` filters *after* the projection, so an alias the WITH
8885    // introduces is in scope for it. The executor always did that; the
8886    // planner's pre-flight check scoped the WHERE to the pre-WITH variables
8887    // and rejected every such query before it ran.
8888
8889    /// An alias a non-aggregate `WITH` introduces is visible to its `WHERE`.
8890    #[test]
8891    fn a_with_alias_is_visible_to_its_where() {
8892        let fx = assoc_graph();
8893        let rs = run(
8894            &fx.view(),
8895            "MATCH (t:Talent) WITH t, t.years_of_experience AS x WHERE x > 11 \
8896             RETURN key(t), x",
8897            &BTreeMap::new(),
8898        )
8899        .unwrap();
8900        assert_eq!(rs.columns(), ["key(t)", "x"]);
8901        assert_eq!(rows_of(&rs), vec![vec![Some(s("t1")), Some(i(12))]]);
8902    }
8903
8904    /// …and to its `ORDER BY`, in the same clause.
8905    #[test]
8906    fn a_with_alias_is_visible_to_where_and_order_by_together() {
8907        let fx = assoc_graph();
8908        let rs = run(
8909            &fx.view(),
8910            "MATCH (t:Talent) WITH t, t.years_of_experience AS x WHERE x > 3 \
8911             ORDER BY x DESC RETURN key(t), x",
8912            &BTreeMap::new(),
8913        )
8914        .unwrap();
8915        assert_eq!(rs.columns(), ["key(t)", "x"]);
8916        assert_eq!(
8917            rows_of(&rs),
8918            vec![
8919                vec![Some(s("t1")), Some(i(12))],
8920                vec![Some(s("t2")), Some(i(11))],
8921            ]
8922        );
8923    }
8924
8925    /// A `WITH` that carries only the variable, with no alias at all, filters
8926    /// on the node's own properties. This always worked; it is pinned so the
8927    /// scoping change cannot quietly take it away.
8928    #[test]
8929    fn a_with_carrying_only_a_variable_still_filters_on_it() {
8930        let fx = assoc_graph();
8931        let rs = run(
8932            &fx.view(),
8933            "MATCH (t:Talent) WHERE t.status = 'published' \
8934             WITH t WHERE t.years_of_experience > 11 RETURN key(t)",
8935            &BTreeMap::new(),
8936        )
8937        .unwrap();
8938        assert_eq!(rs.columns(), ["key(t)"]);
8939        assert_eq!(rows_of(&rs), vec![vec![Some(s("t1"))]]);
8940    }
8941
8942    /// A `WITH` alias with no `WHERE` projects under its new name. Also
8943    /// always worked; pinned alongside the two above.
8944    #[test]
8945    fn a_with_alias_projects_under_its_new_name() {
8946        let fx = assoc_graph();
8947        let rs = run(
8948            &fx.view(),
8949            "MATCH (c:Company) WITH c, c.name AS nm RETURN nm",
8950            &BTreeMap::new(),
8951        )
8952        .unwrap();
8953        assert_eq!(rs.columns(), ["nm"]);
8954        assert_eq!(
8955            rows_of(&rs),
8956            vec![
8957                vec![Some(s("Acme Design Works"))],
8958                vec![Some(s("Beta Studio"))],
8959                vec![Some(s("Gamma Works"))],
8960            ]
8961        );
8962    }
8963
8964    /// Every alias shape a WITH can introduce is in scope for its WHERE: a
8965    /// property, an arithmetic expression, a subscript, a renamed node, and
8966    /// an alias that drops the node it came from.
8967    #[test]
8968    fn every_with_alias_shape_is_in_scope_for_the_where() {
8969        let fx = assoc_graph();
8970        let p = BTreeMap::new();
8971        for (q, want) in [
8972            (
8973                "MATCH (t:Talent) WITH t, t.years_of_experience + 1 AS x WHERE x > 12 \
8974                 RETURN key(t)",
8975                vec!["t1"],
8976            ),
8977            (
8978                "MATCH (t:Talent) WITH t, t.location[0] AS lat WHERE lat > 41.0 RETURN key(t)",
8979                vec!["t2"],
8980            ),
8981            (
8982                "MATCH (t:Talent) WITH t AS u WHERE u.years_of_experience > 11 RETURN key(u)",
8983                vec!["t1"],
8984            ),
8985            (
8986                "MATCH (t:Talent) WITH t.key AS k WHERE k = 't2' RETURN k",
8987                vec!["t2"],
8988            ),
8989        ] {
8990            let rs = run(&fx.view(), q, &p).unwrap_or_else(|e| panic!("{q}: {e}"));
8991            let got: Vec<String> = (0..rs.len())
8992                .map(|r| match rs.row(r)[0].clone() {
8993                    Some(Value::Str(k)) => k,
8994                    other => panic!("{q}: {other:?}"),
8995                })
8996                .collect();
8997            assert_eq!(got, want, "{q}");
8998        }
8999    }
9000
9001    /// A name that is neither in scope before the WITH nor introduced by it
9002    /// is still a named error — the scope widened, it did not disappear.
9003    #[test]
9004    fn an_unknown_name_in_a_with_where_is_still_an_error() {
9005        let err = plan(
9006            &parse(
9007                &lex(
9008                    "MATCH (t:Talent) WITH t, t.years_of_experience AS x WHERE nope > 1 \
9009                     RETURN key(t)",
9010                )
9011                .unwrap(),
9012            )
9013            .unwrap(),
9014        )
9015        .expect_err("must not plan");
9016        assert_eq!(err, "unbound variable `nope` in WHERE");
9017    }
9018
9019    /// Comma-separated patterns with no shared variable are a cartesian
9020    /// product, the openCypher meaning.
9021    #[test]
9022    fn comma_patterns_without_shared_vars_are_a_product() {
9023        let mut fx = Fx::new();
9024        fx.add("A", "a1", vec![]);
9025        fx.add("A", "a2", vec![]);
9026        fx.add("B", "b1", vec![]);
9027        let rs = run(
9028            &fx.view(),
9029            "MATCH (a:A), (b:B) RETURN a.key, b.key",
9030            &BTreeMap::new(),
9031        )
9032        .unwrap();
9033        assert_eq!(
9034            rows_of(&rs),
9035            vec![
9036                vec![Some(s("a1")), Some(s("b1"))],
9037                vec![Some(s("a2")), Some(s("b1"))],
9038            ]
9039        );
9040    }
9041}