Skip to main content

core_query/cypher/
exec.rs

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