Skip to main content

core_query/cypher/
plan.rs

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