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