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    ret_val_label, AggArg, AggFunc, Expr, LimitSkip, NodePat, Operand, OptionalClause, OrderItem,
9    OrderTarget, 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        // `WITH … WHERE …` filters *after* the projection — the executor
643        // projects each row through the WITH items and only then runs the
644        // filter, exactly so that `WITH t, t.age AS age WHERE age > 30`
645        // works. The pre-flight check has to scope the same way: against the
646        // variables that survive the WITH as well as those already in scope.
647        // Checking the pre-WITH set alone rejected every alias a
648        // non-aggregate WITH introduced (`unbound variable `age` in WHERE`)
649        // even though the plan it would have produced ran correctly, while
650        // the aggregate branch above has always scoped its HAVING clause to
651        // the group's output columns.
652        let with_col_names: BTreeSet<String> = stage.items.iter().map(column_name).collect();
653        let with_scope: BTreeSet<String> = bound.union(&with_col_names).cloned().collect();
654        if let Some(expr) = &stage.where_expr {
655            check_expr_bound(expr, &with_scope)?;
656        }
657        // ORDER BY items reference either var names or prop paths — no rewrite needed
658        // here; exec_order_by_rows handles raw row ordering.
659        // ORDER BY may reference the WITH output columns (aliases) in addition to
660        // variables already in scope before the WITH.
661        for item in &stage.order_by {
662            match &item.target {
663                OrderTarget::Prop { var, .. } | OrderTarget::Var(var) => {
664                    if !bound.contains(var.as_str()) && !with_col_names.contains(var.as_str()) {
665                        return Err(format!("unbound variable `{var}` in ORDER BY in WITH"));
666                    }
667                }
668                OrderTarget::Alias(name) => {
669                    if !bound.contains(name.as_str()) && !with_col_names.contains(name.as_str()) {
670                        return Err(format!("unbound variable `{name}` in ORDER BY in WITH"));
671                    }
672                }
673            }
674        }
675        ops.push(PlanOp::With {
676            items: stage.items.clone(),
677            where_expr: stage.where_expr.clone(),
678            order_by: stage.order_by.clone(),
679            skip: stage.skip.clone(),
680            limit: stage.limit.clone(),
681        });
682
683        // Update bound: after non-aggregate WITH, only the WITH items survive.
684        let mut new_bound: BTreeSet<String> = BTreeSet::new();
685        let mut new_rel_bound: BTreeSet<String> = BTreeSet::new();
686        for item in &stage.items {
687            let col = column_name(item);
688            new_bound.insert(col.clone());
689            // Preserve rel-bound status for relationship variables carried through.
690            match &item.value {
691                RetVal::Var(v) if rel_bound.contains(v.as_str()) => {
692                    new_rel_bound.insert(col);
693                }
694                _ => {}
695            }
696        }
697        *bound = new_bound;
698        *rel_bound = new_rel_bound;
699    }
700
701    // MATCH clauses that follow this WITH.
702    for pat in &stage.matches {
703        compile_pattern(pat, ops, bound, rel_bound, node_anon, rel_anon)?;
704    }
705    // OPTIONAL MATCH clauses that follow those MATCHes.
706    for oc in &stage.optional_clauses {
707        compile_optional_clause(oc, ops, bound, rel_bound, node_anon, rel_anon)?;
708    }
709    // UNWIND clauses that follow this WITH.
710    for uw in &stage.unwinds {
711        check_unwind_bound(&uw.list, bound)?;
712        bound.insert(uw.alias.clone());
713        ops.push(PlanOp::Unwind {
714            expr: uw.list.clone(),
715            alias: uw.alias.clone(),
716        });
717    }
718    // WHERE that follows those MATCHes.
719    if let Some(expr) = &stage.post_where {
720        check_expr_bound(expr, bound)?;
721        ops.push(PlanOp::Filter { expr: expr.clone() });
722    }
723
724    Ok(())
725}
726
727fn id_lookup(props: &[(String, Operand)]) -> Option<&Operand> {
728    if props.len() == 1 && props[0].0 == "id" {
729        Some(&props[0].1)
730    } else {
731        None
732    }
733}
734
735/// A single equality on a non-`id` field with a literal or `$param` value —
736/// the shape eligible for an `IndexScan`. Returns `(field, value)`.
737fn index_lookup(props: &[(String, Operand)]) -> Option<(&str, &Operand)> {
738    if props.len() == 1
739        && props[0].0 != "id"
740        && matches!(props[0].1, Operand::Lit(_) | Operand::Param(_))
741    {
742        Some((props[0].0.as_str(), &props[0].1))
743    } else {
744        None
745    }
746}
747
748/// Two or more non-`id` equalities all with literal or `$param` values —
749/// the shape eligible for `IndexIntersect`. Returns the full equality list
750/// when ALL props qualify (no `id` field, all Lit|Param operands, len ≥ 2).
751fn multi_index_lookup(props: &[(String, Operand)]) -> Option<Vec<(String, Operand)>> {
752    if props.len() < 2 {
753        return None;
754    }
755    if props
756        .iter()
757        .any(|(f, v)| f == "id" || !matches!(v, Operand::Lit(_) | Operand::Param(_)))
758    {
759        return None;
760    }
761    Some(props.to_vec())
762}
763
764/// Split an `Expr::And` chain into a flat list of sub-expressions. Used by the
765/// WHERE-equality fold pass (T1) and compound-equality folding (T2).
766pub(super) fn split_and(expr: Expr) -> Vec<Expr> {
767    match expr {
768        Expr::And(l, r) => {
769            let mut v = split_and(*l);
770            v.extend(split_and(*r));
771            v
772        }
773        other => vec![other],
774    }
775}
776
777/// Reassemble a flat list of expressions into an `Expr::And` chain.
778/// Returns `None` when the list is empty (the caller must drop the Filter op).
779pub(super) fn join_and(mut exprs: Vec<Expr>) -> Option<Expr> {
780    if exprs.is_empty() {
781        return None;
782    }
783    let mut result = exprs.remove(0);
784    for e in exprs {
785        result = Expr::And(Box::new(result), Box::new(e));
786    }
787    Some(result)
788}
789
790/// Post-pass: fold WHERE-clause single-equality predicates into `IndexScan` ops.
791///
792/// Eligibility (conservative):
793/// - The anchoring scan must be `ScanLabel` — `ScanKey` is already O(1);
794///   `IndexScan` already holds one equality (T2 handles compound).
795/// - No `Expand` op may appear between the `ScanLabel` and the `Filter` being
796///   folded: the conservative rule avoids cross-expand pushdown for now.
797/// - The Filter must contain at least one `Cmp{Prop{var==scan_var,field}, Eq,
798///   Lit|Param}` term. Only the first such term is taken; the rest stay as a
799///   residual `Filter` for T2 / the executor to handle.
800///
801/// Folding is always correct regardless of whether the field is indexed:
802/// the `IndexScan` executor arm (exec.rs:1379-1386) falls back to a full scan
803/// + single-equality retain when `nodes_with_prop` returns `None`.
804///
805/// **Semantic note:** folding changes the set of *candidate* nodes that reach
806/// residual predicates — only nodes matching the equality are visited, not every
807/// node in the label. For valid data this produces identical result rows.
808/// However, if a node's residual property would cause a type error (e.g. calling
809/// a string function on an Int) that node must also match the folded equality to
810/// trigger the error; nodes eliminated by the `IndexScan` will not surface it.
811/// This is consistent with predicate pushdown in all standard query engines.
812pub(super) fn fold_where_equalities(mut ops: Vec<PlanOp>) -> Vec<PlanOp> {
813    // Locate the anchoring scan op: ScanLabel (most common), or IndexScan produced
814    // by the inline-prop path (merge with WHERE equalities → IndexIntersect).
815    // IndexIntersect anchors are not re-folded here; T2's exec handles them.
816    let Some(scan_pos) = ops
817        .iter()
818        .position(|op| matches!(op, PlanOp::ScanLabel { .. } | PlanOp::IndexScan { .. }))
819    else {
820        return ops;
821    };
822
823    // Extract the scan variable, label, and any equality already committed by an
824    // inline-prop IndexScan (used when merging inline+WHERE into IndexIntersect).
825    let (scan_var, scan_label, existing_eq) = match &ops[scan_pos] {
826        PlanOp::ScanLabel { var, label } => (var.clone(), label.clone(), None),
827        PlanOp::IndexScan {
828            var,
829            label,
830            field,
831            value,
832        } => (
833            var.clone(),
834            label.clone(),
835            Some((field.clone(), value.clone())),
836        ),
837        _ => unreachable!(),
838    };
839
840    // Find the first Filter after the anchoring scan op.
841    let Some(rel_pos) = ops[scan_pos + 1..]
842        .iter()
843        .position(|op| matches!(op, PlanOp::Filter { .. }))
844    else {
845        return ops;
846    };
847    let filter_pos = scan_pos + 1 + rel_pos;
848
849    // Conservative: do not fold if any Expand lies between the anchor and the Filter.
850    if ops[scan_pos + 1..filter_pos]
851        .iter()
852        .any(|op| matches!(op, PlanOp::Expand { .. }))
853    {
854        return ops;
855    }
856
857    let filter_expr = match &ops[filter_pos] {
858        PlanOp::Filter { expr } => expr.clone(),
859        _ => unreachable!(),
860    };
861
862    // Flatten the AND chain and collect ALL eligible equalities on the scan var.
863    let mut terms = split_and(filter_expr);
864    let mut extracted: Vec<(String, Operand)> = Vec::new();
865    let mut i = 0;
866    while i < terms.len() {
867        if matches!(
868            &terms[i],
869            Expr::Cmp {
870                lhs: Operand::Prop { var, .. },
871                op: CmpOp::Eq,
872                rhs: Operand::Lit(_) | Operand::Param(_),
873            } if var == &scan_var
874        ) {
875            let term = terms.remove(i);
876            match term {
877                Expr::Cmp {
878                    lhs: Operand::Prop { field, .. },
879                    rhs,
880                    ..
881                } => extracted.push((field, rhs)),
882                _ => unreachable!(),
883            }
884        } else {
885            i += 1;
886        }
887    }
888
889    if extracted.is_empty() {
890        return ops;
891    }
892
893    // Merge any pre-existing inline equality (from IndexScan) with the WHERE equalities.
894    let mut all_equalities: Vec<(String, Operand)> = Vec::new();
895    if let Some(eq) = existing_eq {
896        all_equalities.push(eq);
897    }
898    all_equalities.extend(extracted);
899
900    // Promote the anchor op.
901    ops[scan_pos] = if all_equalities.len() == 1 {
902        let (field, value) = all_equalities.remove(0);
903        PlanOp::IndexScan {
904            var: scan_var,
905            label: scan_label,
906            field,
907            value,
908        }
909    } else {
910        PlanOp::IndexIntersect {
911            var: scan_var,
912            label: scan_label,
913            equalities: all_equalities,
914        }
915    };
916
917    // Drop or narrow the Filter.
918    match join_and(terms) {
919        Some(residual) => ops[filter_pos] = PlanOp::Filter { expr: residual },
920        None => {
921            ops.remove(filter_pos);
922        }
923    }
924
925    ops
926}
927
928fn invert_dir(d: RelDir) -> RelDir {
929    match d {
930        RelDir::Right => RelDir::Left,
931        RelDir::Left => RelDir::Right,
932        RelDir::Undirected => RelDir::Undirected,
933    }
934}
935
936fn compile_pattern(
937    pat: &Pattern,
938    ops: &mut Vec<PlanOp>,
939    bound: &mut BTreeSet<String>,
940    rel_bound: &mut BTreeSet<String>,
941    node_anon: &mut u32,
942    rel_anon: &mut u32,
943) -> Result<(), String> {
944    let start = name_node(&pat.start, node_anon, bound);
945    if pat.shortest {
946        // shortestPath requires both endpoints already bound.
947        if !bound.contains(&start) {
948            return Err(format!(
949                "shortestPath: source node `{start}` is not bound; \
950                 bind both endpoints before shortestPath"
951            ));
952        }
953        ops.push(PlanOp::JoinBound {
954            var: start.clone(),
955            label: pat.start.label.clone(),
956            props: pat.start.props.clone(),
957        });
958    } else if bound.contains(&start) {
959        ops.push(PlanOp::JoinBound {
960            var: start.clone(),
961            label: pat.start.label.clone(),
962            props: pat.start.props.clone(),
963        });
964    } else if pat.chain.len() == 1
965        && pat.chain[0].0.hops.is_none()
966        && pat.chain[0]
967            .1
968            .var
969            .as_ref()
970            .is_some_and(|v| bound.contains(v))
971    {
972        // Expand-from-bound: leftmost unbound, rightmost dest already bound,
973        // single-rel *fixed-hop* pattern. Start from dest, invert dir, expand
974        // toward start. Variable-length (`*min..max`) is not reversed: VarExpand
975        // has no dest label/prop filter, so reversing would drop start checks.
976        let (rel, dest) = &pat.chain[0];
977        let dest_name = name_node(dest, node_anon, bound);
978        let rel_name = name_rel(rel, rel_anon, bound);
979        bound.insert(rel_name.clone());
980        rel_bound.insert(rel_name.clone());
981        if dest.label.is_some() || !dest.props.is_empty() {
982            ops.push(PlanOp::JoinBound {
983                var: dest_name.clone(),
984                label: dest.label.clone(),
985                props: dest.props.clone(),
986            });
987        }
988        ops.push(PlanOp::Expand {
989            from: dest_name,
990            rel_var: Some(rel_name),
991            etypes: rel.etypes.clone(),
992            dir: invert_dir(rel.dir),
993            to: start.clone(),
994            to_label: pat.start.label.clone(),
995            to_props: pat.start.props.clone(),
996        });
997        bound.insert(start);
998        return Ok(());
999    } else if let Some(key) = id_lookup(&pat.start.props) {
1000        ops.push(PlanOp::ScanKey {
1001            var: start.clone(),
1002            key: key.clone(),
1003            label: pat.start.label.clone(),
1004        });
1005        bound.insert(start.clone());
1006    } else if let Some((field, value)) = index_lookup(&pat.start.props) {
1007        ops.push(PlanOp::IndexScan {
1008            var: start.clone(),
1009            label: pat.start.label.clone(),
1010            field: field.to_string(),
1011            value: value.clone(),
1012        });
1013        bound.insert(start.clone());
1014    } else if let Some(equalities) = multi_index_lookup(&pat.start.props) {
1015        ops.push(PlanOp::IndexIntersect {
1016            var: start.clone(),
1017            label: pat.start.label.clone(),
1018            equalities,
1019        });
1020        bound.insert(start.clone());
1021    } else {
1022        ops.push(PlanOp::ScanLabel {
1023            var: start.clone(),
1024            label: pat.start.label.clone(),
1025        });
1026        if !pat.start.props.is_empty() {
1027            ops.push(PlanOp::LookupProps {
1028                var: start.clone(),
1029                props: pat.start.props.clone(),
1030            });
1031        }
1032        bound.insert(start.clone());
1033    }
1034
1035    let mut from = start;
1036    for (rel, dest) in &pat.chain {
1037        let rel_name = name_rel(rel, rel_anon, bound);
1038        bound.insert(rel_name.clone());
1039        rel_bound.insert(rel_name.clone());
1040        let to = name_node(dest, node_anon, bound);
1041
1042        if let Some(hops) = rel.hops {
1043            if pat.shortest {
1044                // shortestPath: destination must also already be bound.
1045                if !bound.contains(&to) {
1046                    return Err(format!(
1047                        "shortestPath: destination node `{to}` is not bound; \
1048                         bind both endpoints before shortestPath"
1049                    ));
1050                }
1051                // A minimum hop count > 1 is not supported for shortestPath —
1052                // the BFS always returns the shortest (lowest-hop) path, so a
1053                // min constraint would silently be ignored.  Reject explicitly.
1054                if hops.min > 1 {
1055                    return Err(format!(
1056                        "shortestPath does not support a minimum hop count \
1057                         (got min={}); use a plain variable-length pattern \
1058                         if you need a minimum",
1059                        hops.min
1060                    ));
1061                }
1062                ops.push(PlanOp::ShortestPath {
1063                    from: from.clone(),
1064                    rel_var: Some(rel_name),
1065                    etypes: rel.etypes.clone(),
1066                    dir: rel.dir,
1067                    to: to.clone(),
1068                    max_hops: hops.max,
1069                });
1070            } else {
1071                ops.push(PlanOp::VarExpand {
1072                    from: from.clone(),
1073                    rel_var: Some(rel_name),
1074                    etypes: rel.etypes.clone(),
1075                    dir: rel.dir,
1076                    to: to.clone(),
1077                    min: hops.min,
1078                    max: hops.max,
1079                });
1080                bound.insert(to.clone());
1081            }
1082        } else {
1083            ops.push(PlanOp::Expand {
1084                from: from.clone(),
1085                rel_var: Some(rel_name),
1086                etypes: rel.etypes.clone(),
1087                dir: rel.dir,
1088                to: to.clone(),
1089                to_label: dest.label.clone(),
1090                to_props: dest.props.clone(),
1091            });
1092            bound.insert(to.clone());
1093        }
1094        from = to;
1095    }
1096    Ok(())
1097}
1098
1099/// Compile one `OPTIONAL MATCH` clause into a `LeftOuterApply` op.
1100///
1101/// The inner plan is compiled from the pattern(s) and optional WHERE, starting
1102/// from a copy of the outer bound set.  Variables introduced inside the optional
1103/// scope are collected as `optional_vars` — they will be nulled in the fallback
1104/// row when the inner plan produces no results.
1105fn compile_optional_clause(
1106    oc: &OptionalClause,
1107    ops: &mut Vec<PlanOp>,
1108    bound: &mut BTreeSet<String>,
1109    rel_bound: &mut BTreeSet<String>,
1110    node_anon: &mut u32,
1111    rel_anon: &mut u32,
1112) -> Result<(), String> {
1113    // Clone the outer bound state; the inner plan compiles against it.
1114    let mut inner_bound = bound.clone();
1115    let mut inner_rel_bound = rel_bound.clone();
1116    let mut inner_ops: Vec<PlanOp> = Vec::new();
1117
1118    for pat in &oc.patterns {
1119        compile_pattern(
1120            pat,
1121            &mut inner_ops,
1122            &mut inner_bound,
1123            &mut inner_rel_bound,
1124            node_anon,
1125            rel_anon,
1126        )?;
1127    }
1128    if let Some(expr) = &oc.where_expr {
1129        check_expr_bound(expr, &inner_bound)?;
1130        inner_ops.push(PlanOp::Filter { expr: expr.clone() });
1131    }
1132
1133    // Variables newly introduced by the optional clause.
1134    let optional_vars: Vec<String> = inner_bound
1135        .difference(bound)
1136        .chain(inner_rel_bound.difference(rel_bound))
1137        .cloned()
1138        .collect();
1139
1140    // Merge inner-introduced vars into the outer bound set so subsequent
1141    // clauses can reference them (they may be null, but they are "bound").
1142    for v in &optional_vars {
1143        bound.insert(v.clone());
1144    }
1145    for v in inner_rel_bound
1146        .difference(&*rel_bound)
1147        .cloned()
1148        .collect::<Vec<_>>()
1149    {
1150        rel_bound.insert(v);
1151    }
1152
1153    ops.push(PlanOp::LeftOuterApply {
1154        inner: inner_ops,
1155        optional_vars,
1156    });
1157    Ok(())
1158}
1159
1160fn name_node(node: &NodePat, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1161    match &node.var {
1162        Some(v) => v.clone(),
1163        None => fresh("_n", counter, bound),
1164    }
1165}
1166
1167fn name_rel(rel: &RelPat, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1168    match &rel.var {
1169        Some(v) => v.clone(),
1170        None => fresh("_r", counter, bound),
1171    }
1172}
1173
1174/// Stable `_nN` / `_rN` in encounter order. Skips names already bound so a
1175/// user var `_n0` does not collide with the next anonymous node.
1176fn fresh(prefix: &str, counter: &mut u32, bound: &BTreeSet<String>) -> String {
1177    for _ in 0..=u32::MAX {
1178        let name = format!("{prefix}{counter}");
1179        *counter = counter.wrapping_add(1);
1180        if !bound.contains(&name) {
1181            return name;
1182        }
1183    }
1184    format!("{prefix}x")
1185}
1186
1187fn check_expr_bound(expr: &Expr, bound: &BTreeSet<String>) -> Result<(), String> {
1188    match expr {
1189        Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => {
1190            check_expr_bound(lhs, bound)?;
1191            check_expr_bound(rhs, bound)
1192        }
1193        Expr::Not(inner) => check_expr_bound(inner, bound),
1194        Expr::Cmp { lhs, rhs, .. } => {
1195            check_operand_bound(lhs, bound, "WHERE")?;
1196            check_operand_bound(rhs, bound, "WHERE")
1197        }
1198        Expr::Truthy(op) => check_operand_bound(op, bound, "WHERE"),
1199        Expr::IsNull(op) | Expr::IsNotNull(op) => check_operand_bound(op, bound, "WHERE"),
1200        Expr::In { expr, list } => {
1201            check_operand_bound(expr, bound, "WHERE")?;
1202            for item in list {
1203                check_operand_bound(item, bound, "WHERE")?;
1204            }
1205            Ok(())
1206        }
1207    }
1208}
1209
1210fn check_operand_bound(
1211    operand: &Operand,
1212    bound: &BTreeSet<String>,
1213    clause: &str,
1214) -> Result<(), String> {
1215    match operand {
1216        Operand::Prop { var, .. } => require_bound(var, bound, clause),
1217        Operand::Lit(_) | Operand::Param(_) => Ok(()),
1218        Operand::Var(name) => require_bound(name, bound, clause),
1219        Operand::BinArith { left, right, .. } => {
1220            check_operand_bound(left, bound, clause)?;
1221            check_operand_bound(right, bound, clause)
1222        }
1223        Operand::Index { base, index } => {
1224            check_operand_bound(base, bound, clause)?;
1225            check_operand_bound(index, bound, clause)
1226        }
1227        Operand::FuncCall { args, .. } => {
1228            for arg in args {
1229                check_operand_bound(arg, bound, clause)?;
1230            }
1231            Ok(())
1232        }
1233        Operand::Case { branches, default } => {
1234            for (cond, value) in branches {
1235                check_expr_bound(cond, bound)?;
1236                check_operand_bound(value, bound, clause)?;
1237            }
1238            if let Some(d) = default {
1239                check_operand_bound(d, bound, clause)?;
1240            }
1241            Ok(())
1242        }
1243    }
1244}
1245
1246/// Validate that any variable referenced in an UNWIND expression is already bound.
1247fn check_unwind_bound(expr: &UnwindExpr, bound: &BTreeSet<String>) -> Result<(), String> {
1248    match expr {
1249        UnwindExpr::Lit(_) => Ok(()),
1250        UnwindExpr::Prop { var, .. } => require_bound(var, bound, "UNWIND"),
1251        UnwindExpr::Var(name) => require_bound(name, bound, "UNWIND"),
1252    }
1253}
1254
1255fn require_bound(var: &str, bound: &BTreeSet<String>, clause: &str) -> Result<(), String> {
1256    if bound.contains(var) {
1257        Ok(())
1258    } else {
1259        Err(format!("unbound variable `{var}` in {clause}"))
1260    }
1261}
1262
1263fn reject_bare_rel(var: &str, rel_bound: &BTreeSet<String>) -> Result<(), String> {
1264    if rel_bound.contains(var) {
1265        Err(format!(
1266            "cannot return relationship variable '{var}' bare; return its properties ({var}.field) instead"
1267        ))
1268    } else {
1269        Ok(())
1270    }
1271}
1272
1273fn check_return_bound(
1274    items: &[RetItem],
1275    bound: &BTreeSet<String>,
1276    rel_bound: &BTreeSet<String>,
1277) -> Result<(), String> {
1278    for item in items {
1279        match &item.value {
1280            RetVal::Var(v) => {
1281                require_bound(v, bound, "RETURN")?;
1282                reject_bare_rel(v, rel_bound)?;
1283            }
1284            RetVal::Prop { var, .. } => {
1285                require_bound(var, bound, "RETURN")?;
1286            }
1287            RetVal::Agg { arg, .. } => check_agg_arg_bound(arg, bound)?,
1288            RetVal::FuncCall { args, .. } => {
1289                for arg in args {
1290                    check_operand_bound(arg, bound, "RETURN")?;
1291                }
1292            }
1293            RetVal::ScalarExpr(op) => {
1294                check_operand_bound(op, bound, "RETURN")?;
1295            }
1296        }
1297    }
1298    Ok(())
1299}
1300
1301fn check_duplicate_aliases(items: &[RetItem]) -> Result<(), String> {
1302    let mut seen = BTreeSet::new();
1303    for item in items {
1304        if let Some(alias) = &item.alias {
1305            if !seen.insert(alias.clone()) {
1306                return Err(format!("duplicate RETURN alias `{alias}`"));
1307            }
1308        }
1309    }
1310    Ok(())
1311}
1312
1313fn check_duplicate_columns(items: &[RetItem]) -> Result<(), String> {
1314    let mut seen = BTreeSet::new();
1315    for item in items {
1316        let col = column_name(item);
1317        if !seen.insert(col.clone()) {
1318            return Err(format!("duplicate RETURN column `{col}`"));
1319        }
1320    }
1321    Ok(())
1322}
1323
1324/// Projected column name: alias if given, else the bare var, else `var.field`,
1325/// else the canonical aggregate call string, else `funcname(...)`, else `<expr>`.
1326fn column_name(item: &RetItem) -> String {
1327    if let Some(alias) = &item.alias {
1328        return alias.clone();
1329    }
1330    ret_val_label(&item.value).unwrap_or_else(|| match &item.value {
1331        RetVal::Agg { func, arg } => agg_column_name(func, arg),
1332        _ => unreachable!("ret_val_label names every non-aggregate item"),
1333    })
1334}
1335
1336/// Every variable an aggregate argument reads must be bound, through any
1337/// `DISTINCT` wrapper.
1338fn check_agg_arg_bound(arg: &AggArg, bound: &BTreeSet<String>) -> Result<(), String> {
1339    match arg {
1340        AggArg::Star => Ok(()),
1341        AggArg::Var(v) => require_bound(v, bound, "RETURN"),
1342        AggArg::Prop { var, .. } => require_bound(var, bound, "RETURN"),
1343        AggArg::Distinct(inner) => check_agg_arg_bound(inner, bound),
1344    }
1345}
1346
1347/// Canonical string for an aggregate without an alias, e.g. `COUNT(*)`,
1348/// `SUM(n.age)`, `COUNT(DISTINCT t)`.
1349fn agg_column_name(func: &AggFunc, arg: &AggArg) -> String {
1350    let f = func_name(func);
1351    format!("{f}({})", agg_arg_name(arg))
1352}
1353
1354fn agg_arg_name(arg: &AggArg) -> String {
1355    match arg {
1356        AggArg::Star => "*".to_string(),
1357        AggArg::Var(v) => v.clone(),
1358        AggArg::Prop { var, field } => format!("{var}.{field}"),
1359        AggArg::Distinct(inner) => format!("DISTINCT {}", agg_arg_name(inner)),
1360    }
1361}
1362
1363fn func_name(func: &AggFunc) -> &'static str {
1364    match func {
1365        AggFunc::Count => "COUNT",
1366        AggFunc::Sum => "SUM",
1367        AggFunc::Avg => "AVG",
1368        AggFunc::Min => "MIN",
1369        AggFunc::Max => "MAX",
1370        AggFunc::Collect => "COLLECT",
1371    }
1372}
1373
1374fn rewrite_order_item(
1375    item: &OrderItem,
1376    returns: &[RetItem],
1377    bound: &BTreeSet<String>,
1378    rel_bound: &BTreeSet<String>,
1379) -> Result<OrderItem, String> {
1380    let column = match &item.target {
1381        OrderTarget::Alias(name) => {
1382            if returns
1383                .iter()
1384                .any(|r| r.alias.as_deref() == Some(name.as_str()))
1385            {
1386                name.clone()
1387            } else {
1388                return Err(format!("ORDER BY target `{name}` is not present in RETURN"));
1389            }
1390        }
1391        OrderTarget::Var(v) => {
1392            require_bound(v, bound, "ORDER BY")?;
1393            reject_bare_rel(v, rel_bound)?;
1394            match returns
1395                .iter()
1396                .find(|r| matches!(&r.value, RetVal::Var(x) if x == v))
1397            {
1398                Some(r) => column_name(r),
1399                None => {
1400                    return Err(format!("ORDER BY target `{v}` is not present in RETURN"));
1401                }
1402            }
1403        }
1404        OrderTarget::Prop { var, field } => {
1405            require_bound(var, bound, "ORDER BY")?;
1406            match returns.iter().find(
1407                |r| matches!(&r.value, RetVal::Prop { var: v, field: f } if v == var && f == field),
1408            ) {
1409                Some(r) => column_name(r),
1410                None => {
1411                    return Err(format!(
1412                        "ORDER BY target `{var}.{field}` is not present in RETURN"
1413                    ));
1414                }
1415            }
1416        }
1417    };
1418    Ok(OrderItem {
1419        target: OrderTarget::Alias(column),
1420        descending: item.descending,
1421    })
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426    use super::{plan, PlanOp};
1427    use crate::cypher::ast::{Expr, LimitSkip, Operand, OrderItem, OrderTarget, RetItem, RetVal};
1428    use crate::cypher::{lex, parse, RelDir};
1429    use crate::filter::CmpOp;
1430    use core_storage::Value;
1431
1432    fn plan_src(src: &str) -> Result<Vec<PlanOp>, String> {
1433        plan(&parse(&lex(src)?)?)
1434    }
1435
1436    fn assert_plan_err(src: &str, needle: &str) -> String {
1437        let result = std::panic::catch_unwind(|| plan_src(src));
1438        assert!(result.is_ok(), "plan({src:?}) panicked");
1439        let err = result
1440            .unwrap()
1441            .expect_err(&format!("plan({src:?}) must be Err"));
1442        assert!(
1443            err.contains(needle),
1444            "error must mention {needle:?}, got: {err}"
1445        );
1446        err
1447    }
1448
1449    /// Dogfood query from T6. Shape:
1450    /// - MATCH 1: `t` unbound with `{id: $tid}` → `ScanKey`.
1451    /// - MATCH 2: `c` unbound, dest `t` already bound, single-rel → reverse:
1452    ///   Expand from `t` dir Left (inbound) to `c` (Company label on `to`).
1453    /// - MATCH 3: start `c` already bound → `JoinBound`; expand to bound `t`.
1454    #[test]
1455    fn dogfood_query_exact_plan() {
1456        let src = "\
1457MATCH (t:Talent {id: $tid}) \
1458MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
1459MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
1460WHERE i.score >= 0.5 AND s.score >= 0.5 \
1461RETURN c, i.score AS industry, s.score AS specialty \
1462ORDER BY industry DESC, specialty DESC \
1463LIMIT 10";
1464        let got = plan_src(src).expect("dogfood query must plan");
1465        let expected = vec![
1466            PlanOp::ScanKey {
1467                var: "t".into(),
1468                key: Operand::Param("tid".into()),
1469                label: Some("Talent".into()),
1470            },
1471            PlanOp::Expand {
1472                from: "t".into(),
1473                rel_var: Some("i".into()),
1474                etypes: vec!["INDUSTRY_ALIGNMENT".into()],
1475                dir: RelDir::Left,
1476                to: "c".into(),
1477                to_label: Some("Company".into()),
1478                to_props: vec![],
1479            },
1480            PlanOp::JoinBound {
1481                var: "c".into(),
1482                label: None,
1483                props: vec![],
1484            },
1485            PlanOp::Expand {
1486                from: "c".into(),
1487                rel_var: Some("s".into()),
1488                etypes: vec!["SPECIALTY_MATCH".into()],
1489                dir: RelDir::Right,
1490                to: "t".into(),
1491                to_label: None,
1492                to_props: vec![],
1493            },
1494            PlanOp::Filter {
1495                expr: Expr::And(
1496                    Box::new(Expr::Cmp {
1497                        lhs: Operand::Prop {
1498                            var: "i".into(),
1499                            field: "score".into(),
1500                        },
1501                        op: CmpOp::Ge,
1502                        rhs: Operand::Lit(Value::Float(0.5)),
1503                    }),
1504                    Box::new(Expr::Cmp {
1505                        lhs: Operand::Prop {
1506                            var: "s".into(),
1507                            field: "score".into(),
1508                        },
1509                        op: CmpOp::Ge,
1510                        rhs: Operand::Lit(Value::Float(0.5)),
1511                    }),
1512                ),
1513            },
1514            PlanOp::Project {
1515                items: vec![
1516                    RetItem {
1517                        value: RetVal::Var("c".into()),
1518                        alias: None,
1519                    },
1520                    RetItem {
1521                        value: RetVal::Prop {
1522                            var: "i".into(),
1523                            field: "score".into(),
1524                        },
1525                        alias: Some("industry".into()),
1526                    },
1527                    RetItem {
1528                        value: RetVal::Prop {
1529                            var: "s".into(),
1530                            field: "score".into(),
1531                        },
1532                        alias: Some("specialty".into()),
1533                    },
1534                ],
1535            },
1536            PlanOp::OrderBy {
1537                items: vec![
1538                    OrderItem {
1539                        target: OrderTarget::Alias("industry".into()),
1540                        descending: true,
1541                    },
1542                    OrderItem {
1543                        target: OrderTarget::Alias("specialty".into()),
1544                        descending: true,
1545                    },
1546                ],
1547            },
1548            PlanOp::Limit(LimitSkip::Exact(10)),
1549        ];
1550        assert_eq!(got, expected);
1551    }
1552
1553    /// Anonymous names increment in encounter order across the whole query.
1554    /// MATCH 1: start `_n0`, rel `_r0`, dest `a`.
1555    /// MATCH 2: start `_n1` unbound, dest already-bound `a` → reverse Expand
1556    /// from `a` dir Left to `_n1`.
1557    #[test]
1558    fn anonymous_node_and_rel_names_are_stable() {
1559        let got = plan_src("MATCH ()-[]->(a) MATCH ()-[]->(a) RETURN a").unwrap();
1560        assert_eq!(
1561            got,
1562            vec![
1563                PlanOp::ScanLabel {
1564                    var: "_n0".into(),
1565                    label: None,
1566                },
1567                PlanOp::Expand {
1568                    from: "_n0".into(),
1569                    rel_var: Some("_r0".into()),
1570                    etypes: vec![],
1571                    dir: RelDir::Right,
1572                    to: "a".into(),
1573                    to_label: None,
1574                    to_props: vec![],
1575                },
1576                PlanOp::Expand {
1577                    from: "a".into(),
1578                    rel_var: Some("_r1".into()),
1579                    etypes: vec![],
1580                    dir: RelDir::Left,
1581                    to: "_n1".into(),
1582                    to_label: None,
1583                    to_props: vec![],
1584                },
1585                PlanOp::Project {
1586                    items: vec![RetItem {
1587                        value: RetVal::Var("a".into()),
1588                        alias: None,
1589                    }],
1590                },
1591            ]
1592        );
1593    }
1594
1595    #[test]
1596    fn props_on_scan_node_emit_scan_then_lookup() {
1597        let got = plan_src("MATCH (t:Talent {id: $tid}) RETURN t").unwrap();
1598        assert_eq!(
1599            got,
1600            vec![
1601                PlanOp::ScanKey {
1602                    var: "t".into(),
1603                    key: Operand::Param("tid".into()),
1604                    label: Some("Talent".into()),
1605                },
1606                PlanOp::Project {
1607                    items: vec![RetItem {
1608                        value: RetVal::Var("t".into()),
1609                        alias: None,
1610                    }],
1611                },
1612            ]
1613        );
1614    }
1615
1616    #[test]
1617    fn mixed_id_map_stays_scan_label_then_lookup() {
1618        let got = plan_src("MATCH (t:Talent {id: $k, name: 'x'}) RETURN t").unwrap();
1619        assert_eq!(
1620            got,
1621            vec![
1622                PlanOp::ScanLabel {
1623                    var: "t".into(),
1624                    label: Some("Talent".into()),
1625                },
1626                PlanOp::LookupProps {
1627                    var: "t".into(),
1628                    props: vec![
1629                        ("id".into(), Operand::Param("k".into())),
1630                        ("name".into(), Operand::Lit(Value::Str("x".into()))),
1631                    ],
1632                },
1633                PlanOp::Project {
1634                    items: vec![RetItem {
1635                        value: RetVal::Var("t".into()),
1636                        alias: None,
1637                    }],
1638                },
1639            ]
1640        );
1641    }
1642
1643    #[test]
1644    fn plan_id_map_is_scan_key() {
1645        let toks = crate::cypher::lex("MATCH (n:Person {id: $k}) RETURN n").unwrap();
1646        let q = crate::cypher::parse(&toks).unwrap();
1647        let ops = plan(&q).unwrap();
1648        assert!(matches!(ops[0], PlanOp::ScanKey { .. }), "{ops:?}");
1649    }
1650
1651    #[test]
1652    fn plan_expands_from_bound_key() {
1653        let cy =
1654            "MATCH (t:Talent {id: $tid}) MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) RETURN c";
1655        let ops = plan(&crate::cypher::parse(&crate::cypher::lex(cy).unwrap()).unwrap()).unwrap();
1656        // first: ScanKey t; then Expand from t, dir Left (inbound)
1657        assert!(matches!(&ops[0], PlanOp::ScanKey { var, .. } if var == "t"));
1658        match &ops[1] {
1659            PlanOp::Expand { from, dir, to, .. } => {
1660                assert_eq!(from, "t");
1661                assert_eq!(to, "c");
1662                assert_eq!(*dir, RelDir::Left);
1663            }
1664            other => panic!("{other:?}"),
1665        }
1666    }
1667
1668    /// VarExpand has no dest label/prop filter. Reversing
1669    /// `MATCH (c:Company)-[*1..2]->(t)` would drop `:Company` and bind
1670    /// non-Company `c`. Keep LTR: ScanLabel Company then VarExpand.
1671    #[test]
1672    fn plan_does_not_reverse_variable_length_from_bound() {
1673        let cy = "MATCH (t {id: $tid}) MATCH (c:Company)-[*1..2]->(t) RETURN c";
1674        let ops = plan_src(cy).unwrap();
1675        assert!(
1676            matches!(&ops[0], PlanOp::ScanKey { var, .. } if var == "t"),
1677            "{ops:?}"
1678        );
1679        assert!(
1680            matches!(&ops[1], PlanOp::ScanLabel { var, label } if var == "c" && label.as_deref() == Some("Company")),
1681            "{ops:?}"
1682        );
1683        match &ops[2] {
1684            PlanOp::VarExpand {
1685                from,
1686                dir,
1687                to,
1688                min,
1689                max,
1690                ..
1691            } => {
1692                assert_eq!(from, "c");
1693                assert_eq!(to, "t");
1694                assert_eq!(*dir, RelDir::Right);
1695                assert_eq!(*min, 1);
1696                assert_eq!(*max, 2);
1697            }
1698            other => panic!("{other:?}"),
1699        }
1700    }
1701
1702    #[test]
1703    fn unbound_var_in_where_is_err() {
1704        let err = assert_plan_err("MATCH (a) WHERE b.x = 1 RETURN a", "b");
1705        assert!(
1706            err.to_ascii_lowercase().contains("unbound")
1707                && err.to_ascii_lowercase().contains("where"),
1708            "expected unbound-in-WHERE context, got: {err}"
1709        );
1710    }
1711
1712    #[test]
1713    fn unbound_var_in_return_is_err() {
1714        let err = assert_plan_err("MATCH (a) RETURN b", "b");
1715        assert!(
1716            err.to_ascii_lowercase().contains("unbound")
1717                && err.to_ascii_lowercase().contains("return"),
1718            "expected unbound-in-RETURN context, got: {err}"
1719        );
1720    }
1721
1722    #[test]
1723    fn unbound_var_in_order_by_is_err() {
1724        let err = assert_plan_err("MATCH (a) RETURN a ORDER BY b", "b");
1725        assert!(
1726            err.to_ascii_lowercase().contains("unbound")
1727                && (err.to_ascii_lowercase().contains("order")),
1728            "expected unbound-in-ORDER context, got: {err}"
1729        );
1730    }
1731
1732    #[test]
1733    fn duplicate_alias_is_err() {
1734        let err = assert_plan_err("MATCH (a) RETURN a AS x, a.id AS x", "x");
1735        assert!(
1736            err.to_ascii_lowercase().contains("duplicate")
1737                && err.to_ascii_lowercase().contains("alias"),
1738            "expected duplicate-alias context, got: {err}"
1739        );
1740    }
1741
1742    #[test]
1743    fn duplicate_column_name_is_err() {
1744        let err = assert_plan_err("MATCH (a) RETURN a, a", "a");
1745        assert!(
1746            err.to_ascii_lowercase().contains("duplicate")
1747                && err.to_ascii_lowercase().contains("column"),
1748            "expected duplicate-column context, got: {err}"
1749        );
1750    }
1751
1752    #[test]
1753    fn order_by_target_absent_from_return_is_err() {
1754        // `a` is bound, but `a.x` is not a RETURN item.
1755        let err = assert_plan_err("MATCH (a) RETURN a ORDER BY a.x", "a.x");
1756        assert!(
1757            err.to_ascii_lowercase().contains("return"),
1758            "expected ORDER BY target-not-in-RETURN context, got: {err}"
1759        );
1760    }
1761
1762    /// Alias → that alias's column; bare var → its RETURN column (alias if
1763    /// given, else the var name); un-aliased prop → `var.field`.
1764    #[test]
1765    fn order_by_targets_rewrite_to_projected_column_names() {
1766        let got = plan_src(
1767            "MATCH (a)-[r]->(b) \
1768             RETURN a, a.name AS nm, b.age \
1769             ORDER BY nm DESC, a ASC, b.age",
1770        )
1771        .unwrap();
1772        let order = got
1773            .iter()
1774            .find_map(|op| match op {
1775                PlanOp::OrderBy { items } => Some(items),
1776                _ => None,
1777            })
1778            .expect("plan must contain OrderBy");
1779        assert_eq!(
1780            order,
1781            &vec![
1782                OrderItem {
1783                    target: OrderTarget::Alias("nm".into()),
1784                    descending: true,
1785                },
1786                OrderItem {
1787                    target: OrderTarget::Alias("a".into()),
1788                    descending: false,
1789                },
1790                OrderItem {
1791                    target: OrderTarget::Alias("b.age".into()),
1792                    descending: false,
1793                },
1794            ]
1795        );
1796
1797        let aliased_var = plan_src("MATCH (a) RETURN a AS person ORDER BY a").unwrap();
1798        let order = aliased_var
1799            .iter()
1800            .find_map(|op| match op {
1801                PlanOp::OrderBy { items } => Some(items),
1802                _ => None,
1803            })
1804            .expect("plan must contain OrderBy");
1805        assert_eq!(
1806            order,
1807            &vec![OrderItem {
1808                target: OrderTarget::Alias("person".into()),
1809                descending: false,
1810            }]
1811        );
1812    }
1813
1814    #[test]
1815    fn bound_pattern_start_is_join_bound_then_expand() {
1816        let got = plan_src("MATCH (a:L) MATCH (a)-[r:T]->(b) RETURN a, b").unwrap();
1817        assert_eq!(
1818            got,
1819            vec![
1820                PlanOp::ScanLabel {
1821                    var: "a".into(),
1822                    label: Some("L".into()),
1823                },
1824                PlanOp::JoinBound {
1825                    var: "a".into(),
1826                    label: None,
1827                    props: vec![],
1828                },
1829                PlanOp::Expand {
1830                    from: "a".into(),
1831                    rel_var: Some("r".into()),
1832                    etypes: vec!["T".into()],
1833                    dir: RelDir::Right,
1834                    to: "b".into(),
1835                    to_label: None,
1836                    to_props: vec![],
1837                },
1838                PlanOp::Project {
1839                    items: vec![
1840                        RetItem {
1841                            value: RetVal::Var("a".into()),
1842                            alias: None,
1843                        },
1844                        RetItem {
1845                            value: RetVal::Var("b".into()),
1846                            alias: None,
1847                        },
1848                    ],
1849                },
1850            ]
1851        );
1852    }
1853
1854    #[test]
1855    fn bound_dest_extra_checks_ride_on_expand() {
1856        let got = plan_src("MATCH (t:Talent) MATCH (c)-[r]->(t:Talent {id: 1}) RETURN t").unwrap();
1857        assert_eq!(
1858            got,
1859            vec![
1860                PlanOp::ScanLabel {
1861                    var: "t".into(),
1862                    label: Some("Talent".into()),
1863                },
1864                PlanOp::JoinBound {
1865                    var: "t".into(),
1866                    label: Some("Talent".into()),
1867                    props: vec![("id".into(), Operand::Lit(Value::Int(1)))],
1868                },
1869                PlanOp::Expand {
1870                    from: "t".into(),
1871                    rel_var: Some("r".into()),
1872                    etypes: vec![],
1873                    dir: RelDir::Left,
1874                    to: "c".into(),
1875                    to_label: None,
1876                    to_props: vec![],
1877                },
1878                PlanOp::Project {
1879                    items: vec![RetItem {
1880                        value: RetVal::Var("t".into()),
1881                        alias: None,
1882                    }],
1883                },
1884            ]
1885        );
1886    }
1887
1888    #[test]
1889    fn return_distinct_emits_distinct_after_project() {
1890        let ops = plan_src("MATCH (n) RETURN DISTINCT n").expect("DISTINCT must plan");
1891        let proj = ops
1892            .iter()
1893            .position(|op| matches!(op, PlanOp::Project { .. }))
1894            .expect("Project");
1895        assert!(
1896            matches!(ops.get(proj + 1), Some(PlanOp::Distinct)),
1897            "DISTINCT must follow Project, got: {ops:?}"
1898        );
1899        let bounded = plan_src("MATCH (n) RETURN DISTINCT n LIMIT 1").unwrap();
1900        assert!(
1901            super::row_bound(&bounded).is_none(),
1902            "DISTINCT + LIMIT must not push LIMIT into producers"
1903        );
1904    }
1905
1906    #[test]
1907    fn skip_then_limit_follow_project() {
1908        let got = plan_src("MATCH (a) RETURN a SKIP 2 LIMIT 3").unwrap();
1909        assert_eq!(
1910            got,
1911            vec![
1912                PlanOp::ScanLabel {
1913                    var: "a".into(),
1914                    label: None,
1915                },
1916                PlanOp::Project {
1917                    items: vec![RetItem {
1918                        value: RetVal::Var("a".into()),
1919                        alias: None,
1920                    }],
1921                },
1922                PlanOp::Skip(LimitSkip::Exact(2)),
1923                PlanOp::Limit(LimitSkip::Exact(3)),
1924            ]
1925        );
1926    }
1927
1928    #[test]
1929    fn aliased_prop_order_by_rewrites_to_alias_column() {
1930        let got = plan_src("MATCH (a) RETURN a.name AS nm ORDER BY a.name").unwrap();
1931        let order = got
1932            .iter()
1933            .find_map(|op| match op {
1934                PlanOp::OrderBy { items } => Some(items),
1935                _ => None,
1936            })
1937            .unwrap();
1938        assert_eq!(
1939            order,
1940            &vec![OrderItem {
1941                target: OrderTarget::Alias("nm".into()),
1942                descending: false,
1943            }]
1944        );
1945    }
1946
1947    #[test]
1948    fn plan_never_panics_on_hand_built_query() {
1949        use crate::cypher::ast::{NodePat, Pattern, Query};
1950        let q = Query {
1951            matches: vec![],
1952            optional_clauses: vec![],
1953            where_expr: None,
1954            unwinds: vec![],
1955            post_unwind_where: None,
1956            stages: vec![],
1957            returns: vec![],
1958            order_by: vec![],
1959            distinct: false,
1960            skip: None,
1961            limit: None,
1962        };
1963        let result = std::panic::catch_unwind(|| plan(&q));
1964        assert!(result.is_ok(), "plan panicked on empty Query");
1965        let _ = result.unwrap();
1966
1967        let q = Query {
1968            matches: vec![Pattern {
1969                start: NodePat {
1970                    var: None,
1971                    label: None,
1972                    props: vec![],
1973                },
1974                chain: vec![],
1975                shortest: false,
1976            }],
1977            optional_clauses: vec![],
1978            where_expr: Some(Expr::Not(Box::new(Expr::Cmp {
1979                lhs: Operand::Param("p".into()),
1980                op: CmpOp::Eq,
1981                rhs: Operand::Lit(Value::Int(1)),
1982            }))),
1983            unwinds: vec![],
1984            post_unwind_where: None,
1985            stages: vec![],
1986            returns: vec![],
1987            distinct: false,
1988            order_by: vec![OrderItem {
1989                target: OrderTarget::Alias("missing".into()),
1990                descending: true,
1991            }],
1992            skip: Some(LimitSkip::Exact(0)),
1993            limit: Some(LimitSkip::Exact(0)),
1994        };
1995        let result = std::panic::catch_unwind(|| plan(&q));
1996        assert!(result.is_ok(), "plan panicked on hand-built Query");
1997        let _ = result.unwrap();
1998    }
1999
2000    #[test]
2001    fn bare_relationship_var_in_return_is_err() {
2002        let err = assert_plan_err("MATCH (a)-[r:T]->(b) RETURN r", "r");
2003        assert!(
2004            err.to_ascii_lowercase().contains("relationship"),
2005            "expected bare-rel RETURN guidance, got: {err}"
2006        );
2007    }
2008
2009    #[test]
2010    fn relationship_prop_in_return_is_ok() {
2011        plan_src("MATCH (a)-[r:T]->(b) RETURN r.w").expect("rel prop RETURN must plan");
2012    }
2013
2014    #[test]
2015    fn bare_relationship_var_in_order_by_is_err() {
2016        // RETURN r.w is legal; ORDER BY r is a bare rel var (defense, not just
2017        // "not in RETURN").
2018        let err = assert_plan_err("MATCH (a)-[r:T]->(b) RETURN r.w ORDER BY r", "r");
2019        assert!(
2020            err.to_ascii_lowercase().contains("relationship"),
2021            "expected bare-rel ORDER BY guidance, got: {err}"
2022        );
2023    }
2024
2025    #[test]
2026    fn relationship_prop_in_order_by_is_ok() {
2027        plan_src("MATCH (a)-[r:T]->(b) RETURN r.w ORDER BY r.w")
2028            .expect("rel prop ORDER BY must plan");
2029    }
2030
2031    // ── Variable-length path and shortestPath planner tests ───────────────────
2032
2033    #[test]
2034    fn var_expand_op_emitted_for_star_rel() {
2035        use super::row_bound;
2036        let ops = plan_src("MATCH (a)-[r:T*2..4]->(b) RETURN b").unwrap();
2037        let has_var = ops
2038            .iter()
2039            .any(|op| matches!(op, PlanOp::VarExpand { min: 2, max: 4, .. }));
2040        assert!(has_var, "expected VarExpand(2..4) in plan, got: {ops:?}");
2041        // row_bound must be None even when no ORDER BY (VarExpand overrides pull routing)
2042        assert_eq!(
2043            row_bound(&ops),
2044            None,
2045            "VarExpand plan must not use pull path"
2046        );
2047    }
2048
2049    #[test]
2050    fn var_expand_with_limit_still_takes_staged_path() {
2051        use super::row_bound;
2052        let ops = plan_src("MATCH (a)-[r:T*1..3]->(b) RETURN b LIMIT 5").unwrap();
2053        // Staged path: row_bound returns None for VarExpand.
2054        assert_eq!(
2055            row_bound(&ops),
2056            None,
2057            "VarExpand + LIMIT must still use staged path"
2058        );
2059        let has_var = ops.iter().any(|op| matches!(op, PlanOp::VarExpand { .. }));
2060        assert!(has_var, "plan must contain VarExpand");
2061        let has_limit = ops
2062            .iter()
2063            .any(|op| matches!(op, PlanOp::Limit(LimitSkip::Exact(5))));
2064        assert!(has_limit, "plan must still emit Limit op");
2065    }
2066
2067    #[test]
2068    fn shortest_path_op_emitted_for_shortest_path_clause() {
2069        let ops =
2070            plan_src("MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*..3]->(b)) RETURN a")
2071                .unwrap();
2072        let has_sp = ops
2073            .iter()
2074            .any(|op| matches!(op, PlanOp::ShortestPath { max_hops: 3, .. }));
2075        assert!(
2076            has_sp,
2077            "expected ShortestPath op with max_hops=3, got: {ops:?}"
2078        );
2079    }
2080
2081    #[test]
2082    fn shortest_path_unbound_endpoint_is_err() {
2083        let err = assert_plan_err(
2084            "MATCH shortestPath((a)-[r:T*..3]->(b)) RETURN a",
2085            "shortestPath",
2086        );
2087        assert!(
2088            err.contains("not bound") || err.contains("bound"),
2089            "error must mention binding, got: {err}"
2090        );
2091    }
2092
2093    #[test]
2094    fn var_expand_rel_var_is_in_rel_bound() {
2095        // r.length should be allowed in RETURN (prop access)
2096        plan_src("MATCH (a)-[r:T*1..3]->(b) RETURN r.length").expect("r.length must plan");
2097        // bare r must be rejected
2098        assert_plan_err("MATCH (a)-[r:T*1..3]->(b) RETURN r", "r");
2099    }
2100
2101    #[test]
2102    fn shortest_path_min_gt_1_is_plan_err() {
2103        // shortestPath with *2..5 must be rejected: min>1 is not supported
2104        let err = assert_plan_err(
2105            "MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*2..5]->(b)) RETURN r.length",
2106            "shortestPath",
2107        );
2108        assert!(
2109            err.contains("minimum"),
2110            "error must mention minimum hop count, got: {err}"
2111        );
2112    }
2113
2114    // ── is_subscribable tests ──────────────────────────────────────────────────
2115
2116    fn subscribable(src: &str) -> bool {
2117        let ops = plan_src(src).expect("must plan");
2118        super::is_subscribable(&ops)
2119    }
2120
2121    #[test]
2122    fn is_subscribable_passes_simple_label_scan() {
2123        assert!(subscribable("MATCH (n:Person) RETURN n"));
2124        assert!(subscribable("MATCH (n:Person) WHERE n.age > 18 RETURN n"));
2125        assert!(subscribable("MATCH (n:Person) RETURN n LIMIT 100"));
2126    }
2127
2128    #[test]
2129    fn is_subscribable_passes_single_hop_expand() {
2130        assert!(subscribable(
2131            "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a"
2132        ));
2133        assert!(subscribable(
2134            "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a LIMIT 50"
2135        ));
2136    }
2137
2138    #[test]
2139    fn is_subscribable_rejects_multi_hop_expand() {
2140        // Two Expand ops: outside the documented single-hop subset.
2141        assert!(
2142            !subscribable("MATCH (a:Person)-[r1:KNOWS]->(b:Person)-[r2:LIKES]->(c:Thing) RETURN a"),
2143            "two-hop chain must be rejected"
2144        );
2145    }
2146
2147    #[test]
2148    fn is_subscribable_rejects_skip() {
2149        // SKIP creates unstable offset windows — explicitly excluded.
2150        assert!(
2151            !subscribable("MATCH (n:Person) RETURN n SKIP 10 LIMIT 50"),
2152            "SKIP must be rejected"
2153        );
2154        assert!(
2155            !subscribable("MATCH (n:Person) RETURN n SKIP 10"),
2156            "bare SKIP must be rejected"
2157        );
2158    }
2159
2160    #[test]
2161    fn is_subscribable_rejects_order_by() {
2162        assert!(!subscribable("MATCH (n:Person) RETURN n ORDER BY n"));
2163    }
2164
2165    #[test]
2166    fn is_subscribable_rejects_aggregates() {
2167        assert!(!subscribable("MATCH (n:Person) RETURN COUNT(*)"));
2168    }
2169
2170    #[test]
2171    fn is_subscribable_rejects_var_expand() {
2172        assert!(!subscribable(
2173            "MATCH (a:Person)-[r:KNOWS*1..3]->(b) RETURN b"
2174        ));
2175    }
2176
2177    // --- WHERE equality fold tests (T1) ---
2178
2179    #[test]
2180    fn where_equality_folds_to_index_scan() {
2181        let ops = plan_src("MATCH (n:Person) WHERE n.city = 'austin' RETURN n.key").unwrap();
2182        assert!(
2183            matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2184            "WHERE single equality must fold to IndexScan, got {:?}",
2185            ops[0]
2186        );
2187        assert!(
2188            !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2189            "consumed predicate must not remain as Filter"
2190        );
2191    }
2192
2193    #[test]
2194    fn where_equality_param_folds_to_index_scan() {
2195        let ops = plan_src("MATCH (n:Person) WHERE n.city = $c RETURN n.key").unwrap();
2196        assert!(
2197            matches!(&ops[0], PlanOp::IndexScan { .. }),
2198            "param WHERE equality must fold to IndexScan, got {:?}",
2199            ops[0]
2200        );
2201    }
2202
2203    #[test]
2204    fn where_and_keeps_residual_filter() {
2205        let ops = plan_src("MATCH (n:Person) WHERE n.city = 'austin' AND n.age > 30 RETURN n.key")
2206            .unwrap();
2207        assert!(
2208            matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2209            "equality must fold to IndexScan, got {:?}",
2210            ops[0]
2211        );
2212        assert!(
2213            ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2214            "n.age > 30 must remain as residual Filter"
2215        );
2216    }
2217
2218    #[test]
2219    fn where_on_expanded_var_does_not_fold() {
2220        let ops =
2221            plan_src("MATCH (a:Person)-[:KNOWS]->(b:Person) WHERE b.city = 'austin' RETURN a.key")
2222                .unwrap();
2223        assert!(
2224            matches!(&ops[0], PlanOp::ScanLabel { .. } | PlanOp::IndexScan { .. }),
2225            "first op must be a scan, got {:?}",
2226            ops[0]
2227        );
2228        assert!(
2229            ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2230            "b.city filter must remain"
2231        );
2232    }
2233
2234    #[test]
2235    fn where_inline_prop_and_where_equality_both_usable() {
2236        // T2: inline prop + WHERE equality on same var → IndexIntersect with both.
2237        let ops = plan_src("MATCH (n:Person {team: 'core'}) WHERE n.city = 'austin' RETURN n.key")
2238            .unwrap();
2239        assert!(
2240            matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2241            "inline+WHERE equalities must merge to IndexIntersect(2), got {:?}",
2242            ops[0]
2243        );
2244        assert!(
2245            !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2246            "both equalities fully folded; no residual Filter expected"
2247        );
2248    }
2249
2250    // --- IndexIntersect tests (T2) ---
2251
2252    #[test]
2253    fn single_equality_inline_stays_index_scan() {
2254        // Regression: single inline prop must stay IndexScan, not IndexIntersect.
2255        let ops = plan_src("MATCH (n:Person {city: 'austin'}) RETURN n").unwrap();
2256        assert!(
2257            matches!(&ops[0], PlanOp::IndexScan { field, .. } if field == "city"),
2258            "single-equality inline prop must emit IndexScan, got {:?}",
2259            ops[0]
2260        );
2261    }
2262
2263    #[test]
2264    fn compound_inline_props_emit_index_intersect() {
2265        let ops = plan_src("MATCH (n:Doc {namespace: 'a', status: 'live'}) RETURN n.key").unwrap();
2266        assert!(
2267            matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2268            "two inline props must emit IndexIntersect(2), got {:?}",
2269            ops[0]
2270        );
2271    }
2272
2273    #[test]
2274    fn where_two_equalities_emit_index_intersect() {
2275        let ops = plan_src("MATCH (n:Doc) WHERE n.namespace = 'a' AND n.status = $s RETURN n.key")
2276            .unwrap();
2277        assert!(
2278            matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2279            "two WHERE equalities must emit IndexIntersect(2), got {:?}",
2280            ops[0]
2281        );
2282        assert!(
2283            !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2284            "both equalities fully consumed; no residual Filter expected"
2285        );
2286    }
2287
2288    #[test]
2289    fn mixed_inline_and_where_equalities_merge() {
2290        let ops = plan_src("MATCH (n:Doc {namespace: 'a'}) WHERE n.status = 'live' RETURN n.key")
2291            .unwrap();
2292        assert!(
2293            matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 2),
2294            "inline+WHERE equalities must merge to IndexIntersect(2), got {:?}",
2295            ops[0]
2296        );
2297    }
2298
2299    #[test]
2300    fn where_three_equalities_emit_index_intersect() {
2301        let ops = plan_src(
2302            "MATCH (n:Doc) WHERE n.namespace = 'a' AND n.status = 'live' AND n.kind = $k RETURN n",
2303        )
2304        .unwrap();
2305        assert!(
2306            matches!(&ops[0], PlanOp::IndexIntersect { equalities, .. } if equalities.len() == 3),
2307            "three WHERE equalities must emit IndexIntersect(3), got {:?}",
2308            ops[0]
2309        );
2310        assert!(
2311            !ops.iter().any(|op| matches!(op, PlanOp::Filter { .. })),
2312            "all three equalities fully consumed; no residual Filter expected"
2313        );
2314    }
2315}