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