Skip to main content

core_query/cypher/
plan.rs

1//! Cypher logical planner: `Query` → `Vec<PlanOp>`.
2//!
3//! Pure (no `GraphView`). Never panics. Bound-destination handling lives on
4//! `Expand` (see `PlanOp::Expand`); `JoinBound` is only emitted for the
5//! *start* node of a MATCH whose variable is already bound.
6
7use super::ast::{
8    AggArg, AggFunc, Expr, LimitSkip, NodePat, Operand, OptionalClause, OrderItem, OrderTarget,
9    Pattern, Query, RelDir, RelPat, RetItem, RetVal, UnwindExpr, WithStage,
10};
11use crate::filter::CmpOp;
12use std::collections::BTreeSet;
13
14/// One operator in the logical plan. Patterns compile left-to-right into
15/// scan / join / expand ops; WHERE is a single `Filter`; then `Project`,
16/// rewritten `OrderBy`, `Skip`, `Limit` in that order.
17#[derive(Debug, Clone, PartialEq)]
18pub enum PlanOp {
19    /// Seed rows from all nodes, or those with `label`.
20    ScanLabel {
21        var: String,
22        label: Option<String>,
23    },
24    /// Point lookup by IdMap key. Emitted when a MATCH node property map is
25    /// exactly one equality on field `id` (mixed maps stay ScanLabel+LookupProps).
26    ScanKey {
27        var: String,
28        key: Operand,
29        label: Option<String>,
30    },
31    /// Indexed equality lookup: seed rows from nodes of `label` whose scalar
32    /// `field` equals `value`. Emitted when a MATCH node property map is exactly
33    /// one equality on a non-`id` field. The executor uses the property index
34    /// when `(label, field)` is declared, else falls back to a scan+filter, so
35    /// this op is always correct regardless of whether the index exists.
36    IndexScan {
37        var: String,
38        label: Option<String>,
39        field: String,
40        value: Operand,
41    },
42    /// Compound indexed equality lookup: seed rows from nodes whose scalar
43    /// properties all satisfy two or more equalities. Emitted when a MATCH node
44    /// has ≥2 non-`id` scalar equalities (inline map, WHERE fold, or a mix).
45    ///
46    /// The executor resolves each `(field, value)` via `nodes_with_prop`:
47    /// indexed fields contribute a candidate list; unindexed fields become
48    /// per-node post-filters (`node_matches`). When ALL fields are unindexed it
49    /// falls back to a full label scan + filter — identical semantics to
50    /// `ScanLabel + LookupProps` but expressed as a single op for later passes.
51    /// `INDEX_INTERSECT_FIRES` advances only when at least one field is indexed.
52    IndexIntersect {
53        var: String,
54        label: Option<String>,
55        equalities: Vec<(String, Operand)>,
56    },
57    /// Retain rows whose `var` node matches the pattern-map props.
58    LookupProps {
59        var: String,
60        props: Vec<(String, Operand)>,
61    },
62    /// Expand from `from` along `etype`/`dir`, binding `rel_var` and `to`.
63    ///
64    /// Destination label/prop checks ride on this op (`to_label` / `to_props`).
65    /// If `to` is already bound in the row, the executor keeps only edges
66    /// that land on that bound id (JoinBound semantics *inside* Expand).
67    /// `rel_var` is always `Some` after planning: user name or `_rN`.
68    Expand {
69        from: String,
70        rel_var: Option<String>,
71        etypes: Vec<String>,
72        dir: RelDir,
73        to: String,
74        to_label: Option<String>,
75        to_props: Vec<(String, Operand)>,
76    },
77    /// Pattern-*start* node whose var is already bound: label/prop re-check only.
78    JoinBound {
79        var: String,
80        label: Option<String>,
81        props: Vec<(String, Operand)>,
82    },
83    Filter {
84        expr: Expr,
85    },
86    Project {
87        items: Vec<RetItem>,
88    },
89    /// Deduplicate projected rows (`RETURN DISTINCT`). Hashed with the same
90    /// numeric unify as `GroupAggregate`. Caps distinct rows at the
91    /// intermediate-row budget.
92    Distinct,
93    /// After `plan`, every item's `target` is `OrderTarget::Alias(column)`
94    /// where `column` is a projected column name. The executor resolves
95    /// ORDER BY against the post-Project table only.
96    OrderBy {
97        items: Vec<OrderItem>,
98    },
99    Skip(LimitSkip),
100    Limit(LimitSkip),
101    /// Single aggregate over all matched rows (no grouping).
102    ///
103    /// Execution routes to a streaming accumulator path (O(1) memory).
104    /// The 1 M intermediate-row budget does **not** apply: the accumulator
105    /// holds a single running value regardless of how many source rows exist.
106    ///
107    /// Null/non-numeric values in `arg` are silently skipped for SUM/AVG/MIN/MAX.
108    Aggregate {
109        func: AggFunc,
110        arg: AggArg,
111        /// Projected column name — alias if provided, else the canonical
112        /// function call string (`COUNT(*)`, `SUM(n.age)`, etc.).
113        column: String,
114    },
115    /// Grouped aggregation: one or more group-key items and one or more aggregate
116    /// functions, computed per distinct group.
117    ///
118    /// The executor streams through all matching rows, computing one
119    /// `Option<ValueKey>` per group-key item; `None` represents a null value,
120    /// and null keys group together (openCypher semantics).
121    ///
122    /// Group count is capped at 1,000,000; exceeding the cap is an error.
123    ///
124    /// `ORDER BY` / `SKIP` / `LIMIT` ops that follow in the plan apply to the
125    /// finished group table (sort the groups, then slice).  `row_bound()` always
126    /// returns `None` for plans containing this op so that LIMIT is never pushed
127    /// into producers.
128    GroupAggregate {
129        /// Non-aggregate RETURN items: `(projected_column_name, ret_item)`.
130        keys: Vec<(String, RetItem)>,
131        /// Aggregate RETURN items: `(func, arg, projected_column_name)`.
132        aggs: Vec<(AggFunc, AggArg, String)>,
133    },
134    /// Variable-length path expansion: BFS from `from`, emitting one row per
135    /// (start, end, depth) path found with `min ≤ depth ≤ max`.
136    ///
137    /// Per-path edge-uniqueness (Cypher relationship isomorphism): a single
138    /// path may not reuse the same edge (`EdgeRef`) twice; node revisits ARE
139    /// allowed.  `rel_var`, when present, is bound to a virtual path cell
140    /// whose sole accessible property is `length` (hop count as `Int`).
141    ///
142    /// Always executes via the **staged path** regardless of LIMIT.
143    /// The 1 M intermediate-row budget applies to the output row count.
144    VarExpand {
145        from: String,
146        rel_var: Option<String>,
147        etypes: Vec<String>,
148        dir: RelDir,
149        to: String,
150        min: u8,
151        max: u8,
152    },
153    /// Shortest path between two already-bound nodes via BFS.
154    ///
155    /// Both `from` and `to` must be bound in the current row before this op
156    /// executes.  BFS terminates at the first depth where `to` is reached.
157    /// If `to` is unreachable within `max_hops`, zero rows are emitted.
158    /// Exactly one row is emitted when a path exists.
159    ///
160    /// `rel_var`, when present, binds to a virtual path cell; `r.length`
161    /// yields the hop count as `Int`.
162    ///
163    /// Always executes via the **staged path** regardless of LIMIT.
164    ShortestPath {
165        from: String,
166        rel_var: Option<String>,
167        etypes: Vec<String>,
168        dir: RelDir,
169        to: String,
170        max_hops: u8,
171    },
172    /// Non-aggregate WITH: apply filter / order / skip / limit to the current
173    /// row set without projecting. Node bindings in the row survive as-is so
174    /// that subsequent MATCH clauses can join against them.
175    ///
176    /// Always executes via the **staged path** (row_bound returns None for any
177    /// plan containing this op).
178    With {
179        items: Vec<RetItem>,
180        where_expr: Option<Expr>,
181        order_by: Vec<OrderItem>,
182        skip: Option<LimitSkip>,
183        limit: Option<LimitSkip>,
184    },
185    /// UNWIND: expand each input row into N rows by iterating a list value.
186    ///
187    /// - `list: UnwindExpr::Lit(v)` → use a literal list.
188    /// - `list: UnwindExpr::Prop { var, field }` → resolve the list from a node property.
189    /// - `list: UnwindExpr::Var(name)` → look up a scalar binding from a prior WITH.
190    ///
191    /// null / empty list → 0 output rows (openCypher).
192    /// Non-list → named error at execution time.
193    ///
194    /// Always executes via the **staged path** (row_bound returns None).
195    Unwind {
196        expr: UnwindExpr,
197        alias: String,
198    },
199    /// OPTIONAL MATCH: left-outer-join semantics.
200    ///
201    /// For each input row the `inner` plan is executed in isolation.  If the
202    /// inner plan produces at least one output row, those rows replace the
203    /// input row (inner join semantics for the rows that match).  If the inner
204    /// plan produces **zero** rows, the input row survives with every variable
205    /// listed in `optional_vars` set to null (left-outer fallback).
206    ///
207    /// `optional_vars` lists the variables that are introduced inside the
208    /// optional pattern (i.e., the variables that must be nulled when the
209    /// pattern fails).  Variables that were already bound before the optional
210    /// clause are not listed here — they continue to hold their original values
211    /// in the null row.
212    ///
213    /// Always executes via the **staged path** (row_bound returns None).
214    LeftOuterApply {
215        inner: Vec<PlanOp>,
216        optional_vars: Vec<String>,
217    },
218}
219
220/// Compute the effective row bound for LIMIT push-down.
221///
222/// Returns `Some(SKIP + LIMIT)` when the plan can terminate producers early —
223/// that is, when the plan contains a `Limit` op **and no `OrderBy`** op.
224/// An `OrderBy` requires full materialisation before slicing, so the bound
225/// cannot be pushed past it.
226///
227/// Returns `None` when:
228/// - No `Limit` op is present, or
229/// - An `OrderBy` op is present (sorting needs every row).
230///
231/// # Decision table
232///
233/// | Plan shape (tail before Project)           | push-down? | note                                         |
234/// |--------------------------------------------|------------|----------------------------------------------|
235/// | Scan / Expand → Project → Limit            | YES        | pull-based; all stages stop at bound         |
236/// | Scan / Expand → Filter → Project → Limit   | YES        | pull-based; Filter + earlier stages all stop |
237/// | … → OrderBy → … Limit                      | NO         | sort requires all rows first                 |
238///
239/// When `row_bound` returns `Some`, the executor uses a demand-driven
240/// (pull-based) strategy: **all** producer stages (Scan, Expand, Filter, …)
241/// terminate as soon as `bound` final rows have been collected.  No
242/// intermediate table is ever fully materialised for the bounded path.
243///
244/// `SKIP + LIMIT` is used instead of plain `LIMIT` so that there are enough
245/// rows to apply the `SKIP` offset and still yield `LIMIT` final rows.
246/// Saturating addition is used to guard against pathological large values.
247pub fn row_bound(ops: &[PlanOp]) -> Option<usize> {
248    // ORDER BY and DISTINCT require full materialisation — bound cannot be pushed.
249    if ops
250        .iter()
251        .any(|op| matches!(op, PlanOp::OrderBy { .. } | PlanOp::Distinct))
252    {
253        return None;
254    }
255    // Aggregate plans use the streaming accumulator path, not the pull path.
256    if ops.iter().any(|op| matches!(op, PlanOp::Aggregate { .. })) {
257        return None;
258    }
259    // GroupAggregate plans are a sink over the full row stream; ORDER BY and LIMIT
260    // apply to the finished group table, never to producers.
261    if ops
262        .iter()
263        .any(|op| matches!(op, PlanOp::GroupAggregate { .. }))
264    {
265        return None;
266    }
267    // VarExpand / ShortestPath always use the staged path so that the 1M row
268    // budget applies and BFS state is cleanly managed stage-by-stage.
269    if ops
270        .iter()
271        .any(|op| matches!(op, PlanOp::VarExpand { .. } | PlanOp::ShortestPath { .. }))
272    {
273        return None;
274    }
275    // Pipeline plans (WITH / UNWIND / LeftOuterApply) always use the staged
276    // path so that intermediate rows are correctly bounded and sequenced.
277    if ops.iter().any(|op| {
278        matches!(
279            op,
280            PlanOp::With { .. } | PlanOp::Unwind { .. } | PlanOp::LeftOuterApply { .. }
281        )
282    }) {
283        return None;
284    }
285    let limit_n = ops.iter().rev().find_map(|op| match op {
286        PlanOp::Limit(LimitSkip::Exact(n)) => Some(*n),
287        PlanOp::Limit(LimitSkip::Param(_)) => None, // param-limit: can't determine bound statically
288        _ => None,
289    })?;
290    // A param Skip means the skip count is unknown at plan time — force staged.
291    if ops
292        .iter()
293        .any(|op| matches!(op, PlanOp::Skip(LimitSkip::Param(_))))
294    {
295        return None;
296    }
297    let skip_n = ops
298        .iter()
299        .rev()
300        .find_map(|op| match op {
301            PlanOp::Skip(LimitSkip::Exact(n)) => Some(*n),
302            _ => None,
303        })
304        .unwrap_or(0);
305    Some((skip_n as usize).saturating_add(limit_n as usize))
306}
307
308/// Returns `true` when the plan shape is supported by `subscribe_query`.
309///
310/// Allowlisted shapes (documented subset — not full Cypher):
311///   • `MATCH (n:Label) WHERE … RETURN …`
312///     → ScanLabel or ScanKey, optional LookupProps, optional Filter, Project,
313///       optional Limit.
314///   • `MATCH (a)-[r:TYPE]->(b) RETURN …`
315///     → ScanLabel or ScanKey, exactly one Expand, optional Filter, Project,
316///       optional Limit.
317///
318/// Everything else is rejected: SKIP (creates unstable offset windows), multi-hop
319/// Expand chains, ORDER BY, DISTINCT, aggregates, variable-length paths, OPTIONAL
320/// MATCH, WITH, UNWIND, JoinBound (multi-MATCH). Use LIMIT to bound re-execution
321/// cost (`subscribe_query` does a full re-run per commit).
322pub fn is_subscribable(ops: &[PlanOp]) -> bool {
323    // All ops must be from the allowlisted set. Skip is excluded: SKIP N shifts
324    // the result window on every commit, causing spurious Added/Removed churn for
325    // rows whose data never changed.
326    ops.iter().all(|op| {
327        matches!(
328            op,
329            PlanOp::ScanLabel { .. }
330                | PlanOp::ScanKey { .. }
331                | PlanOp::IndexScan { .. }
332                | PlanOp::IndexIntersect { .. }
333                | PlanOp::LookupProps { .. }
334                | PlanOp::Expand { .. }
335                | PlanOp::Filter { .. }
336                | PlanOp::Project { .. }
337                | PlanOp::Limit(_)
338        )
339    })
340    // At least one scan.
341    && ops.iter().any(|op| {
342        matches!(
343            op,
344            PlanOp::ScanLabel { .. }
345                | PlanOp::ScanKey { .. }
346                | PlanOp::IndexScan { .. }
347                | PlanOp::IndexIntersect { .. }
348        )
349    })
350    // Exactly one Project (ensures it is a RETURN query).
351    && ops.iter().any(|op| matches!(op, PlanOp::Project { .. }))
352    // At most one Expand: multi-hop chains are outside the documented subset.
353    && ops
354        .iter()
355        .filter(|op| matches!(op, PlanOp::Expand { .. }))
356        .count()
357        <= 1
358}
359
360/// Compile `q` into a logical plan. Errors are contextual `String`s; never panics.
361pub fn plan(q: &Query) -> Result<Vec<PlanOp>, String> {
362    let mut bound = BTreeSet::new();
363    let mut rel_bound = BTreeSet::new();
364    let mut ops = Vec::new();
365    let mut node_anon = 0u32;
366    let mut rel_anon = 0u32;
367
368    for pat in &q.matches {
369        compile_pattern(
370            pat,
371            &mut ops,
372            &mut bound,
373            &mut rel_bound,
374            &mut node_anon,
375            &mut rel_anon,
376        )?;
377    }
378
379    // OPTIONAL MATCH clauses (after required MATCHes).
380    for oc in &q.optional_clauses {
381        compile_optional_clause(
382            oc,
383            &mut ops,
384            &mut bound,
385            &mut rel_bound,
386            &mut node_anon,
387            &mut rel_anon,
388        )?;
389    }
390
391    // Top-level UNWIND clauses.
392    for uw in &q.unwinds {
393        check_unwind_bound(&uw.list, &bound)?;
394        bound.insert(uw.alias.clone());
395        ops.push(PlanOp::Unwind {
396            expr: uw.list.clone(),
397            alias: uw.alias.clone(),
398        });
399    }
400
401    if let Some(expr) = &q.where_expr {
402        check_expr_bound(expr, &bound)?;
403        ops.push(PlanOp::Filter { expr: expr.clone() });
404    }
405    // Fold WHERE single-equality predicates into IndexScan ops before any
406    // aggregate or project ops are appended.
407    ops = fold_where_equalities(ops);
408
409    // Post-UNWIND WHERE: filter expanded rows using UNWIND alias bindings.
410    if let Some(expr) = &q.post_unwind_where {
411        check_expr_bound(expr, &bound)?;
412        ops.push(PlanOp::Filter { expr: expr.clone() });
413    }
414
415    // WITH pipeline stages.
416    for stage in &q.stages {
417        compile_with_stage(
418            stage,
419            &mut ops,
420            &mut bound,
421            &mut rel_bound,
422            &mut node_anon,
423            &mut rel_anon,
424        )?;
425    }
426
427    check_return_bound(&q.returns, &bound, &rel_bound)?;
428    check_duplicate_aliases(&q.returns)?;
429    check_duplicate_columns(&q.returns)?;
430    if q.distinct
431        && q.returns
432            .iter()
433            .any(|r| matches!(&r.value, RetVal::Agg { .. }))
434    {
435        return Err(
436            "RETURN DISTINCT is not supported with aggregate functions; use grouping".to_string(),
437        );
438    }
439
440    // Detect aggregate vs non-aggregate items in RETURN.
441    // For pipeline plans (with stages or top-level UNWIND), single-aggregate
442    // path is not used — route to GroupAggregate or Project.
443    let is_pipeline = !q.stages.is_empty()
444        || !q.unwinds.is_empty()
445        || q.post_unwind_where.is_some()
446        || !q.optional_clauses.is_empty();
447    let agg_count = q
448        .returns
449        .iter()
450        .filter(|r| matches!(&r.value, RetVal::Agg { .. }))
451        .count();
452
453    if agg_count == 1 && q.returns.len() == 1 && !is_pipeline {
454        // Single-aggregate fast path: streaming O(1) accumulator, no grouping.
455        let item = &q.returns[0];
456        let (func, arg) = match &item.value {
457            RetVal::Agg { func, arg } => (func.clone(), arg.clone()),
458            _ => unreachable!(),
459        };
460        // Validate: SUM/AVG/MIN/MAX require a Prop arg, not Star.
461        if let (AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max, AggArg::Star) =
462            (&func, &arg)
463        {
464            return Err(format!(
465                "{name} does not accept '*'; use a property expression like `{name}(n.prop)`",
466                name = func_name(&func),
467            ));
468        }
469        let column = item
470            .alias
471            .clone()
472            .unwrap_or_else(|| agg_column_name(&func, &arg));
473        ops.push(PlanOp::Aggregate { func, arg, column });
474        // ORDER BY and LIMIT/SKIP are ignored for single-aggregate queries
475        // (always returns exactly one row).
476        return Ok(ops);
477    }
478
479    if agg_count > 0 {
480        // GroupAggregate: handles grouped (mix of key items and aggregates) as
481        // well as multi-aggregate-no-keys (all RETURN items are aggregates).
482        let mut keys: Vec<(String, RetItem)> = Vec::new();
483        let mut aggs: Vec<(AggFunc, AggArg, String)> = Vec::new();
484        for item in &q.returns {
485            match &item.value {
486                RetVal::Agg { func, arg } => {
487                    // Validate: SUM/AVG/MIN/MAX require a Prop arg, not Star.
488                    if let (
489                        AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max,
490                        AggArg::Star,
491                    ) = (func, arg)
492                    {
493                        return Err(format!(
494                            "{name} does not accept '*'; use a property expression like `{name}(n.prop)`",
495                            name = func_name(func),
496                        ));
497                    }
498                    let column = item
499                        .alias
500                        .clone()
501                        .unwrap_or_else(|| agg_column_name(func, arg));
502                    aggs.push((func.clone(), arg.clone(), column));
503                }
504                _ => {
505                    keys.push((column_name(item), item.clone()));
506                }
507            }
508        }
509        ops.push(PlanOp::GroupAggregate { keys, aggs });
510        // ORDER BY + SKIP + LIMIT apply to the finished group result table.
511        if !q.order_by.is_empty() {
512            let mut items = Vec::with_capacity(q.order_by.len());
513            for item in &q.order_by {
514                items.push(rewrite_order_item(item, &q.returns, &bound, &rel_bound)?);
515            }
516            ops.push(PlanOp::OrderBy { items });
517        }
518        if let Some(ls) = &q.skip {
519            ops.push(PlanOp::Skip(ls.clone()));
520        }
521        if let Some(ls) = &q.limit {
522            ops.push(PlanOp::Limit(ls.clone()));
523        }
524        return Ok(ops);
525    }
526
527    ops.push(PlanOp::Project {
528        items: q.returns.clone(),
529    });
530    if q.distinct {
531        ops.push(PlanOp::Distinct);
532    }
533
534    if !q.order_by.is_empty() {
535        let mut items = Vec::with_capacity(q.order_by.len());
536        for item in &q.order_by {
537            items.push(rewrite_order_item(item, &q.returns, &bound, &rel_bound)?);
538        }
539        ops.push(PlanOp::OrderBy { items });
540    }
541
542    if let Some(ls) = &q.skip {
543        ops.push(PlanOp::Skip(ls.clone()));
544    }
545    if let Some(ls) = &q.limit {
546        ops.push(PlanOp::Limit(ls.clone()));
547    }
548
549    Ok(ops)
550}
551
552/// Compile one WITH pipeline stage.
553fn compile_with_stage(
554    stage: &WithStage,
555    ops: &mut Vec<PlanOp>,
556    bound: &mut BTreeSet<String>,
557    rel_bound: &mut BTreeSet<String>,
558    node_anon: &mut u32,
559    rel_anon: &mut u32,
560) -> Result<(), String> {
561    let agg_count = stage
562        .items
563        .iter()
564        .filter(|r| matches!(&r.value, RetVal::Agg { .. }))
565        .count();
566
567    if agg_count > 0 {
568        // Aggregate WITH → compile GroupAggregate + optional Filter/OrderBy/Skip/Limit.
569        let mut keys: Vec<(String, RetItem)> = Vec::new();
570        let mut aggs: Vec<(AggFunc, AggArg, String)> = Vec::new();
571        for item in &stage.items {
572            match &item.value {
573                RetVal::Agg { func, arg } => {
574                    if let (
575                        AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max,
576                        AggArg::Star,
577                    ) = (func, arg)
578                    {
579                        return Err(format!(
580                            "{name} does not accept '*'; use a property expression like `{name}(n.prop)`",
581                            name = func_name(func),
582                        ));
583                    }
584                    let col = item
585                        .alias
586                        .clone()
587                        .unwrap_or_else(|| agg_column_name(func, arg));
588                    aggs.push((func.clone(), arg.clone(), col));
589                }
590                _ => {
591                    keys.push((column_name(item), item.clone()));
592                }
593            }
594        }
595        ops.push(PlanOp::GroupAggregate {
596            keys: keys.clone(),
597            aggs: aggs.clone(),
598        });
599
600        // Update bound to reflect only what GroupAggregate outputs.
601        bound.clear();
602        rel_bound.clear();
603        for (col, _) in &keys {
604            bound.insert(col.clone());
605        }
606        for (_, _, col) in &aggs {
607            bound.insert(col.clone());
608        }
609
610        // Optional HAVING filter (WHERE after WITH with aggregates).
611        if let Some(expr) = &stage.where_expr {
612            check_expr_bound(expr, bound)?;
613            ops.push(PlanOp::Filter { expr: expr.clone() });
614        }
615        // ORDER BY on the group result rows — validate targets against GroupAggregate output.
616        // `bound` was updated above (lines 435–442) to hold only group output columns.
617        if !stage.order_by.is_empty() {
618            for item in &stage.order_by {
619                match &item.target {
620                    OrderTarget::Prop { var, .. } | OrderTarget::Var(var) => {
621                        require_bound(var, bound, "ORDER BY in aggregate WITH")?;
622                    }
623                    OrderTarget::Alias(name) => {
624                        require_bound(name, bound, "ORDER BY in aggregate WITH")?;
625                    }
626                }
627            }
628            ops.push(PlanOp::OrderBy {
629                items: stage.order_by.clone(),
630            });
631        }
632        if let Some(ls) = &stage.skip {
633            ops.push(PlanOp::Skip(ls.clone()));
634        }
635        if let Some(ls) = &stage.limit {
636            ops.push(PlanOp::Limit(ls.clone()));
637        }
638    } else {
639        // Non-aggregate WITH → validate items and emit PlanOp::With.
640        check_return_bound(&stage.items, bound, rel_bound)?;
641
642        // Optional WHERE filter on the current (pre-WITH) rows.
643        // This also handles bare-variable operands (Operand::Var) referencing
644        // scalar aliases produced by earlier stages.
645        if let Some(expr) = &stage.where_expr {
646            check_expr_bound(expr, bound)?;
647        }
648        // ORDER BY items reference either var names or prop paths — no rewrite needed
649        // here; exec_order_by_rows handles raw row ordering.
650        // ORDER BY may reference the WITH output columns (aliases) in addition to
651        // variables already in scope before the WITH.
652        let with_col_names: BTreeSet<String> = stage.items.iter().map(column_name).collect();
653        for item in &stage.order_by {
654            match &item.target {
655                OrderTarget::Prop { var, .. } | OrderTarget::Var(var) => {
656                    if !bound.contains(var.as_str()) && !with_col_names.contains(var.as_str()) {
657                        return Err(format!("unbound variable `{var}` in ORDER BY in WITH"));
658                    }
659                }
660                OrderTarget::Alias(name) => {
661                    if !bound.contains(name.as_str()) && !with_col_names.contains(name.as_str()) {
662                        return Err(format!("unbound variable `{name}` in ORDER BY in WITH"));
663                    }
664                }
665            }
666        }
667        ops.push(PlanOp::With {
668            items: stage.items.clone(),
669            where_expr: stage.where_expr.clone(),
670            order_by: stage.order_by.clone(),
671            skip: stage.skip.clone(),
672            limit: stage.limit.clone(),
673        });
674
675        // Update bound: after non-aggregate WITH, only the WITH items survive.
676        let mut new_bound: BTreeSet<String> = BTreeSet::new();
677        let mut new_rel_bound: BTreeSet<String> = BTreeSet::new();
678        for item in &stage.items {
679            let col = column_name(item);
680            new_bound.insert(col.clone());
681            // Preserve rel-bound status for relationship variables carried through.
682            match &item.value {
683                RetVal::Var(v) if rel_bound.contains(v.as_str()) => {
684                    new_rel_bound.insert(col);
685                }
686                _ => {}
687            }
688        }
689        *bound = new_bound;
690        *rel_bound = new_rel_bound;
691    }
692
693    // MATCH clauses that follow this WITH.
694    for pat in &stage.matches {
695        compile_pattern(pat, ops, bound, rel_bound, node_anon, rel_anon)?;
696    }
697    // OPTIONAL MATCH clauses that follow those MATCHes.
698    for oc in &stage.optional_clauses {
699        compile_optional_clause(oc, ops, bound, rel_bound, node_anon, rel_anon)?;
700    }
701    // UNWIND clauses that follow this WITH.
702    for uw in &stage.unwinds {
703        check_unwind_bound(&uw.list, bound)?;
704        bound.insert(uw.alias.clone());
705        ops.push(PlanOp::Unwind {
706            expr: uw.list.clone(),
707            alias: uw.alias.clone(),
708        });
709    }
710    // WHERE that follows those MATCHes.
711    if let Some(expr) = &stage.post_where {
712        check_expr_bound(expr, bound)?;
713        ops.push(PlanOp::Filter { expr: expr.clone() });
714    }
715
716    Ok(())
717}
718
719fn id_lookup(props: &[(String, Operand)]) -> Option<&Operand> {
720    if props.len() == 1 && props[0].0 == "id" {
721        Some(&props[0].1)
722    } else {
723        None
724    }
725}
726
727/// A single equality on a non-`id` field with a literal or `$param` value —
728/// the shape eligible for an `IndexScan`. Returns `(field, value)`.
729fn index_lookup(props: &[(String, Operand)]) -> Option<(&str, &Operand)> {
730    if props.len() == 1
731        && props[0].0 != "id"
732        && matches!(props[0].1, Operand::Lit(_) | Operand::Param(_))
733    {
734        Some((props[0].0.as_str(), &props[0].1))
735    } else {
736        None
737    }
738}
739
740/// Two or more non-`id` equalities all with literal or `$param` values —
741/// the shape eligible for `IndexIntersect`. Returns the full equality list
742/// when ALL props qualify (no `id` field, all Lit|Param operands, len ≥ 2).
743fn multi_index_lookup(props: &[(String, Operand)]) -> Option<Vec<(String, Operand)>> {
744    if props.len() < 2 {
745        return None;
746    }
747    if props
748        .iter()
749        .any(|(f, v)| f == "id" || !matches!(v, Operand::Lit(_) | Operand::Param(_)))
750    {
751        return None;
752    }
753    Some(props.to_vec())
754}
755
756/// Split an `Expr::And` chain into a flat list of sub-expressions. Used by the
757/// WHERE-equality fold pass (T1) and compound-equality folding (T2).
758pub(super) fn split_and(expr: Expr) -> Vec<Expr> {
759    match expr {
760        Expr::And(l, r) => {
761            let mut v = split_and(*l);
762            v.extend(split_and(*r));
763            v
764        }
765        other => vec![other],
766    }
767}
768
769/// Reassemble a flat list of expressions into an `Expr::And` chain.
770/// Returns `None` when the list is empty (the caller must drop the Filter op).
771pub(super) fn join_and(mut exprs: Vec<Expr>) -> Option<Expr> {
772    if exprs.is_empty() {
773        return None;
774    }
775    let mut result = exprs.remove(0);
776    for e in exprs {
777        result = Expr::And(Box::new(result), Box::new(e));
778    }
779    Some(result)
780}
781
782/// Post-pass: fold WHERE-clause single-equality predicates into `IndexScan` ops.
783///
784/// Eligibility (conservative):
785/// - The anchoring scan must be `ScanLabel` — `ScanKey` is already O(1);
786///   `IndexScan` already holds one equality (T2 handles compound).
787/// - No `Expand` op may appear between the `ScanLabel` and the `Filter` being
788///   folded: the conservative rule avoids cross-expand pushdown for now.
789/// - The Filter must contain at least one `Cmp{Prop{var==scan_var,field}, Eq,
790///   Lit|Param}` term. Only the first such term is taken; the rest stay as a
791///   residual `Filter` for T2 / the executor to handle.
792///
793/// Folding is always correct regardless of whether the field is indexed:
794/// the `IndexScan` executor arm (exec.rs:1379-1386) falls back to a full scan
795/// + single-equality retain when `nodes_with_prop` returns `None`.
796///
797/// **Semantic note:** folding changes the set of *candidate* nodes that reach
798/// residual predicates — only nodes matching the equality are visited, not every
799/// node in the label. For valid data this produces identical result rows.
800/// However, if a node's residual property would cause a type error (e.g. calling
801/// a string function on an Int) that node must also match the folded equality to
802/// trigger the error; nodes eliminated by the `IndexScan` will not surface it.
803/// This is consistent with predicate pushdown in all standard query engines.
804pub(super) fn fold_where_equalities(mut ops: Vec<PlanOp>) -> Vec<PlanOp> {
805    // Locate the anchoring scan op: ScanLabel (most common), or IndexScan produced
806    // by the inline-prop path (merge with WHERE equalities → IndexIntersect).
807    // IndexIntersect anchors are not re-folded here; T2's exec handles them.
808    let Some(scan_pos) = ops
809        .iter()
810        .position(|op| matches!(op, PlanOp::ScanLabel { .. } | PlanOp::IndexScan { .. }))
811    else {
812        return ops;
813    };
814
815    // Extract the scan variable, label, and any equality already committed by an
816    // inline-prop IndexScan (used when merging inline+WHERE into IndexIntersect).
817    let (scan_var, scan_label, existing_eq) = match &ops[scan_pos] {
818        PlanOp::ScanLabel { var, label } => (var.clone(), label.clone(), None),
819        PlanOp::IndexScan {
820            var,
821            label,
822            field,
823            value,
824        } => (
825            var.clone(),
826            label.clone(),
827            Some((field.clone(), value.clone())),
828        ),
829        _ => unreachable!(),
830    };
831
832    // Find the first Filter after the anchoring scan op.
833    let Some(rel_pos) = ops[scan_pos + 1..]
834        .iter()
835        .position(|op| matches!(op, PlanOp::Filter { .. }))
836    else {
837        return ops;
838    };
839    let filter_pos = scan_pos + 1 + rel_pos;
840
841    // Conservative: do not fold if any Expand lies between the anchor and the Filter.
842    if ops[scan_pos + 1..filter_pos]
843        .iter()
844        .any(|op| matches!(op, PlanOp::Expand { .. }))
845    {
846        return ops;
847    }
848
849    let filter_expr = match &ops[filter_pos] {
850        PlanOp::Filter { expr } => expr.clone(),
851        _ => unreachable!(),
852    };
853
854    // Flatten the AND chain and collect ALL eligible equalities on the scan var.
855    let mut terms = split_and(filter_expr);
856    let mut extracted: Vec<(String, Operand)> = Vec::new();
857    let mut i = 0;
858    while i < terms.len() {
859        if matches!(
860            &terms[i],
861            Expr::Cmp {
862                lhs: Operand::Prop { var, .. },
863                op: CmpOp::Eq,
864                rhs: Operand::Lit(_) | Operand::Param(_),
865            } if var == &scan_var
866        ) {
867            let term = terms.remove(i);
868            match term {
869                Expr::Cmp {
870                    lhs: Operand::Prop { field, .. },
871                    rhs,
872                    ..
873                } => extracted.push((field, rhs)),
874                _ => unreachable!(),
875            }
876        } else {
877            i += 1;
878        }
879    }
880
881    if extracted.is_empty() {
882        return ops;
883    }
884
885    // Merge any pre-existing inline equality (from IndexScan) with the WHERE equalities.
886    let mut all_equalities: Vec<(String, Operand)> = Vec::new();
887    if let Some(eq) = existing_eq {
888        all_equalities.push(eq);
889    }
890    all_equalities.extend(extracted);
891
892    // Promote the anchor op.
893    ops[scan_pos] = if all_equalities.len() == 1 {
894        let (field, value) = all_equalities.remove(0);
895        PlanOp::IndexScan {
896            var: scan_var,
897            label: scan_label,
898            field,
899            value,
900        }
901    } else {
902        PlanOp::IndexIntersect {
903            var: scan_var,
904            label: scan_label,
905            equalities: all_equalities,
906        }
907    };
908
909    // Drop or narrow the Filter.
910    match join_and(terms) {
911        Some(residual) => ops[filter_pos] = PlanOp::Filter { expr: residual },
912        None => {
913            ops.remove(filter_pos);
914        }
915    }
916
917    ops
918}
919
920fn invert_dir(d: RelDir) -> RelDir {
921    match d {
922        RelDir::Right => RelDir::Left,
923        RelDir::Left => RelDir::Right,
924        RelDir::Undirected => RelDir::Undirected,
925    }
926}
927
928fn compile_pattern(
929    pat: &Pattern,
930    ops: &mut Vec<PlanOp>,
931    bound: &mut BTreeSet<String>,
932    rel_bound: &mut BTreeSet<String>,
933    node_anon: &mut u32,
934    rel_anon: &mut u32,
935) -> Result<(), String> {
936    let start = name_node(&pat.start, node_anon, bound);
937    if pat.shortest {
938        // shortestPath requires both endpoints already bound.
939        if !bound.contains(&start) {
940            return Err(format!(
941                "shortestPath: source node `{start}` is not bound; \
942                 bind both endpoints before shortestPath"
943            ));
944        }
945        ops.push(PlanOp::JoinBound {
946            var: start.clone(),
947            label: pat.start.label.clone(),
948            props: pat.start.props.clone(),
949        });
950    } else if bound.contains(&start) {
951        ops.push(PlanOp::JoinBound {
952            var: start.clone(),
953            label: pat.start.label.clone(),
954            props: pat.start.props.clone(),
955        });
956    } else if pat.chain.len() == 1
957        && pat.chain[0].0.hops.is_none()
958        && pat.chain[0]
959            .1
960            .var
961            .as_ref()
962            .is_some_and(|v| bound.contains(v))
963    {
964        // Expand-from-bound: leftmost unbound, rightmost dest already bound,
965        // single-rel *fixed-hop* pattern. Start from dest, invert dir, expand
966        // toward start. Variable-length (`*min..max`) is not reversed: VarExpand
967        // has no dest label/prop filter, so reversing would drop start checks.
968        let (rel, dest) = &pat.chain[0];
969        let dest_name = name_node(dest, node_anon, bound);
970        let rel_name = name_rel(rel, rel_anon, bound);
971        bound.insert(rel_name.clone());
972        rel_bound.insert(rel_name.clone());
973        if dest.label.is_some() || !dest.props.is_empty() {
974            ops.push(PlanOp::JoinBound {
975                var: dest_name.clone(),
976                label: dest.label.clone(),
977                props: dest.props.clone(),
978            });
979        }
980        ops.push(PlanOp::Expand {
981            from: dest_name,
982            rel_var: Some(rel_name),
983            etypes: rel.etypes.clone(),
984            dir: invert_dir(rel.dir),
985            to: start.clone(),
986            to_label: pat.start.label.clone(),
987            to_props: pat.start.props.clone(),
988        });
989        bound.insert(start);
990        return Ok(());
991    } else if let Some(key) = id_lookup(&pat.start.props) {
992        ops.push(PlanOp::ScanKey {
993            var: start.clone(),
994            key: key.clone(),
995            label: pat.start.label.clone(),
996        });
997        bound.insert(start.clone());
998    } else if let Some((field, value)) = index_lookup(&pat.start.props) {
999        ops.push(PlanOp::IndexScan {
1000            var: start.clone(),
1001            label: pat.start.label.clone(),
1002            field: field.to_string(),
1003            value: value.clone(),
1004        });
1005        bound.insert(start.clone());
1006    } else if let Some(equalities) = multi_index_lookup(&pat.start.props) {
1007        ops.push(PlanOp::IndexIntersect {
1008            var: start.clone(),
1009            label: pat.start.label.clone(),
1010            equalities,
1011        });
1012        bound.insert(start.clone());
1013    } else {
1014        ops.push(PlanOp::ScanLabel {
1015            var: start.clone(),
1016            label: pat.start.label.clone(),
1017        });
1018        if !pat.start.props.is_empty() {
1019            ops.push(PlanOp::LookupProps {
1020                var: start.clone(),
1021                props: pat.start.props.clone(),
1022            });
1023        }
1024        bound.insert(start.clone());
1025    }
1026
1027    let mut from = start;
1028    for (rel, dest) in &pat.chain {
1029        let rel_name = name_rel(rel, rel_anon, bound);
1030        bound.insert(rel_name.clone());
1031        rel_bound.insert(rel_name.clone());
1032        let to = name_node(dest, node_anon, bound);
1033
1034        if let Some(hops) = rel.hops {
1035            if pat.shortest {
1036                // shortestPath: destination must also already be bound.
1037                if !bound.contains(&to) {
1038                    return Err(format!(
1039                        "shortestPath: destination node `{to}` is not bound; \
1040                         bind both endpoints before shortestPath"
1041                    ));
1042                }
1043                // A minimum hop count > 1 is not supported for shortestPath —
1044                // the BFS always returns the shortest (lowest-hop) path, so a
1045                // min constraint would silently be ignored.  Reject explicitly.
1046                if hops.min > 1 {
1047                    return Err(format!(
1048                        "shortestPath does not support a minimum hop count \
1049                         (got min={}); use a plain variable-length pattern \
1050                         if you need a minimum",
1051                        hops.min
1052                    ));
1053                }
1054                ops.push(PlanOp::ShortestPath {
1055                    from: from.clone(),
1056                    rel_var: Some(rel_name),
1057                    etypes: rel.etypes.clone(),
1058                    dir: rel.dir,
1059                    to: to.clone(),
1060                    max_hops: hops.max,
1061                });
1062            } else {
1063                ops.push(PlanOp::VarExpand {
1064                    from: from.clone(),
1065                    rel_var: Some(rel_name),
1066                    etypes: rel.etypes.clone(),
1067                    dir: rel.dir,
1068                    to: to.clone(),
1069                    min: hops.min,
1070                    max: hops.max,
1071                });
1072                bound.insert(to.clone());
1073            }
1074        } else {
1075            ops.push(PlanOp::Expand {
1076                from: from.clone(),
1077                rel_var: Some(rel_name),
1078                etypes: rel.etypes.clone(),
1079                dir: rel.dir,
1080                to: to.clone(),
1081                to_label: dest.label.clone(),
1082                to_props: dest.props.clone(),
1083            });
1084            bound.insert(to.clone());
1085        }
1086        from = to;
1087    }
1088    Ok(())
1089}
1090
1091/// Compile one `OPTIONAL MATCH` clause into a `LeftOuterApply` op.
1092///
1093/// The inner plan is compiled from the pattern(s) and optional WHERE, starting
1094/// from a copy of the outer bound set.  Variables introduced inside the optional
1095/// scope are collected as `optional_vars` — they will be nulled in the fallback
1096/// row when the inner plan produces no results.
1097fn compile_optional_clause(
1098    oc: &OptionalClause,
1099    ops: &mut Vec<PlanOp>,
1100    bound: &mut BTreeSet<String>,
1101    rel_bound: &mut BTreeSet<String>,
1102    node_anon: &mut u32,
1103    rel_anon: &mut u32,
1104) -> Result<(), String> {
1105    // Clone the outer bound state; the inner plan compiles against it.
1106    let mut inner_bound = bound.clone();
1107    let mut inner_rel_bound = rel_bound.clone();
1108    let mut inner_ops: Vec<PlanOp> = Vec::new();
1109
1110    for pat in &oc.patterns {
1111        compile_pattern(
1112            pat,
1113            &mut inner_ops,
1114            &mut inner_bound,
1115            &mut inner_rel_bound,
1116            node_anon,
1117            rel_anon,
1118        )?;
1119    }
1120    if let Some(expr) = &oc.where_expr {
1121        check_expr_bound(expr, &inner_bound)?;
1122        inner_ops.push(PlanOp::Filter { expr: expr.clone() });
1123    }
1124
1125    // Variables newly introduced by the optional clause.
1126    let optional_vars: Vec<String> = inner_bound
1127        .difference(bound)
1128        .chain(inner_rel_bound.difference(rel_bound))
1129        .cloned()
1130        .collect();
1131
1132    // Merge inner-introduced vars into the outer bound set so subsequent
1133    // clauses can reference them (they may be null, but they are "bound").
1134    for v in &optional_vars {
1135        bound.insert(v.clone());
1136    }
1137    for v in inner_rel_bound
1138        .difference(&*rel_bound)
1139        .cloned()
1140        .collect::<Vec<_>>()
1141    {
1142        rel_bound.insert(v);
1143    }
1144
1145    ops.push(PlanOp::LeftOuterApply {
1146        inner: inner_ops,
1147        optional_vars,
1148    });
1149    Ok(())
1150}
1151
1152fn name_node(node: &NodePat, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1153    match &node.var {
1154        Some(v) => v.clone(),
1155        None => fresh("_n", counter, bound),
1156    }
1157}
1158
1159fn name_rel(rel: &RelPat, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1160    match &rel.var {
1161        Some(v) => v.clone(),
1162        None => fresh("_r", counter, bound),
1163    }
1164}
1165
1166/// Stable `_nN` / `_rN` in encounter order. Skips names already bound so a
1167/// user var `_n0` does not collide with the next anonymous node.
1168fn fresh(prefix: &str, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1169    for _ in 0..=u32::MAX {
1170        let name = format!("{prefix}{counter}");
1171        *counter = counter.wrapping_add(1);
1172        if !bound.contains(&name) {
1173            return name;
1174        }
1175    }
1176    format!("{prefix}x")
1177}
1178
1179fn check_expr_bound(expr: &Expr, bound: &BTreeSet<String>) -> Result<(), String> {
1180    match expr {
1181        Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => {
1182            check_expr_bound(lhs, bound)?;
1183            check_expr_bound(rhs, bound)
1184        }
1185        Expr::Not(inner) => check_expr_bound(inner, bound),
1186        Expr::Cmp { lhs, rhs, .. } => {
1187            check_operand_bound(lhs, bound, "WHERE")?;
1188            check_operand_bound(rhs, bound, "WHERE")
1189        }
1190        Expr::Truthy(op) => check_operand_bound(op, bound, "WHERE"),
1191        Expr::IsNull(op) | Expr::IsNotNull(op) => check_operand_bound(op, bound, "WHERE"),
1192        Expr::In { expr, list } => {
1193            check_operand_bound(expr, bound, "WHERE")?;
1194            for item in list {
1195                check_operand_bound(item, bound, "WHERE")?;
1196            }
1197            Ok(())
1198        }
1199    }
1200}
1201
1202fn check_operand_bound(
1203    operand: &Operand,
1204    bound: &BTreeSet<String>,
1205    clause: &str,
1206) -> Result<(), String> {
1207    match operand {
1208        Operand::Prop { var, .. } => require_bound(var, bound, clause),
1209        Operand::Lit(_) | Operand::Param(_) => Ok(()),
1210        Operand::Var(name) => require_bound(name, bound, clause),
1211        Operand::BinArith { left, right, .. } => {
1212            check_operand_bound(left, bound, clause)?;
1213            check_operand_bound(right, bound, clause)
1214        }
1215        Operand::FuncCall { args, .. } => {
1216            for arg in args {
1217                check_operand_bound(arg, bound, clause)?;
1218            }
1219            Ok(())
1220        }
1221        Operand::Case { branches, default } => {
1222            for (cond, value) in branches {
1223                check_expr_bound(cond, bound)?;
1224                check_operand_bound(value, bound, clause)?;
1225            }
1226            if let Some(d) = default {
1227                check_operand_bound(d, bound, clause)?;
1228            }
1229            Ok(())
1230        }
1231    }
1232}
1233
1234/// Validate that any variable referenced in an UNWIND expression is already bound.
1235fn check_unwind_bound(expr: &UnwindExpr, bound: &BTreeSet<String>) -> Result<(), String> {
1236    match expr {
1237        UnwindExpr::Lit(_) => Ok(()),
1238        UnwindExpr::Prop { var, .. } => require_bound(var, bound, "UNWIND"),
1239        UnwindExpr::Var(name) => require_bound(name, bound, "UNWIND"),
1240    }
1241}
1242
1243fn require_bound(var: &str, bound: &BTreeSet<String>, clause: &str) -> Result<(), String> {
1244    if bound.contains(var) {
1245        Ok(())
1246    } else {
1247        Err(format!("unbound variable `{var}` in {clause}"))
1248    }
1249}
1250
1251fn reject_bare_rel(var: &str, rel_bound: &BTreeSet<String>) -> Result<(), String> {
1252    if rel_bound.contains(var) {
1253        Err(format!(
1254            "cannot return relationship variable '{var}' bare; return its properties ({var}.field) instead"
1255        ))
1256    } else {
1257        Ok(())
1258    }
1259}
1260
1261fn check_return_bound(
1262    items: &[RetItem],
1263    bound: &BTreeSet<String>,
1264    rel_bound: &BTreeSet<String>,
1265) -> Result<(), String> {
1266    for item in items {
1267        match &item.value {
1268            RetVal::Var(v) => {
1269                require_bound(v, bound, "RETURN")?;
1270                reject_bare_rel(v, rel_bound)?;
1271            }
1272            RetVal::Prop { var, .. } => {
1273                require_bound(var, bound, "RETURN")?;
1274            }
1275            RetVal::Agg { arg, .. } => match arg {
1276                AggArg::Star => {}
1277                AggArg::Var(v) => {
1278                    require_bound(v, bound, "RETURN")?;
1279                }
1280                AggArg::Prop { var, .. } => {
1281                    require_bound(var, bound, "RETURN")?;
1282                }
1283            },
1284            RetVal::FuncCall { args, .. } => {
1285                for arg in args {
1286                    check_operand_bound(arg, bound, "RETURN")?;
1287                }
1288            }
1289            RetVal::ScalarExpr(op) => {
1290                check_operand_bound(op, bound, "RETURN")?;
1291            }
1292        }
1293    }
1294    Ok(())
1295}
1296
1297fn check_duplicate_aliases(items: &[RetItem]) -> Result<(), String> {
1298    let mut seen = BTreeSet::new();
1299    for item in items {
1300        if let Some(alias) = &item.alias {
1301            if !seen.insert(alias.clone()) {
1302                return Err(format!("duplicate RETURN alias `{alias}`"));
1303            }
1304        }
1305    }
1306    Ok(())
1307}
1308
1309fn check_duplicate_columns(items: &[RetItem]) -> Result<(), String> {
1310    let mut seen = BTreeSet::new();
1311    for item in items {
1312        let col = column_name(item);
1313        if !seen.insert(col.clone()) {
1314            return Err(format!("duplicate RETURN column `{col}`"));
1315        }
1316    }
1317    Ok(())
1318}
1319
1320/// Projected column name: alias if given, else the bare var, else `var.field`,
1321/// else the canonical aggregate call string, else `funcname(...)`, else `<expr>`.
1322fn column_name(item: &RetItem) -> String {
1323    if let Some(alias) = &item.alias {
1324        return alias.clone();
1325    }
1326    match &item.value {
1327        RetVal::Var(v) => v.clone(),
1328        RetVal::Prop { var, field } => format!("{var}.{field}"),
1329        RetVal::Agg { func, arg } => agg_column_name(func, arg),
1330        RetVal::FuncCall { name, args } => {
1331            let arg_strs: Vec<String> = args
1332                .iter()
1333                .map(|a| match a {
1334                    Operand::Var(v) => v.clone(),
1335                    Operand::Prop { var, field } => format!("{var}.{field}"),
1336                    Operand::Lit(_) => "<lit>".to_string(),
1337                    Operand::Param(p) => format!("${p}"),
1338                    Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
1339                    Operand::BinArith { .. } => "<arith>".to_string(),
1340                    Operand::Case { .. } => "<case>".to_string(),
1341                })
1342                .collect();
1343            format!("{name}({})", arg_strs.join(", "))
1344        }
1345        RetVal::ScalarExpr(_) => "<expr>".to_string(),
1346    }
1347}
1348
1349/// Canonical string for an aggregate without an alias, e.g. `COUNT(*)`,
1350/// `SUM(n.age)`.
1351fn agg_column_name(func: &AggFunc, arg: &AggArg) -> String {
1352    let f = func_name(func);
1353    let a = match arg {
1354        AggArg::Star => "*".to_string(),
1355        AggArg::Var(v) => v.clone(),
1356        AggArg::Prop { var, field } => format!("{var}.{field}"),
1357    };
1358    format!("{f}({a})")
1359}
1360
1361fn func_name(func: &AggFunc) -> &'static str {
1362    match func {
1363        AggFunc::Count => "COUNT",
1364        AggFunc::Sum => "SUM",
1365        AggFunc::Avg => "AVG",
1366        AggFunc::Min => "MIN",
1367        AggFunc::Max => "MAX",
1368        AggFunc::Collect => "COLLECT",
1369    }
1370}
1371
1372fn rewrite_order_item(
1373    item: &OrderItem,
1374    returns: &[RetItem],
1375    bound: &BTreeSet<String>,
1376    rel_bound: &BTreeSet<String>,
1377) -> Result<OrderItem, String> {
1378    let column = match &item.target {
1379        OrderTarget::Alias(name) => {
1380            if returns
1381                .iter()
1382                .any(|r| r.alias.as_deref() == Some(name.as_str()))
1383            {
1384                name.clone()
1385            } else {
1386                return Err(format!("ORDER BY target `{name}` is not present in RETURN"));
1387            }
1388        }
1389        OrderTarget::Var(v) => {
1390            require_bound(v, bound, "ORDER BY")?;
1391            reject_bare_rel(v, rel_bound)?;
1392            match returns
1393                .iter()
1394                .find(|r| matches!(&r.value, RetVal::Var(x) if x == v))
1395            {
1396                Some(r) => column_name(r),
1397                None => {
1398                    return Err(format!("ORDER BY target `{v}` is not present in RETURN"));
1399                }
1400            }
1401        }
1402        OrderTarget::Prop { var, field } => {
1403            require_bound(var, bound, "ORDER BY")?;
1404            match returns.iter().find(
1405                |r| matches!(&r.value, RetVal::Prop { var: v, field: f } if v == var && f == field),
1406            ) {
1407                Some(r) => column_name(r),
1408                None => {
1409                    return Err(format!(
1410                        "ORDER BY target `{var}.{field}` is not present in RETURN"
1411                    ));
1412                }
1413            }
1414        }
1415    };
1416    Ok(OrderItem {
1417        target: OrderTarget::Alias(column),
1418        descending: item.descending,
1419    })
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424    use super::{plan, PlanOp};
1425    use crate::cypher::ast::{Expr, LimitSkip, Operand, OrderItem, OrderTarget, RetItem, RetVal};
1426    use crate::cypher::{lex, parse, RelDir};
1427    use crate::filter::CmpOp;
1428    use core_storage::Value;
1429
1430    fn plan_src(src: &str) -> Result<Vec<PlanOp>, String> {
1431        plan(&parse(&lex(src)?)?)
1432    }
1433
1434    fn assert_plan_err(src: &str, needle: &str) -> String {
1435        let result = std::panic::catch_unwind(|| plan_src(src));
1436        assert!(result.is_ok(), "plan({src:?}) panicked");
1437        let err = result
1438            .unwrap()
1439            .expect_err(&format!("plan({src:?}) must be Err"));
1440        assert!(
1441            err.contains(needle),
1442            "error must mention {needle:?}, got: {err}"
1443        );
1444        err
1445    }
1446
1447    /// Dogfood query from T6. Shape:
1448    /// - MATCH 1: `t` unbound with `{id: $tid}` → `ScanKey`.
1449    /// - MATCH 2: `c` unbound, dest `t` already bound, single-rel → reverse:
1450    ///   Expand from `t` dir Left (inbound) to `c` (Company label on `to`).
1451    /// - MATCH 3: start `c` already bound → `JoinBound`; expand to bound `t`.
1452    #[test]
1453    fn dogfood_query_exact_plan() {
1454        let src = "\
1455MATCH (t:Talent {id: $tid}) \
1456MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
1457MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
1458WHERE i.score >= 0.5 AND s.score >= 0.5 \
1459RETURN c, i.score AS industry, s.score AS specialty \
1460ORDER BY industry DESC, specialty DESC \
1461LIMIT 10";
1462        let got = plan_src(src).expect("dogfood query must plan");
1463        let expected = vec![
1464            PlanOp::ScanKey {
1465                var: "t".into(),
1466                key: Operand::Param("tid".into()),
1467                label: Some("Talent".into()),
1468            },
1469            PlanOp::Expand {
1470                from: "t".into(),
1471                rel_var: Some("i".into()),
1472                etypes: vec!["INDUSTRY_ALIGNMENT".into()],
1473                dir: RelDir::Left,
1474                to: "c".into(),
1475                to_label: Some("Company".into()),
1476                to_props: vec![],
1477            },
1478            PlanOp::JoinBound {
1479                var: "c".into(),
1480                label: None,
1481                props: vec![],
1482            },
1483            PlanOp::Expand {
1484                from: "c".into(),
1485                rel_var: Some("s".into()),
1486                etypes: vec!["SPECIALTY_MATCH".into()],
1487                dir: RelDir::Right,
1488                to: "t".into(),
1489                to_label: None,
1490                to_props: vec![],
1491            },
1492            PlanOp::Filter {
1493                expr: Expr::And(
1494                    Box::new(Expr::Cmp {
1495                        lhs: Operand::Prop {
1496                            var: "i".into(),
1497                            field: "score".into(),
1498                        },
1499                        op: CmpOp::Ge,
1500                        rhs: Operand::Lit(Value::Float(0.5)),
1501                    }),
1502                    Box::new(Expr::Cmp {
1503                        lhs: Operand::Prop {
1504                            var: "s".into(),
1505                            field: "score".into(),
1506                        },
1507                        op: CmpOp::Ge,
1508                        rhs: Operand::Lit(Value::Float(0.5)),
1509                    }),
1510                ),
1511            },
1512            PlanOp::Project {
1513                items: vec![
1514                    RetItem {
1515                        value: RetVal::Var("c".into()),
1516                        alias: None,
1517                    },
1518                    RetItem {
1519                        value: RetVal::Prop {
1520                            var: "i".into(),
1521                            field: "score".into(),
1522                        },
1523                        alias: Some("industry".into()),
1524                    },
1525                    RetItem {
1526                        value: RetVal::Prop {
1527                            var: "s".into(),
1528                            field: "score".into(),
1529                        },
1530                        alias: Some("specialty".into()),
1531                    },
1532                ],
1533            },
1534            PlanOp::OrderBy {
1535                items: vec![
1536                    OrderItem {
1537                        target: OrderTarget::Alias("industry".into()),
1538                        descending: true,
1539                    },
1540                    OrderItem {
1541                        target: OrderTarget::Alias("specialty".into()),
1542                        descending: true,
1543                    },
1544                ],
1545            },
1546            PlanOp::Limit(LimitSkip::Exact(10)),
1547        ];
1548        assert_eq!(got, expected);
1549    }
1550
1551    /// Anonymous names increment in encounter order across the whole query.
1552    /// MATCH 1: start `_n0`, rel `_r0`, dest `a`.
1553    /// MATCH 2: start `_n1` unbound, dest already-bound `a` → reverse Expand
1554    /// from `a` dir Left to `_n1`.
1555    #[test]
1556    fn anonymous_node_and_rel_names_are_stable() {
1557        let got = plan_src("MATCH ()-[]->(a) MATCH ()-[]->(a) RETURN a").unwrap();
1558        assert_eq!(
1559            got,
1560            vec![
1561                PlanOp::ScanLabel {
1562                    var: "_n0".into(),
1563                    label: None,
1564                },
1565                PlanOp::Expand {
1566                    from: "_n0".into(),
1567                    rel_var: Some("_r0".into()),
1568                    etypes: vec![],
1569                    dir: RelDir::Right,
1570                    to: "a".into(),
1571                    to_label: None,
1572                    to_props: vec![],
1573                },
1574                PlanOp::Expand {
1575                    from: "a".into(),
1576                    rel_var: Some("_r1".into()),
1577                    etypes: vec![],
1578                    dir: RelDir::Left,
1579                    to: "_n1".into(),
1580                    to_label: None,
1581                    to_props: vec![],
1582                },
1583                PlanOp::Project {
1584                    items: vec![RetItem {
1585                        value: RetVal::Var("a".into()),
1586                        alias: None,
1587                    }],
1588                },
1589            ]
1590        );
1591    }
1592
1593    #[test]
1594    fn props_on_scan_node_emit_scan_then_lookup() {
1595        let got = plan_src("MATCH (t:Talent {id: $tid}) RETURN t").unwrap();
1596        assert_eq!(
1597            got,
1598            vec![
1599                PlanOp::ScanKey {
1600                    var: "t".into(),
1601                    key: Operand::Param("tid".into()),
1602                    label: Some("Talent".into()),
1603                },
1604                PlanOp::Project {
1605                    items: vec![RetItem {
1606                        value: RetVal::Var("t".into()),
1607                        alias: None,
1608                    }],
1609                },
1610            ]
1611        );
1612    }
1613
1614    #[test]
1615    fn mixed_id_map_stays_scan_label_then_lookup() {
1616        let got = plan_src("MATCH (t:Talent {id: $k, name: 'x'}) RETURN t").unwrap();
1617        assert_eq!(
1618            got,
1619            vec![
1620                PlanOp::ScanLabel {
1621                    var: "t".into(),
1622                    label: Some("Talent".into()),
1623                },
1624                PlanOp::LookupProps {
1625                    var: "t".into(),
1626                    props: vec![
1627                        ("id".into(), Operand::Param("k".into())),
1628                        ("name".into(), Operand::Lit(Value::Str("x".into()))),
1629                    ],
1630                },
1631                PlanOp::Project {
1632                    items: vec![RetItem {
1633                        value: RetVal::Var("t".into()),
1634                        alias: None,
1635                    }],
1636                },
1637            ]
1638        );
1639    }
1640
1641    #[test]
1642    fn plan_id_map_is_scan_key() {
1643        let toks = crate::cypher::lex("MATCH (n:Person {id: $k}) RETURN n").unwrap();
1644        let q = crate::cypher::parse(&toks).unwrap();
1645        let ops = plan(&q).unwrap();
1646        assert!(matches!(ops[0], PlanOp::ScanKey { .. }), "{ops:?}");
1647    }
1648
1649    #[test]
1650    fn plan_expands_from_bound_key() {
1651        let cy =
1652            "MATCH (t:Talent {id: $tid}) MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) RETURN c";
1653        let ops = plan(&crate::cypher::parse(&crate::cypher::lex(cy).unwrap()).unwrap()).unwrap();
1654        // first: ScanKey t; then Expand from t, dir Left (inbound)
1655        assert!(matches!(&ops[0], PlanOp::ScanKey { var, .. } if var == "t"));
1656        match &ops[1] {
1657            PlanOp::Expand { from, dir, to, .. } => {
1658                assert_eq!(from, "t");
1659                assert_eq!(to, "c");
1660                assert_eq!(*dir, RelDir::Left);
1661            }
1662            other => panic!("{other:?}"),
1663        }
1664    }
1665
1666    /// VarExpand has no dest label/prop filter. Reversing
1667    /// `MATCH (c:Company)-[*1..2]->(t)` would drop `:Company` and bind
1668    /// non-Company `c`. Keep LTR: ScanLabel Company then VarExpand.
1669    #[test]
1670    fn plan_does_not_reverse_variable_length_from_bound() {
1671        let cy = "MATCH (t {id: $tid}) MATCH (c:Company)-[*1..2]->(t) RETURN c";
1672        let ops = plan_src(cy).unwrap();
1673        assert!(
1674            matches!(&ops[0], PlanOp::ScanKey { var, .. } if var == "t"),
1675            "{ops:?}"
1676        );
1677        assert!(
1678            matches!(&ops[1], PlanOp::ScanLabel { var, label } if var == "c" && label.as_deref() == Some("Company")),
1679            "{ops:?}"
1680        );
1681        match &ops[2] {
1682            PlanOp::VarExpand {
1683                from,
1684                dir,
1685                to,
1686                min,
1687                max,
1688                ..
1689            } => {
1690                assert_eq!(from, "c");
1691                assert_eq!(to, "t");
1692                assert_eq!(*dir, RelDir::Right);
1693                assert_eq!(*min, 1);
1694                assert_eq!(*max, 2);
1695            }
1696            other => panic!("{other:?}"),
1697        }
1698    }
1699
1700    #[test]
1701    fn unbound_var_in_where_is_err() {
1702        let err = assert_plan_err("MATCH (a) WHERE b.x = 1 RETURN a", "b");
1703        assert!(
1704            err.to_ascii_lowercase().contains("unbound")
1705                && err.to_ascii_lowercase().contains("where"),
1706            "expected unbound-in-WHERE context, got: {err}"
1707        );
1708    }
1709
1710    #[test]
1711    fn unbound_var_in_return_is_err() {
1712        let err = assert_plan_err("MATCH (a) RETURN b", "b");
1713        assert!(
1714            err.to_ascii_lowercase().contains("unbound")
1715                && err.to_ascii_lowercase().contains("return"),
1716            "expected unbound-in-RETURN context, got: {err}"
1717        );
1718    }
1719
1720    #[test]
1721    fn unbound_var_in_order_by_is_err() {
1722        let err = assert_plan_err("MATCH (a) RETURN a ORDER BY b", "b");
1723        assert!(
1724            err.to_ascii_lowercase().contains("unbound")
1725                && (err.to_ascii_lowercase().contains("order")),
1726            "expected unbound-in-ORDER context, got: {err}"
1727        );
1728    }
1729
1730    #[test]
1731    fn duplicate_alias_is_err() {
1732        let err = assert_plan_err("MATCH (a) RETURN a AS x, a.id AS x", "x");
1733        assert!(
1734            err.to_ascii_lowercase().contains("duplicate")
1735                && err.to_ascii_lowercase().contains("alias"),
1736            "expected duplicate-alias context, got: {err}"
1737        );
1738    }
1739
1740    #[test]
1741    fn duplicate_column_name_is_err() {
1742        let err = assert_plan_err("MATCH (a) RETURN a, a", "a");
1743        assert!(
1744            err.to_ascii_lowercase().contains("duplicate")
1745                && err.to_ascii_lowercase().contains("column"),
1746            "expected duplicate-column context, got: {err}"
1747        );
1748    }
1749
1750    #[test]
1751    fn order_by_target_absent_from_return_is_err() {
1752        // `a` is bound, but `a.x` is not a RETURN item.
1753        let err = assert_plan_err("MATCH (a) RETURN a ORDER BY a.x", "a.x");
1754        assert!(
1755            err.to_ascii_lowercase().contains("return"),
1756            "expected ORDER BY target-not-in-RETURN context, got: {err}"
1757        );
1758    }
1759
1760    /// Alias → that alias's column; bare var → its RETURN column (alias if
1761    /// given, else the var name); un-aliased prop → `var.field`.
1762    #[test]
1763    fn order_by_targets_rewrite_to_projected_column_names() {
1764        let got = plan_src(
1765            "MATCH (a)-[r]->(b) \
1766             RETURN a, a.name AS nm, b.age \
1767             ORDER BY nm DESC, a ASC, b.age",
1768        )
1769        .unwrap();
1770        let order = got
1771            .iter()
1772            .find_map(|op| match op {
1773                PlanOp::OrderBy { items } => Some(items),
1774                _ => None,
1775            })
1776            .expect("plan must contain OrderBy");
1777        assert_eq!(
1778            order,
1779            &vec![
1780                OrderItem {
1781                    target: OrderTarget::Alias("nm".into()),
1782                    descending: true,
1783                },
1784                OrderItem {
1785                    target: OrderTarget::Alias("a".into()),
1786                    descending: false,
1787                },
1788                OrderItem {
1789                    target: OrderTarget::Alias("b.age".into()),
1790                    descending: false,
1791                },
1792            ]
1793        );
1794
1795        let aliased_var = plan_src("MATCH (a) RETURN a AS person ORDER BY a").unwrap();
1796        let order = aliased_var
1797            .iter()
1798            .find_map(|op| match op {
1799                PlanOp::OrderBy { items } => Some(items),
1800                _ => None,
1801            })
1802            .expect("plan must contain OrderBy");
1803        assert_eq!(
1804            order,
1805            &vec![OrderItem {
1806                target: OrderTarget::Alias("person".into()),
1807                descending: false,
1808            }]
1809        );
1810    }
1811
1812    #[test]
1813    fn bound_pattern_start_is_join_bound_then_expand() {
1814        let got = plan_src("MATCH (a:L) MATCH (a)-[r:T]->(b) RETURN a, b").unwrap();
1815        assert_eq!(
1816            got,
1817            vec![
1818                PlanOp::ScanLabel {
1819                    var: "a".into(),
1820                    label: Some("L".into()),
1821                },
1822                PlanOp::JoinBound {
1823                    var: "a".into(),
1824                    label: None,
1825                    props: vec![],
1826                },
1827                PlanOp::Expand {
1828                    from: "a".into(),
1829                    rel_var: Some("r".into()),
1830                    etypes: vec!["T".into()],
1831                    dir: RelDir::Right,
1832                    to: "b".into(),
1833                    to_label: None,
1834                    to_props: vec![],
1835                },
1836                PlanOp::Project {
1837                    items: vec![
1838                        RetItem {
1839                            value: RetVal::Var("a".into()),
1840                            alias: None,
1841                        },
1842                        RetItem {
1843                            value: RetVal::Var("b".into()),
1844                            alias: None,
1845                        },
1846                    ],
1847                },
1848            ]
1849        );
1850    }
1851
1852    #[test]
1853    fn bound_dest_extra_checks_ride_on_expand() {
1854        let got = plan_src("MATCH (t:Talent) MATCH (c)-[r]->(t:Talent {id: 1}) RETURN t").unwrap();
1855        assert_eq!(
1856            got,
1857            vec![
1858                PlanOp::ScanLabel {
1859                    var: "t".into(),
1860                    label: Some("Talent".into()),
1861                },
1862                PlanOp::JoinBound {
1863                    var: "t".into(),
1864                    label: Some("Talent".into()),
1865                    props: vec![("id".into(), Operand::Lit(Value::Int(1)))],
1866                },
1867                PlanOp::Expand {
1868                    from: "t".into(),
1869                    rel_var: Some("r".into()),
1870                    etypes: vec![],
1871                    dir: RelDir::Left,
1872                    to: "c".into(),
1873                    to_label: None,
1874                    to_props: vec![],
1875                },
1876                PlanOp::Project {
1877                    items: vec![RetItem {
1878                        value: RetVal::Var("t".into()),
1879                        alias: None,
1880                    }],
1881                },
1882            ]
1883        );
1884    }
1885
1886    #[test]
1887    fn return_distinct_emits_distinct_after_project() {
1888        let ops = plan_src("MATCH (n) RETURN DISTINCT n").expect("DISTINCT must plan");
1889        let proj = ops
1890            .iter()
1891            .position(|op| matches!(op, PlanOp::Project { .. }))
1892            .expect("Project");
1893        assert!(
1894            matches!(ops.get(proj + 1), Some(PlanOp::Distinct)),
1895            "DISTINCT must follow Project, got: {ops:?}"
1896        );
1897        let bounded = plan_src("MATCH (n) RETURN DISTINCT n LIMIT 1").unwrap();
1898        assert!(
1899            super::row_bound(&bounded).is_none(),
1900            "DISTINCT + LIMIT must not push LIMIT into producers"
1901        );
1902    }
1903
1904    #[test]
1905    fn skip_then_limit_follow_project() {
1906        let got = plan_src("MATCH (a) RETURN a SKIP 2 LIMIT 3").unwrap();
1907        assert_eq!(
1908            got,
1909            vec![
1910                PlanOp::ScanLabel {
1911                    var: "a".into(),
1912                    label: None,
1913                },
1914                PlanOp::Project {
1915                    items: vec![RetItem {
1916                        value: RetVal::Var("a".into()),
1917                        alias: None,
1918                    }],
1919                },
1920                PlanOp::Skip(LimitSkip::Exact(2)),
1921                PlanOp::Limit(LimitSkip::Exact(3)),
1922            ]
1923        );
1924    }
1925
1926    #[test]
1927    fn aliased_prop_order_by_rewrites_to_alias_column() {
1928        let got = plan_src("MATCH (a) RETURN a.name AS nm ORDER BY a.name").unwrap();
1929        let order = got
1930            .iter()
1931            .find_map(|op| match op {
1932                PlanOp::OrderBy { items } => Some(items),
1933                _ => None,
1934            })
1935            .unwrap();
1936        assert_eq!(
1937            order,
1938            &vec![OrderItem {
1939                target: OrderTarget::Alias("nm".into()),
1940                descending: false,
1941            }]
1942        );
1943    }
1944
1945    #[test]
1946    fn plan_never_panics_on_hand_built_query() {
1947        use crate::cypher::ast::{NodePat, Pattern, Query};
1948        let q = Query {
1949            matches: vec![],
1950            optional_clauses: vec![],
1951            where_expr: None,
1952            unwinds: vec![],
1953            post_unwind_where: None,
1954            stages: vec![],
1955            returns: vec![],
1956            order_by: vec![],
1957            distinct: false,
1958            skip: None,
1959            limit: None,
1960        };
1961        let result = std::panic::catch_unwind(|| plan(&q));
1962        assert!(result.is_ok(), "plan panicked on empty Query");
1963        let _ = result.unwrap();
1964
1965        let q = Query {
1966            matches: vec![Pattern {
1967                start: NodePat {
1968                    var: None,
1969                    label: None,
1970                    props: vec![],
1971                },
1972                chain: vec![],
1973                shortest: false,
1974            }],
1975            optional_clauses: vec![],
1976            where_expr: Some(Expr::Not(Box::new(Expr::Cmp {
1977                lhs: Operand::Param("p".into()),
1978                op: CmpOp::Eq,
1979                rhs: Operand::Lit(Value::Int(1)),
1980            }))),
1981            unwinds: vec![],
1982            post_unwind_where: None,
1983            stages: vec![],
1984            returns: vec![],
1985            distinct: false,
1986            order_by: vec![OrderItem {
1987                target: OrderTarget::Alias("missing".into()),
1988                descending: true,
1989            }],
1990            skip: Some(LimitSkip::Exact(0)),
1991            limit: Some(LimitSkip::Exact(0)),
1992        };
1993        let result = std::panic::catch_unwind(|| plan(&q));
1994        assert!(result.is_ok(), "plan panicked on hand-built Query");
1995        let _ = result.unwrap();
1996    }
1997
1998    #[test]
1999    fn bare_relationship_var_in_return_is_err() {
2000        let err = assert_plan_err("MATCH (a)-[r:T]->(b) RETURN r", "r");
2001        assert!(
2002            err.to_ascii_lowercase().contains("relationship"),
2003            "expected bare-rel RETURN guidance, got: {err}"
2004        );
2005    }
2006
2007    #[test]
2008    fn relationship_prop_in_return_is_ok() {
2009        plan_src("MATCH (a)-[r:T]->(b) RETURN r.w").expect("rel prop RETURN must plan");
2010    }
2011
2012    #[test]
2013    fn bare_relationship_var_in_order_by_is_err() {
2014        // RETURN r.w is legal; ORDER BY r is a bare rel var (defense, not just
2015        // "not in RETURN").
2016        let err = assert_plan_err("MATCH (a)-[r:T]->(b) RETURN r.w ORDER BY r", "r");
2017        assert!(
2018            err.to_ascii_lowercase().contains("relationship"),
2019            "expected bare-rel ORDER BY guidance, got: {err}"
2020        );
2021    }
2022
2023    #[test]
2024    fn relationship_prop_in_order_by_is_ok() {
2025        plan_src("MATCH (a)-[r:T]->(b) RETURN r.w ORDER BY r.w")
2026            .expect("rel prop ORDER BY must plan");
2027    }
2028
2029    // ── Variable-length path and shortestPath planner tests ───────────────────
2030
2031    #[test]
2032    fn var_expand_op_emitted_for_star_rel() {
2033        use super::row_bound;
2034        let ops = plan_src("MATCH (a)-[r:T*2..4]->(b) RETURN b").unwrap();
2035        let has_var = ops
2036            .iter()
2037            .any(|op| matches!(op, PlanOp::VarExpand { min: 2, max: 4, .. }));
2038        assert!(has_var, "expected VarExpand(2..4) in plan, got: {ops:?}");
2039        // row_bound must be None even when no ORDER BY (VarExpand overrides pull routing)
2040        assert_eq!(
2041            row_bound(&ops),
2042            None,
2043            "VarExpand plan must not use pull path"
2044        );
2045    }
2046
2047    #[test]
2048    fn var_expand_with_limit_still_takes_staged_path() {
2049        use super::row_bound;
2050        let ops = plan_src("MATCH (a)-[r:T*1..3]->(b) RETURN b LIMIT 5").unwrap();
2051        // Staged path: row_bound returns None for VarExpand.
2052        assert_eq!(
2053            row_bound(&ops),
2054            None,
2055            "VarExpand + LIMIT must still use staged path"
2056        );
2057        let has_var = ops.iter().any(|op| matches!(op, PlanOp::VarExpand { .. }));
2058        assert!(has_var, "plan must contain VarExpand");
2059        let has_limit = ops
2060            .iter()
2061            .any(|op| matches!(op, PlanOp::Limit(LimitSkip::Exact(5))));
2062        assert!(has_limit, "plan must still emit Limit op");
2063    }
2064
2065    #[test]
2066    fn shortest_path_op_emitted_for_shortest_path_clause() {
2067        let ops =
2068            plan_src("MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*..3]->(b)) RETURN a")
2069                .unwrap();
2070        let has_sp = ops
2071            .iter()
2072            .any(|op| matches!(op, PlanOp::ShortestPath { max_hops: 3, .. }));
2073        assert!(
2074            has_sp,
2075            "expected ShortestPath op with max_hops=3, got: {ops:?}"
2076        );
2077    }
2078
2079    #[test]
2080    fn shortest_path_unbound_endpoint_is_err() {
2081        let err = assert_plan_err(
2082            "MATCH shortestPath((a)-[r:T*..3]->(b)) RETURN a",
2083            "shortestPath",
2084        );
2085        assert!(
2086            err.contains("not bound") || err.contains("bound"),
2087            "error must mention binding, got: {err}"
2088        );
2089    }
2090
2091    #[test]
2092    fn var_expand_rel_var_is_in_rel_bound() {
2093        // r.length should be allowed in RETURN (prop access)
2094        plan_src("MATCH (a)-[r:T*1..3]->(b) RETURN r.length").expect("r.length must plan");
2095        // bare r must be rejected
2096        assert_plan_err("MATCH (a)-[r:T*1..3]->(b) RETURN r", "r");
2097    }
2098
2099    #[test]
2100    fn shortest_path_min_gt_1_is_plan_err() {
2101        // shortestPath with *2..5 must be rejected: min>1 is not supported
2102        let err = assert_plan_err(
2103            "MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*2..5]->(b)) RETURN r.length",
2104            "shortestPath",
2105        );
2106        assert!(
2107            err.contains("minimum"),
2108            "error must mention minimum hop count, got: {err}"
2109        );
2110    }
2111
2112    // ── is_subscribable tests ──────────────────────────────────────────────────
2113
2114    fn subscribable(src: &str) -> bool {
2115        let ops = plan_src(src).expect("must plan");
2116        super::is_subscribable(&ops)
2117    }
2118
2119    #[test]
2120    fn is_subscribable_passes_simple_label_scan() {
2121        assert!(subscribable("MATCH (n:Person) RETURN n"));
2122        assert!(subscribable("MATCH (n:Person) WHERE n.age > 18 RETURN n"));
2123        assert!(subscribable("MATCH (n:Person) RETURN n LIMIT 100"));
2124    }
2125
2126    #[test]
2127    fn is_subscribable_passes_single_hop_expand() {
2128        assert!(subscribable(
2129            "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a"
2130        ));
2131        assert!(subscribable(
2132            "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a LIMIT 50"
2133        ));
2134    }
2135
2136    #[test]
2137    fn is_subscribable_rejects_multi_hop_expand() {
2138        // Two Expand ops: outside the documented single-hop subset.
2139        assert!(
2140            !subscribable("MATCH (a:Person)-[r1:KNOWS]->(b:Person)-[r2:LIKES]->(c:Thing) RETURN a"),
2141            "two-hop chain must be rejected"
2142        );
2143    }
2144
2145    #[test]
2146    fn is_subscribable_rejects_skip() {
2147        // SKIP creates unstable offset windows — explicitly excluded.
2148        assert!(
2149            !subscribable("MATCH (n:Person) RETURN n SKIP 10 LIMIT 50"),
2150            "SKIP must be rejected"
2151        );
2152        assert!(
2153            !subscribable("MATCH (n:Person) RETURN n SKIP 10"),
2154            "bare SKIP must be rejected"
2155        );
2156    }
2157
2158    #[test]
2159    fn is_subscribable_rejects_order_by() {
2160        assert!(!subscribable("MATCH (n:Person) RETURN n ORDER BY n"));
2161    }
2162
2163    #[test]
2164    fn is_subscribable_rejects_aggregates() {
2165        assert!(!subscribable("MATCH (n:Person) RETURN COUNT(*)"));
2166    }
2167
2168    #[test]
2169    fn is_subscribable_rejects_var_expand() {
2170        assert!(!subscribable(
2171            "MATCH (a:Person)-[r:KNOWS*1..3]->(b) RETURN b"
2172        ));
2173    }
2174
2175    // --- WHERE equality fold tests (T1) ---
2176
2177    #[test]
2178    fn where_equality_folds_to_index_scan() {
2179        let ops = plan_src("MATCH (n:Person) WHERE n.city = 'austin' RETURN n.key").unwrap();
2180        assert!(
2181            matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2182            "WHERE single equality must fold to IndexScan, got {:?}",
2183            ops[0]
2184        );
2185        assert!(
2186            !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2187            "consumed predicate must not remain as Filter"
2188        );
2189    }
2190
2191    #[test]
2192    fn where_equality_param_folds_to_index_scan() {
2193        let ops = plan_src("MATCH (n:Person) WHERE n.city = $c RETURN n.key").unwrap();
2194        assert!(
2195            matches!(&ops[0], PlanOp::IndexScan { .. }),
2196            "param WHERE equality must fold to IndexScan, got {:?}",
2197            ops[0]
2198        );
2199    }
2200
2201    #[test]
2202    fn where_and_keeps_residual_filter() {
2203        let ops = plan_src("MATCH (n:Person) WHERE n.city = 'austin' AND n.age > 30 RETURN n.key")
2204            .unwrap();
2205        assert!(
2206            matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2207            "equality must fold to IndexScan, got {:?}",
2208            ops[0]
2209        );
2210        assert!(
2211            ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2212            "n.age > 30 must remain as residual Filter"
2213        );
2214    }
2215
2216    #[test]
2217    fn where_on_expanded_var_does_not_fold() {
2218        let ops =
2219            plan_src("MATCH (a:Person)-[:KNOWS]->(b:Person) WHERE b.city = 'austin' RETURN a.key")
2220                .unwrap();
2221        assert!(
2222            matches!(&ops[0], PlanOp::ScanLabel { .. } | PlanOp::IndexScan { .. }),
2223            "first op must be a scan, got {:?}",
2224            ops[0]
2225        );
2226        assert!(
2227            ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2228            "b.city filter must remain"
2229        );
2230    }
2231
2232    #[test]
2233    fn where_inline_prop_and_where_equality_both_usable() {
2234        // T2: inline prop + WHERE equality on same var → IndexIntersect with both.
2235        let ops = plan_src("MATCH (n:Person {team: 'core'}) WHERE n.city = 'austin' RETURN n.key")
2236            .unwrap();
2237        assert!(
2238            matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2239            "inline+WHERE equalities must merge to IndexIntersect(2), got {:?}",
2240            ops[0]
2241        );
2242        assert!(
2243            !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2244            "both equalities fully folded; no residual Filter expected"
2245        );
2246    }
2247
2248    // --- IndexIntersect tests (T2) ---
2249
2250    #[test]
2251    fn single_equality_inline_stays_index_scan() {
2252        // Regression: single inline prop must stay IndexScan, not IndexIntersect.
2253        let ops = plan_src("MATCH (n:Person {city: 'austin'}) RETURN n").unwrap();
2254        assert!(
2255            matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2256            "single-equality inline prop must emit IndexScan, got {:?}",
2257            ops[0]
2258        );
2259    }
2260
2261    #[test]
2262    fn compound_inline_props_emit_index_intersect() {
2263        let ops = plan_src("MATCH (n:Doc {namespace: 'a', status: 'live'}) RETURN n.key").unwrap();
2264        assert!(
2265            matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2266            "two inline props must emit IndexIntersect(2), got {:?}",
2267            ops[0]
2268        );
2269    }
2270
2271    #[test]
2272    fn where_two_equalities_emit_index_intersect() {
2273        let ops = plan_src("MATCH (n:Doc) WHERE n.namespace = 'a' AND n.status = $s RETURN n.key")
2274            .unwrap();
2275        assert!(
2276            matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2277            "two WHERE equalities must emit IndexIntersect(2), got {:?}",
2278            ops[0]
2279        );
2280        assert!(
2281            !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2282            "both equalities fully consumed; no residual Filter expected"
2283        );
2284    }
2285
2286    #[test]
2287    fn mixed_inline_and_where_equalities_merge() {
2288        let ops = plan_src("MATCH (n:Doc {namespace: 'a'}) WHERE n.status = 'live' RETURN n.key")
2289            .unwrap();
2290        assert!(
2291            matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2292            "inline+WHERE equalities must merge to IndexIntersect(2), got {:?}",
2293            ops[0]
2294        );
2295    }
2296
2297    #[test]
2298    fn where_three_equalities_emit_index_intersect() {
2299        let ops = plan_src(
2300            "MATCH (n:Doc) WHERE n.namespace = 'a' AND n.status = 'live' AND n.kind = $k RETURN n",
2301        )
2302        .unwrap();
2303        assert!(
2304            matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 3),
2305            "three WHERE equalities must emit IndexIntersect(3), got {:?}",
2306            ops[0]
2307        );
2308        assert!(
2309            !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2310            "all three equalities fully consumed; no residual Filter expected"
2311        );
2312    }
2313}