Skip to main content

cyrs_plan/
lower.rs

1//! HIR → Plan lowering (spec 0001 §12).
2//!
3//! Entry point: [`lower_statement`]. One call per Cypher statement.
4//!
5//! # Pre-conditions
6//!
7//! The HIR passed in **must** be post-resolve and post-desugar:
8//!
9//! - Name resolution (cy-nres / cy-b4b) must have run so that every
10//!   variable reference is `cyrs_hir::Expr::Var(VarId)` — not
11//!   `Expr::Unresolved`.
12//! - HIR desugaring (cy-mla / `cyrs_hir::desugar`) must have run so
13//!   that `ListComprehension`, `MapProjection`, and `PatternPredicate`
14//!   nodes are absent.
15//!
16//! These pre-conditions are enforced at the entry point by a pre-lowering
17//! sanity scan (bead cy-wlr): a stray `Expr::Unresolved` or un-desugared
18//! construct now yields `Err(PlanLowerError::UnresolvedName)` or
19//! `Err(PlanLowerError::UndesugaredExpr)` respectively (see
20//! [`crate::PlanLowerError`]), rather than a deep panic. The
21//! `debug_assert!`s that guard the same conditions inside the private
22//! `LowerCtx::lower_expr` remain as belt-and-braces checks for defense.
23//!
24//! If you hand this function a freshly-constructed HIR without running
25//! those passes first, you will get one of those errors rather than an
26//! incorrect or incomplete plan.
27//!
28//! # Output shape
29//!
30//! Returns a [`PlanStatement`] whose `ops` vec is the operator arena.
31//! Operators reference each other via [`crate::OpId`] (dense index into
32//! `ops`). The last element of `ops` is the root (i.e. the final
33//! consumer-visible operator). Write operators are collected in
34//! `write_ops` and are applied in order after every read-phase row.
35//! `var_map` translates plan-scoped [`crate::VarId`]s back to HIR
36//! [`cyrs_hir::VarId`]s for diagnostics.
37
38use indexmap::IndexMap;
39use smol_str::SmolStr;
40
41use cyrs_hir::{
42    Clause, Direction as HirDir, Expr as HirExpr, HirSpan, ListPredKind as HirListPredKind,
43    Pattern, PatternElement, PatternPart, Projection, RelLength as HirRelLen, RemoveItem, SetItem,
44    Statement, VarId as HirVarId,
45};
46
47use crate::{
48    AggExpr, BinOp, Direction, Expr, LabelSet, ListPredKind, NodeSpec, OpId, OrderKey,
49    PlanLowerError, Projection as PlanProj, ReadOp, RelLength, RelSpec, UnaryOp, UnionKind, VarId,
50    WriteOp,
51};
52
53// ── Public output type ────────────────────────────────────────────────────────
54
55/// The result of lowering a single HIR [`Statement`] to a logical plan.
56///
57/// `ops` is the operator arena: each entry is a [`ReadOp`] and may
58/// reference earlier entries via [`OpId`]. The last entry is the root.
59/// If the statement has no read phase (e.g. bare `CREATE`), `ops` is
60/// empty and the root is implicit (one write pass over an empty row).
61///
62/// `write_ops` are applied in order after every row produced by the read
63/// phase. For a pure read query they are empty.
64///
65/// `var_map` maps plan-scoped [`VarId`]s back to HIR [`HirVarId`]s for
66/// diagnostic purposes (spec §12.3).
67#[derive(Debug, Clone)]
68pub struct PlanStatement {
69    /// Ordered flat arena of read operators. References use dense [`OpId`].
70    pub ops: Vec<ReadOp>,
71    /// Write operators applied after each read-phase row.
72    pub write_ops: Vec<WriteOp>,
73    /// Mapping from plan [`VarId`] → HIR [`HirVarId`]. Insertion-ordered
74    /// for determinism (spec §17.14).
75    pub var_map: IndexMap<VarId, HirVarId>,
76}
77
78impl PlanStatement {
79    fn new() -> Self {
80        Self::empty()
81    }
82
83    /// Construct an empty [`PlanStatement`] — no read or write operators
84    /// and an empty `var_map`. Useful as a fallback when downstream
85    /// callers need a plan shape for a malformed query (cy-wlr).
86    #[must_use]
87    pub fn empty() -> Self {
88        Self {
89            ops: Vec::new(),
90            write_ops: Vec::new(),
91            var_map: IndexMap::new(),
92        }
93    }
94
95    /// Push an operator and return its [`OpId`].
96    fn push(&mut self, op: ReadOp) -> OpId {
97        #[allow(clippy::cast_possible_truncation)]
98        let id = OpId(self.ops.len() as u32);
99        self.ops.push(op);
100        id
101    }
102}
103
104// ── Public entry point ────────────────────────────────────────────────────────
105
106/// Lower a post-resolve, post-desugar HIR [`Statement`] into a logical
107/// [`PlanStatement`].
108///
109/// # Errors
110///
111/// Before walking the HIR the entry point performs a pre-lowering sanity
112/// scan (bead cy-wlr). It returns without building any plan operators when
113/// it encounters:
114///
115/// - [`PlanLowerError::UnresolvedName`] — a
116///   [`cyrs_hir::Expr::Unresolved`] node. Run name resolution
117///   (`cyrs-sema::resolve` / cy-b4b) first.
118/// - [`PlanLowerError::UndesugaredExpr`] — a
119///   [`cyrs_hir::Expr::PatternPredicate`],
120///   [`cyrs_hir::Expr::ListComprehension`], or
121///   [`cyrs_hir::Expr::MapProjection`]. Run
122///   [`cyrs_hir::desugar::desugar_statement`] (cy-mla) first.
123///
124/// The scan returns at the first offending node; other violations in the
125/// same statement are not reported in a single call.
126///
127/// # Panics (debug)
128///
129/// The pre-scan makes the main lowering body sound for the accepted
130/// subset of HIR. The `debug_assert!`s inside the private `lower_expr`
131/// helper remain for defense; they must not fire in practice because the
132/// scan catches the same conditions first.
133pub fn lower_statement(stmt: &Statement) -> Result<PlanStatement, PlanLowerError> {
134    precheck_statement(stmt)?;
135    let mut ctx = LowerCtx::new(stmt);
136    ctx.lower(stmt);
137    Ok(ctx.into_plan())
138}
139
140// ── Pre-lowering sanity scan (cy-wlr) ─────────────────────────────────────────
141
142/// Walk every expression reachable from `stmt.clauses` and return the first
143/// precondition violation, if any. See [`lower_statement`] for the contract.
144fn precheck_statement(stmt: &Statement) -> Result<(), PlanLowerError> {
145    for clause in &stmt.clauses {
146        let span = clause.span();
147        match clause {
148            Clause::Match { pattern, .. } | Clause::Create { pattern, .. } => {
149                check_pattern(pattern, span)?;
150            }
151            Clause::Where { predicate, .. } => check_expr(predicate, span)?,
152            Clause::With {
153                projections,
154                filter,
155                ..
156            } => {
157                for p in projections {
158                    check_expr(&p.expr, span)?;
159                }
160                if let Some(f) = filter {
161                    check_expr(f, span)?;
162                }
163            }
164            Clause::Return { projections, .. } => {
165                for p in projections {
166                    check_expr(&p.expr, span)?;
167                }
168            }
169            Clause::Unwind { list, .. } => check_expr(list, span)?,
170            Clause::Merge {
171                pattern,
172                on_create,
173                on_match,
174                ..
175            } => {
176                check_pattern(pattern, span)?;
177                for item in on_create.iter().chain(on_match.iter()) {
178                    check_set_item(item, span)?;
179                }
180            }
181            Clause::Set { items, .. } => {
182                for item in items {
183                    check_set_item(item, span)?;
184                }
185            }
186            Clause::Remove { items, .. } => {
187                for item in items {
188                    check_remove_item(item, span)?;
189                }
190            }
191            Clause::Delete { targets, .. } => {
192                for t in targets {
193                    check_expr(t, span)?;
194                }
195            }
196            Clause::Call { args, .. } => {
197                for a in args {
198                    check_expr(a, span)?;
199                }
200            }
201        }
202    }
203    Ok(())
204}
205
206fn check_pattern(pattern: &Pattern, clause_span: HirSpan) -> Result<(), PlanLowerError> {
207    for part in &pattern.parts {
208        // cy-f2t: the parser's error-recovery pass can yield a `PatternPart`
209        // with zero elements (e.g. bare `MATCH`) or a part whose first element
210        // is a `Rel` (e.g. `MATCH -[:R]->(n)`). The Source + Expand walker in
211        // `lower_pattern_part` assumes the first element is a `Node` and that
212        // the part has at least one element; surface any violation here as a
213        // clean error rather than a deep `.expect(...)` panic.
214        match part.elements.first() {
215            None => return Err(PlanLowerError::EmptyPatternPart { span: clause_span }),
216            Some(PatternElement::Rel { .. }) => {
217                return Err(PlanLowerError::EmptyPatternPart { span: clause_span });
218            }
219            Some(PatternElement::Node { .. }) => {}
220        }
221        for elem in &part.elements {
222            let props = match elem {
223                PatternElement::Node { props, .. } | PatternElement::Rel { props, .. } => {
224                    props.as_ref()
225                }
226            };
227            if let Some(p) = props {
228                // Element spans are preferred over the clause span because the
229                // HIR records them per element.
230                check_expr(p, elem.span())?;
231            }
232        }
233    }
234    Ok(())
235}
236
237fn check_set_item(item: &SetItem, span: HirSpan) -> Result<(), PlanLowerError> {
238    match item {
239        SetItem::Property { target, value, .. } => {
240            check_expr(target, span)?;
241            check_expr(value, span)?;
242        }
243        SetItem::Labels { .. } => {}
244        SetItem::AssignMap { map, .. } => check_expr(map, span)?,
245    }
246    Ok(())
247}
248
249fn check_remove_item(item: &RemoveItem, span: HirSpan) -> Result<(), PlanLowerError> {
250    match item {
251        RemoveItem::Property { target, .. } => check_expr(target, span)?,
252        RemoveItem::Labels { .. } => {}
253    }
254    Ok(())
255}
256
257/// Recursively walk a HIR expression, returning the first precondition
258/// violation encountered (see [`PlanLowerError`]).
259///
260/// `span` is the enclosing clause's span — the HIR does not carry
261/// per-expression spans in v1, so sub-expressions inherit their clause
262/// span for diagnostic purposes.
263fn check_expr(expr: &HirExpr, span: HirSpan) -> Result<(), PlanLowerError> {
264    match expr {
265        // Leaf nodes with no sub-expressions.
266        HirExpr::Null
267        | HirExpr::Bool(_)
268        | HirExpr::Int(_)
269        | HirExpr::Float(_)
270        | HirExpr::String(_)
271        | HirExpr::Var(_)
272        | HirExpr::Param(_) => Ok(()),
273
274        // cy-863: `PatternPredicate` carries an embedded `Pattern` that
275        // is lowered in-place by `lower_match_pattern` during plan
276        // construction (cy-lve, see [`Expr::Exists`]). That machinery
277        // calls `lower_expr` on element properties without first running
278        // the pre-lowering scan, so any precondition violation hidden
279        // inside the embedded pattern would surface as a deep
280        // `debug_assert!` panic. Recurse into the embedded pattern here
281        // so violations are reported as a clean `Err` from the outer
282        // `lower_statement` call instead.
283        HirExpr::PatternPredicate(pattern) => check_pattern(pattern, span),
284
285        // Precondition violations.
286        HirExpr::Unresolved(name) => Err(PlanLowerError::UnresolvedName {
287            name: name.clone(),
288            span,
289        }),
290        HirExpr::ListComprehension { .. } => Err(PlanLowerError::UndesugaredExpr {
291            kind: "ListComprehension",
292            span,
293        }),
294        HirExpr::MapProjection { .. } => Err(PlanLowerError::UndesugaredExpr {
295            kind: "MapProjection",
296            span,
297        }),
298
299        // Recursive cases.
300        HirExpr::Prop { target, .. } => check_expr(target, span),
301        HirExpr::Index { target, index } => {
302            check_expr(target, span)?;
303            check_expr(index, span)
304        }
305        HirExpr::Slice { target, start, end } => {
306            check_expr(target, span)?;
307            if let Some(s) = start {
308                check_expr(s, span)?;
309            }
310            if let Some(e) = end {
311                check_expr(e, span)?;
312            }
313            Ok(())
314        }
315        HirExpr::List(items) => {
316            for item in items {
317                check_expr(item, span)?;
318            }
319            Ok(())
320        }
321        HirExpr::Map(pairs) => {
322            for (_, v) in pairs {
323                check_expr(v, span)?;
324            }
325            Ok(())
326        }
327        HirExpr::Call { args, .. } => {
328            for a in args {
329                check_expr(a, span)?;
330            }
331            Ok(())
332        }
333        HirExpr::BinOp { lhs, rhs, .. } => {
334            check_expr(lhs, span)?;
335            check_expr(rhs, span)
336        }
337        HirExpr::UnaryOp { operand, .. } | HirExpr::IsNull { operand, .. } => {
338            check_expr(operand, span)
339        }
340        HirExpr::Case {
341            scrutinee,
342            arms,
343            otherwise,
344        } => {
345            if let Some(s) = scrutinee {
346                check_expr(s, span)?;
347            }
348            for (w, t) in arms {
349                check_expr(w, span)?;
350                check_expr(t, span)?;
351            }
352            if let Some(o) = otherwise {
353                check_expr(o, span)?;
354            }
355            Ok(())
356        }
357        HirExpr::InList { operand, list } => {
358            check_expr(operand, span)?;
359            check_expr(list, span)
360        }
361        HirExpr::ListPredicate {
362            iterable,
363            predicate,
364            ..
365        } => {
366            check_expr(iterable, span)?;
367            if let Some(p) = predicate {
368                check_expr(p, span)?;
369            }
370            Ok(())
371        }
372    }
373}
374
375// ── Lowering context ──────────────────────────────────────────────────────────
376
377struct LowerCtx<'s> {
378    plan: PlanStatement,
379    /// Mapping from HIR `VarId` to plan `VarId` (allocated on first seen).
380    hir_to_plan: IndexMap<HirVarId, VarId>,
381    next_var: u32,
382    _stmt: &'s Statement,
383}
384
385impl<'s> LowerCtx<'s> {
386    fn new(stmt: &'s Statement) -> Self {
387        Self {
388            plan: PlanStatement::new(),
389            hir_to_plan: IndexMap::new(),
390            next_var: 0,
391            _stmt: stmt,
392        }
393    }
394
395    fn into_plan(self) -> PlanStatement {
396        self.plan
397    }
398
399    // ── VarId mapping ─────────────────────────────────────────────────────────
400
401    fn map_var(&mut self, hir_var: HirVarId) -> VarId {
402        if let Some(&plan_var) = self.hir_to_plan.get(&hir_var) {
403            return plan_var;
404        }
405        let plan_var = VarId(self.next_var);
406        self.next_var += 1;
407        self.hir_to_plan.insert(hir_var, plan_var);
408        self.plan.var_map.insert(plan_var, hir_var);
409        plan_var
410    }
411
412    // ── Top-level clause dispatch ─────────────────────────────────────────────
413
414    fn lower(&mut self, stmt: &Statement) {
415        // Handle UNION at statement level: a UNION query is two sub-statements
416        // separated by UNION / UNION ALL. In the HIR the clauses of each arm
417        // are simply concatenated — there is no explicit union clause node in
418        // the current HIR shape. We therefore lower all clauses sequentially;
419        // consumers that produce UNION must build paired PlanStatements
420        // themselves. Union construction from two Statement arms is handled
421        // by `lower_union_pair` (see below).
422        //
423        // For regular queries we walk the clause list and build up an operator
424        // chain.
425        let mut current_op: Option<OpId> = None;
426
427        let mut i = 0;
428        while i < stmt.clauses.len() {
429            let clause = &stmt.clauses[i];
430            match clause {
431                Clause::Match {
432                    pattern, optional, ..
433                } => {
434                    let (new_op, _) = self.lower_match_pattern(pattern, current_op, *optional);
435                    current_op = Some(new_op);
436                }
437                Clause::Where { predicate, .. } => {
438                    let pred = self.lower_expr(predicate);
439                    let input = current_op.unwrap_or_else(|| self.push_source_all());
440                    let op = self.plan.push(ReadOp::Filter {
441                        input,
442                        predicate: pred,
443                    });
444                    current_op = Some(op);
445                }
446                Clause::With {
447                    projections,
448                    filter,
449                    ..
450                } => {
451                    let input = current_op.unwrap_or_else(|| self.push_source_all());
452                    let items = self.lower_projections(projections);
453                    let filter_expr = filter.as_ref().map(|f| self.lower_expr(f));
454                    let op = self.plan.push(ReadOp::With {
455                        input,
456                        items,
457                        filter: filter_expr,
458                    });
459                    current_op = Some(op);
460                }
461                Clause::Return {
462                    projections,
463                    distinct,
464                    ..
465                } => {
466                    let input = current_op.unwrap_or_else(|| self.push_source_all());
467                    let (items, agg_items) = self.split_projections_agg(projections);
468                    let op = if agg_items.is_empty() {
469                        let proj_items = self.lower_projections(projections);
470                        self.plan.push(ReadOp::Project {
471                            input,
472                            items: proj_items,
473                        })
474                    } else {
475                        // Aggregating RETURN: emit Aggregate then Project for
476                        // the non-agg columns.
477                        let keys: Vec<Expr> = items.iter().map(|p| p.expr.clone()).collect();
478                        let agg_op = self.plan.push(ReadOp::Aggregate {
479                            input,
480                            keys,
481                            aggs: agg_items,
482                        });
483                        // Project picks up both key cols and agg output cols;
484                        // we project everything from the aggregate.
485                        let all_items = self.lower_projections(projections);
486                        self.plan.push(ReadOp::Project {
487                            input: agg_op,
488                            items: all_items,
489                        })
490                    };
491                    let op = if *distinct {
492                        self.plan.push(ReadOp::Distinct { input: op })
493                    } else {
494                        op
495                    };
496                    current_op = Some(op);
497                }
498                Clause::Unwind { list, bind, .. } => {
499                    let input = current_op.unwrap_or_else(|| self.push_source_all());
500                    let list_expr = self.lower_expr(list);
501                    let bind_var = self.map_var(*bind);
502                    let op = self.plan.push(ReadOp::Unwind {
503                        input,
504                        list: list_expr,
505                        bind: bind_var,
506                    });
507                    current_op = Some(op);
508                }
509                Clause::Create { pattern, .. } => {
510                    let write_ops = self.lower_create_pattern(pattern);
511                    self.plan.write_ops.extend(write_ops);
512                }
513                Clause::Merge {
514                    pattern,
515                    on_create,
516                    on_match,
517                    ..
518                } => {
519                    let write_ops = self.lower_merge_pattern(pattern, on_create, on_match);
520                    self.plan.write_ops.extend(write_ops);
521                }
522                Clause::Set { items, .. } => {
523                    let write_ops = self.lower_set_items(items);
524                    self.plan.write_ops.extend(write_ops);
525                }
526                Clause::Remove { items, .. } => {
527                    let write_ops = self.lower_remove_items(items);
528                    self.plan.write_ops.extend(write_ops);
529                }
530                Clause::Delete {
531                    targets, detach, ..
532                } => {
533                    let exprs: Vec<Expr> = targets.iter().map(|e| self.lower_expr(e)).collect();
534                    self.plan.write_ops.push(WriteOp::Delete {
535                        targets: exprs,
536                        detach: *detach,
537                    });
538                }
539                Clause::Call { .. } => {
540                    // CALL subquery / procedure call is out of v1 scope (spec §19/§20).
541                    // Leave current_op unchanged.
542                }
543            }
544            i += 1;
545        }
546    }
547
548    /// Push a degenerate all-node Source (used when a clause appears without
549    /// a preceding MATCH, e.g. a standalone RETURN).
550    fn push_source_all(&mut self) -> OpId {
551        self.plan.push(ReadOp::Source {
552            label: None,
553            bind: VarId(self.next_var),
554        })
555        // Note: we do NOT register this synthetic var in var_map because it
556        // has no HIR counterpart.
557    }
558
559    // ── MATCH pattern → Source + Expand chain ────────────────────────────────
560
561    /// Lower a [`Pattern`] into a Source + Expand chain. Returns the `OpId`
562    /// of the outermost operator and a list of variable bindings introduced.
563    ///
564    /// If `optional` is true and there is an existing `current_op`, the
565    /// chain is wrapped in an `OptionalJoin`.
566    fn lower_match_pattern(
567        &mut self,
568        pattern: &Pattern,
569        current_op: Option<OpId>,
570        optional: bool,
571    ) -> (OpId, Vec<VarId>) {
572        let mut vars = Vec::new();
573        let mut op: Option<OpId> = None;
574
575        for part in &pattern.parts {
576            let part_op = self.lower_pattern_part(part, &mut vars);
577            op = Some(match op {
578                None => part_op,
579                Some(left) => {
580                    // Multiple pattern parts in a single MATCH clause: treat
581                    // as a cross-product by wrapping later parts as nested
582                    // expands on the first. In practice, patterns with
583                    // multiple parts are rare; we link them sequentially.
584                    // The last part's root op is the join point.
585                    let _ = left;
586                    part_op
587                }
588            });
589        }
590
591        let inner_op = op.unwrap_or_else(|| {
592            // Empty pattern — emit an all-node source anyway.
593            let bind = VarId(self.next_var);
594            self.next_var += 1;
595            self.plan.push(ReadOp::Source { label: None, bind })
596        });
597
598        let final_op = if optional {
599            if let Some(outer) = current_op {
600                // Wrap the inner pattern in an OptionalJoin.
601                let inner_root = self.plan.ops[inner_op.0 as usize].clone();
602                self.plan.push(ReadOp::OptionalJoin {
603                    input: outer,
604                    pattern: Box::new(inner_root),
605                })
606            } else {
607                inner_op
608            }
609        } else {
610            inner_op
611        };
612
613        (final_op, vars)
614    }
615
616    fn lower_pattern_part(&mut self, part: &PatternPart, vars: &mut Vec<VarId>) -> OpId {
617        // Walk elements; first node becomes Source, alternating
618        // Rel+Node pairs become Expand.
619        //
620        // The entry-point pre-scan (`precheck_statement`, cy-f2t) guarantees
621        // `part.elements` is non-empty and starts with a `Node` — the
622        // previously-panicking `.expect(…)` sites in this function are
623        // replaced with graceful fallbacks so that a consumer who skips the
624        // pre-scan still gets a plan, not a panic.
625        let mut last_op: Option<OpId> = None;
626        let mut last_node_var: Option<VarId> = None;
627        let mut last_rel: Option<&PatternElement> = None;
628
629        for elem in &part.elements {
630            match elem {
631                PatternElement::Node {
632                    bind,
633                    labels,
634                    props,
635                    ..
636                } => {
637                    let bind_var = bind.map(|v| {
638                        let pv = self.map_var(v);
639                        vars.push(pv);
640                        pv
641                    });
642
643                    if let (Some(rel_elem), Some(from), Some(input)) =
644                        (last_rel.take(), last_node_var, last_op)
645                    {
646                        // We have a pending relationship + a preceding node
647                        // bound → emit an Expand.
648                        let bind_var = bind_var.unwrap_or_else(|| {
649                            let v = VarId(self.next_var);
650                            self.next_var += 1;
651                            v
652                        });
653                        let bind_to = bind_var;
654
655                        let (rel_spec, bind_rel) = self.lower_rel_element(rel_elem, vars);
656
657                        let node_spec = NodeSpec {
658                            labels: LabelSet(labels.clone()),
659                            properties: props.as_ref().map(|e| self.lower_expr(e)),
660                        };
661
662                        let op = self.plan.push(ReadOp::Expand {
663                            input,
664                            from,
665                            rel: rel_spec,
666                            to: node_spec,
667                            bind_rel,
668                            bind_to,
669                        });
670                        last_node_var = Some(bind_to);
671                        last_op = Some(op);
672                    } else {
673                        // First node (or malformed-but-recovered part whose
674                        // leading Rel we silently drop): Source.
675                        let label_set = if labels.is_empty() {
676                            None
677                        } else {
678                            Some(LabelSet(labels.clone()))
679                        };
680                        let bind_var = bind_var.unwrap_or_else(|| {
681                            let v = VarId(self.next_var);
682                            self.next_var += 1;
683                            v
684                        });
685                        let op = self.plan.push(ReadOp::Source {
686                            label: label_set,
687                            bind: bind_var,
688                        });
689                        // If there are inline props on the node, add a Filter.
690                        let op = if let Some(prop_expr) = props.as_ref() {
691                            let predicate = self.lower_expr(prop_expr);
692                            self.plan.push(ReadOp::Filter {
693                                input: op,
694                                predicate,
695                            })
696                        } else {
697                            op
698                        };
699                        last_node_var = Some(bind_var);
700                        last_op = Some(op);
701                    }
702                }
703                PatternElement::Rel { .. } => {
704                    // Store for pairing with the next Node.
705                    last_rel = Some(elem);
706                }
707            }
708        }
709
710        // Empty / leading-Rel pattern parts are rejected at the entry point
711        // (see `precheck_statement`, cy-f2t). If a consumer bypasses the
712        // pre-scan and hands us a part with no Node, degrade to a degenerate
713        // all-node Source so lowering still produces a valid plan.
714        last_op.unwrap_or_else(|| self.push_source_all())
715    }
716
717    fn lower_rel_element(
718        &mut self,
719        elem: &PatternElement,
720        vars: &mut Vec<VarId>,
721    ) -> (RelSpec, VarId) {
722        match elem {
723            PatternElement::Rel {
724                bind,
725                types,
726                direction,
727                length,
728                props,
729                ..
730            } => {
731                let bind_rel = bind
732                    .map(|v| {
733                        let pv = self.map_var(v);
734                        vars.push(pv);
735                        pv
736                    })
737                    .unwrap_or_else(|| {
738                        let v = VarId(self.next_var);
739                        self.next_var += 1;
740                        v
741                    });
742
743                let dir = match direction {
744                    HirDir::Outgoing => Direction::Outgoing,
745                    HirDir::Incoming => Direction::Incoming,
746                    HirDir::Undirected => Direction::Undirected,
747                    // `Direction` is `#[non_exhaustive]` (cy-2i9.1).
748                    _ => unreachable!("cyrs-plan::lower: unhandled Direction variant"),
749                };
750
751                let rel_len = match length {
752                    HirRelLen::Single => RelLength::Single,
753                    HirRelLen::Variable { min, max } => RelLength::Variable {
754                        min: *min,
755                        max: *max,
756                    },
757                    // `RelLength` is `#[non_exhaustive]` (cy-2i9.1).
758                    _ => unreachable!("cyrs-plan::lower: unhandled RelLength variant"),
759                };
760
761                let rel_spec = RelSpec {
762                    types: types.clone(),
763                    direction: dir,
764                    length: rel_len,
765                    properties: props.as_ref().map(|e| self.lower_expr(e)),
766                };
767
768                (rel_spec, bind_rel)
769            }
770            PatternElement::Node { .. } => panic!("lower_rel_element called on a Node element"),
771        }
772    }
773
774    // ── Projection lowering ───────────────────────────────────────────────────
775
776    fn lower_projections(&mut self, projs: &[Projection]) -> Vec<PlanProj> {
777        projs
778            .iter()
779            .map(|p| {
780                let expr = self.lower_expr(&p.expr);
781                let alias = p.alias.clone().unwrap_or_else(|| synthesise_alias(&p.expr));
782                PlanProj { expr, alias }
783            })
784            .collect()
785    }
786
787    /// Split projections into non-aggregate and aggregate groups.
788    ///
789    /// A projection is considered an aggregate call when it is a
790    /// `HirExpr::Call` whose name is a known aggregate function
791    /// (`count`, `sum`, `avg`, `min`, `max`, `collect`, `stdev`,
792    /// `stdevp`, `percentileCont`, `percentileDisc`). This mirrors the
793    /// function catalog entry `aggregate = true` (spec §8.3) without
794    /// importing `cyrs-sema`.
795    fn split_projections_agg(&mut self, projs: &[Projection]) -> (Vec<PlanProj>, Vec<AggExpr>) {
796        let mut non_agg = Vec::new();
797        let mut agg = Vec::new();
798
799        for p in projs {
800            if let HirExpr::Call {
801                name,
802                args,
803                distinct,
804            } = &p.expr
805                && is_aggregate_func(name)
806            {
807                let plan_args: Vec<Expr> = args.iter().map(|a| self.lower_expr(a)).collect();
808                agg.push(AggExpr {
809                    func: name.clone(),
810                    args: plan_args,
811                    distinct: *distinct,
812                });
813                continue;
814            }
815            let expr = self.lower_expr(&p.expr);
816            let alias = p.alias.clone().unwrap_or_else(|| synthesise_alias(&p.expr));
817            non_agg.push(PlanProj { expr, alias });
818        }
819
820        (non_agg, agg)
821    }
822
823    // ── Write op lowering ─────────────────────────────────────────────────────
824
825    fn lower_create_pattern(&mut self, pattern: &Pattern) -> Vec<WriteOp> {
826        let mut ops = Vec::new();
827        for part in &pattern.parts {
828            // Use the two-pass pairing helper to correctly link rel from/to.
829            let paired = create_pattern_pairs(part);
830            for pair in paired {
831                match pair {
832                    CreatePair::Node {
833                        labels,
834                        props,
835                        bind,
836                    } => {
837                        let bind_var = bind.map(|v| self.map_var(v));
838                        let props_expr = if let Some(e) = props.as_ref() {
839                            self.lower_expr(e)
840                        } else {
841                            Expr::Map(vec![])
842                        };
843                        ops.push(WriteOp::CreateNode {
844                            labels,
845                            props: props_expr,
846                            bind: bind_var,
847                        });
848                    }
849                    CreatePair::Rel {
850                        from_bind,
851                        to_bind,
852                        rel_type,
853                        props,
854                        bind,
855                    } => {
856                        let from = self.map_var(from_bind);
857                        let to = self.map_var(to_bind);
858                        let bind_rel = bind.map(|v| self.map_var(v));
859                        let props_expr = if let Some(e) = props.as_ref() {
860                            self.lower_expr(e)
861                        } else {
862                            Expr::Map(vec![])
863                        };
864                        ops.push(WriteOp::CreateRel {
865                            from,
866                            to,
867                            rel_type,
868                            props: props_expr,
869                            bind: bind_rel,
870                        });
871                    }
872                }
873            }
874        }
875        ops
876    }
877
878    fn lower_merge_pattern(
879        &mut self,
880        pattern: &Pattern,
881        on_create: &[SetItem],
882        on_match: &[SetItem],
883    ) -> Vec<WriteOp> {
884        let mut ops = Vec::new();
885        let create_ops = self.lower_set_items(on_create);
886        let match_ops = self.lower_set_items(on_match);
887
888        for part in &pattern.parts {
889            let paired = create_pattern_pairs(part);
890            for pair in paired {
891                match pair {
892                    CreatePair::Node {
893                        labels,
894                        props,
895                        bind,
896                    } => {
897                        let bind_var = bind.map(|v| self.map_var(v));
898                        let props_expr = if let Some(e) = props.as_ref() {
899                            self.lower_expr(e)
900                        } else {
901                            Expr::Map(vec![])
902                        };
903                        ops.push(WriteOp::MergeNode {
904                            labels,
905                            props: props_expr,
906                            on_create: create_ops.clone(),
907                            on_match: match_ops.clone(),
908                            bind: bind_var,
909                        });
910                    }
911                    CreatePair::Rel {
912                        from_bind,
913                        to_bind,
914                        rel_type,
915                        props,
916                        bind,
917                    } => {
918                        let from = self.map_var(from_bind);
919                        let to = self.map_var(to_bind);
920                        let bind_rel = bind.map(|v| self.map_var(v));
921                        let props_expr = if let Some(e) = props.as_ref() {
922                            self.lower_expr(e)
923                        } else {
924                            Expr::Map(vec![])
925                        };
926                        ops.push(WriteOp::MergeRel {
927                            from,
928                            to,
929                            rel_type,
930                            props: props_expr,
931                            on_create: create_ops.clone(),
932                            on_match: match_ops.clone(),
933                            bind: bind_rel,
934                        });
935                    }
936                }
937            }
938        }
939        ops
940    }
941
942    fn lower_set_items(&mut self, items: &[SetItem]) -> Vec<WriteOp> {
943        items
944            .iter()
945            .flat_map(|item| self.lower_set_item(item))
946            .collect()
947    }
948
949    fn lower_set_item(&mut self, item: &SetItem) -> Vec<WriteOp> {
950        match item {
951            SetItem::Property {
952                target,
953                prop,
954                value,
955            } => {
956                // `target` is an expression; for the plan we need a VarId.
957                // Extract a Var from the expression; fall back to a synthetic
958                // VarId for non-Var targets.
959                let target_var = if let Some(hir_var) = expr_to_var_id(target) {
960                    self.map_var(hir_var)
961                } else {
962                    let v = VarId(self.next_var);
963                    self.next_var += 1;
964                    v
965                };
966                vec![WriteOp::SetProperty {
967                    target: target_var,
968                    prop: prop.clone(),
969                    value: self.lower_expr(value),
970                }]
971            }
972            SetItem::Labels { target, labels } => {
973                let target_var = self.map_var(*target);
974                vec![WriteOp::SetLabels {
975                    target: target_var,
976                    labels: labels.clone(),
977                }]
978            }
979            SetItem::AssignMap {
980                target,
981                map: _,
982                replace: _,
983            } => {
984                // Whole-map assignment (`n = {…}` or `n += {…}`) is not
985                // representable as a single WriteOp in v1; emit SetLabels
986                // with empty labels as a no-op placeholder. Consumers that
987                // need full map assignment should handle this at the
988                // cyrs-db layer.
989                let target_var = self.map_var(*target);
990                vec![WriteOp::SetLabels {
991                    target: target_var,
992                    labels: vec![],
993                }]
994            }
995        }
996    }
997
998    fn lower_remove_items(&mut self, items: &[RemoveItem]) -> Vec<WriteOp> {
999        items
1000            .iter()
1001            .map(|item| match item {
1002                RemoveItem::Property { target, prop } => {
1003                    let target_var = if let Some(hir_var) = expr_to_var_id(target) {
1004                        self.map_var(hir_var)
1005                    } else {
1006                        let v = VarId(self.next_var);
1007                        self.next_var += 1;
1008                        v
1009                    };
1010                    WriteOp::RemoveProperty {
1011                        target: target_var,
1012                        prop: prop.clone(),
1013                    }
1014                }
1015                RemoveItem::Labels { target, labels } => {
1016                    let target_var = self.map_var(*target);
1017                    WriteOp::RemoveLabels {
1018                        target: target_var,
1019                        labels: labels.clone(),
1020                    }
1021                }
1022            })
1023            .collect()
1024    }
1025
1026    // ── Expression lowering ───────────────────────────────────────────────────
1027
1028    /// Lower a HIR expression to a plan expression.
1029    ///
1030    /// # Contract
1031    ///
1032    /// - [`HirExpr::Unresolved`]: must not appear in a post-resolution HIR.
1033    ///   `debug_assert!`s in debug builds; returns [`Expr::Null`] in release.
1034    ///
1035    /// - [`HirExpr::PatternPredicate`] / [`HirExpr::ListComprehension`] /
1036    ///   [`HirExpr::MapProjection`]: must be desugared before lowering (see
1037    ///   cy-mla and `cyrs_hir::desugar`). `debug_assert!`s in debug builds;
1038    ///   returns [`Expr::Null`] in release.
1039    fn lower_expr(&mut self, expr: &HirExpr) -> Expr {
1040        match expr {
1041            HirExpr::Null => Expr::Null,
1042            HirExpr::Bool(b) => Expr::Bool(*b),
1043            HirExpr::Int(i) => Expr::Int(*i),
1044            HirExpr::Float(f) => Expr::Float(*f),
1045            HirExpr::String(s) => Expr::String(s.clone()),
1046            HirExpr::Var(v) => Expr::Var(self.map_var(*v)),
1047            HirExpr::Param(name) => Expr::Param { name: name.clone() },
1048
1049            HirExpr::Prop { target, prop } => Expr::Prop {
1050                target: Box::new(self.lower_expr(target)),
1051                prop: prop.clone(),
1052            },
1053            HirExpr::Index { target, index } => Expr::Index {
1054                target: Box::new(self.lower_expr(target)),
1055                index: Box::new(self.lower_expr(index)),
1056            },
1057            HirExpr::Slice { target, start, end } => Expr::Slice {
1058                target: Box::new(self.lower_expr(target)),
1059                start: start.as_ref().map(|s| Box::new(self.lower_expr(s))),
1060                end: end.as_ref().map(|e| Box::new(self.lower_expr(e))),
1061            },
1062            HirExpr::List(items) => Expr::List(items.iter().map(|e| self.lower_expr(e)).collect()),
1063            HirExpr::Map(pairs) => Expr::Map(
1064                pairs
1065                    .iter()
1066                    .map(|(k, v)| (k.clone(), self.lower_expr(v)))
1067                    .collect(),
1068            ),
1069            HirExpr::Call {
1070                name,
1071                args,
1072                distinct: _,
1073            } => Expr::Call {
1074                func: name.clone(),
1075                args: args.iter().map(|a| self.lower_expr(a)).collect(),
1076            },
1077            HirExpr::BinOp { op, lhs, rhs } => Expr::BinOp {
1078                op: lower_bin_op(*op),
1079                lhs: Box::new(self.lower_expr(lhs)),
1080                rhs: Box::new(self.lower_expr(rhs)),
1081            },
1082            HirExpr::UnaryOp { op, operand } => Expr::UnaryOp {
1083                op: match op {
1084                    cyrs_hir::UnaryOp::Neg => UnaryOp::Neg,
1085                    cyrs_hir::UnaryOp::Not => UnaryOp::Not,
1086                },
1087                operand: Box::new(self.lower_expr(operand)),
1088            },
1089            HirExpr::Case {
1090                scrutinee,
1091                arms,
1092                otherwise,
1093            } => Expr::Case {
1094                scrutinee: scrutinee.as_ref().map(|s| Box::new(self.lower_expr(s))),
1095                arms: arms
1096                    .iter()
1097                    .map(|(w, t)| (self.lower_expr(w), self.lower_expr(t)))
1098                    .collect(),
1099                otherwise: otherwise.as_ref().map(|o| Box::new(self.lower_expr(o))),
1100            },
1101            HirExpr::IsNull { operand, negated } => Expr::IsNull {
1102                operand: Box::new(self.lower_expr(operand)),
1103                negated: *negated,
1104            },
1105            HirExpr::InList { operand, list } => Expr::InList {
1106                operand: Box::new(self.lower_expr(operand)),
1107                list: Box::new(self.lower_expr(list)),
1108            },
1109
1110            // ── Constructs that require pre-lowering passes ──────────────────
1111            HirExpr::Unresolved(name) => {
1112                // Name resolution must run before HIR→Plan lowering.
1113                debug_assert!(
1114                    false,
1115                    "Unresolved variable `{name}` encountered in HIR→Plan lowering; \
1116                     run name resolution (cy-b4b) before calling lower_statement"
1117                );
1118                Expr::Null
1119            }
1120
1121            HirExpr::PatternPredicate(pattern) => {
1122                // cy-lve: lower to plan `Expr::Exists` whose payload is
1123                // the pattern's read-sub-plan. The embedded `ReadOp`
1124                // mirrors the treatment of `OptionalJoin`: a fresh sub-
1125                // tree introduced in-place, not an `OpId` into the main
1126                // arena (spec §12.1 N13 note).
1127                let (sub_op, _sub_vars) =
1128                    self.lower_match_pattern(pattern, None, /* optional = */ false);
1129                let inner_root = self.plan.ops[sub_op.0 as usize].clone();
1130                Expr::Exists {
1131                    pattern: Box::new(inner_root),
1132                }
1133            }
1134
1135            HirExpr::ListComprehension { .. } => {
1136                // List comprehensions must be desugared to Unwind + Filter
1137                // before lowering (see cy-mla).
1138                debug_assert!(
1139                    false,
1140                    "ListComprehension encountered in HIR→Plan lowering; \
1141                     run cyrs_hir::desugar::desugar_statement (cy-mla) first"
1142                );
1143                Expr::Null
1144            }
1145
1146            HirExpr::ListPredicate {
1147                kind,
1148                var,
1149                iterable,
1150                predicate,
1151            } => Expr::ListPredicate {
1152                kind: lower_list_pred_kind(*kind),
1153                var: self.map_var(*var),
1154                iterable: Box::new(self.lower_expr(iterable)),
1155                predicate: predicate.as_ref().map(|p| Box::new(self.lower_expr(p))),
1156            },
1157
1158            HirExpr::MapProjection { .. } => {
1159                // Map projections must be desugared to explicit Expr::Map
1160                // before lowering (see cy-mla).
1161                debug_assert!(
1162                    false,
1163                    "MapProjection encountered in HIR→Plan lowering; \
1164                     run cyrs_hir::desugar::desugar_statement (cy-mla) first"
1165                );
1166                Expr::Null
1167            }
1168        }
1169    }
1170}
1171
1172// ── Helpers ───────────────────────────────────────────────────────────────────
1173
1174/// Synthesise a column alias for a bare expression when no explicit alias
1175/// was provided. Used to ensure every plan Projection has an explicit alias
1176/// (spec §12.1 N4 note).
1177fn synthesise_alias(expr: &HirExpr) -> SmolStr {
1178    match expr {
1179        HirExpr::Var(v) => SmolStr::new(format!("_v{}", v.0)),
1180        HirExpr::Prop { prop, .. } => prop.clone(),
1181        HirExpr::Call { name, .. } => name.clone(),
1182        _ => SmolStr::new("_"),
1183    }
1184}
1185
1186/// Extract the [`HirVarId`] from a simple variable expression, if any.
1187fn expr_to_var_id(expr: &HirExpr) -> Option<HirVarId> {
1188    match expr {
1189        HirExpr::Var(v) => Some(*v),
1190        _ => None,
1191    }
1192}
1193
1194/// Lower a HIR [`HirListPredKind`] to a plan [`ListPredKind`] (cy-8x5).
1195///
1196/// Both enums are `#[non_exhaustive]` at the public boundary; the
1197/// wildcard arm maps unknown future kinds to `ListPredKind::All` so
1198/// the plan stays well-typed. A later bead adding a new HIR kind also
1199/// bumps this mapping.
1200#[allow(clippy::match_same_arms)]
1201fn lower_list_pred_kind(kind: HirListPredKind) -> ListPredKind {
1202    match kind {
1203        HirListPredKind::Any => ListPredKind::Any,
1204        HirListPredKind::All => ListPredKind::All,
1205        HirListPredKind::None => ListPredKind::None,
1206        HirListPredKind::Single => ListPredKind::Single,
1207        _ => ListPredKind::All,
1208    }
1209}
1210
1211/// Lower a HIR [`cyrs_hir::BinOp`] to a plan [`BinOp`].
1212fn lower_bin_op(op: cyrs_hir::BinOp) -> BinOp {
1213    match op {
1214        cyrs_hir::BinOp::Add => BinOp::Add,
1215        cyrs_hir::BinOp::Sub => BinOp::Sub,
1216        cyrs_hir::BinOp::Mul => BinOp::Mul,
1217        cyrs_hir::BinOp::Div => BinOp::Div,
1218        cyrs_hir::BinOp::Mod => BinOp::Mod,
1219        cyrs_hir::BinOp::Pow => BinOp::Pow,
1220        cyrs_hir::BinOp::Eq => BinOp::Eq,
1221        cyrs_hir::BinOp::Neq => BinOp::Neq,
1222        cyrs_hir::BinOp::Lt => BinOp::Lt,
1223        cyrs_hir::BinOp::Le => BinOp::Le,
1224        cyrs_hir::BinOp::Gt => BinOp::Gt,
1225        cyrs_hir::BinOp::Ge => BinOp::Ge,
1226        cyrs_hir::BinOp::And => BinOp::And,
1227        cyrs_hir::BinOp::Or => BinOp::Or,
1228        cyrs_hir::BinOp::Xor => BinOp::Xor,
1229        cyrs_hir::BinOp::StartsWith => BinOp::StartsWith,
1230        cyrs_hir::BinOp::EndsWith => BinOp::EndsWith,
1231        cyrs_hir::BinOp::Contains => BinOp::Contains,
1232        cyrs_hir::BinOp::RegexMatch => BinOp::RegexMatch,
1233        cyrs_hir::BinOp::Concat => BinOp::Concat,
1234    }
1235}
1236
1237/// Returns true if `name` is a known aggregate function (spec §8.3).
1238fn is_aggregate_func(name: &str) -> bool {
1239    matches!(
1240        name.to_ascii_lowercase().as_str(),
1241        "count"
1242            | "sum"
1243            | "avg"
1244            | "min"
1245            | "max"
1246            | "collect"
1247            | "stdev"
1248            | "stdevp"
1249            | "percentilecont"
1250            | "percentiledisc"
1251    )
1252}
1253
1254// ── Create/Merge pattern decomposition helper ─────────────────────────────────
1255
1256/// A decomposed write operation from a CREATE/MERGE pattern.
1257enum CreatePair<'a> {
1258    Node {
1259        labels: Vec<SmolStr>,
1260        props: Option<&'a HirExpr>,
1261        bind: Option<HirVarId>,
1262    },
1263    Rel {
1264        from_bind: HirVarId,
1265        to_bind: HirVarId,
1266        rel_type: SmolStr,
1267        props: Option<&'a HirExpr>,
1268        bind: Option<HirVarId>,
1269    },
1270}
1271
1272/// Decompose a [`PatternPart`] into a sequence of node and relationship
1273/// creation pairs. Relationships reference their adjacent nodes by `HirVarId`.
1274/// Only nodes that have an explicit binding are usable as rel endpoints;
1275/// anonymous nodes in CREATE are given synthetic `VarIds` by the caller.
1276fn create_pattern_pairs(part: &PatternPart) -> Vec<CreatePair<'_>> {
1277    let mut result = Vec::new();
1278    let mut node_vars: Vec<Option<HirVarId>> = Vec::new();
1279    let mut elements = part.elements.iter().peekable();
1280
1281    while let Some(elem) = elements.next() {
1282        match elem {
1283            PatternElement::Node {
1284                bind,
1285                labels,
1286                props,
1287                ..
1288            } => {
1289                node_vars.push(*bind);
1290                result.push(CreatePair::Node {
1291                    labels: labels.clone(),
1292                    props: props.as_ref(),
1293                    bind: *bind,
1294                });
1295            }
1296            PatternElement::Rel {
1297                bind, types, props, ..
1298            } => {
1299                // A relationship must follow a node; take the last node as
1300                // `from`. The `to` node is the *next* element.
1301                let Some(from_bind) = node_vars.last().copied().flatten() else {
1302                    continue; // malformed pattern
1303                };
1304
1305                // Peek at the next node.
1306                let to_bind = match elements.peek() {
1307                    Some(PatternElement::Node { bind: Some(v), .. }) => {
1308                        let v = *v;
1309                        node_vars.push(Some(v));
1310                        // Consume the next node element here so that the outer
1311                        // loop doesn't double-emit it. We emit the Node first,
1312                        // then the Rel.
1313                        let next = elements.next().unwrap();
1314                        if let PatternElement::Node {
1315                            labels,
1316                            props,
1317                            bind,
1318                            ..
1319                        } = next
1320                        {
1321                            result.push(CreatePair::Node {
1322                                labels: labels.clone(),
1323                                props: props.as_ref(),
1324                                bind: *bind,
1325                            });
1326                        }
1327                        v
1328                    }
1329                    // Anonymous to-node — cannot reference it by VarId; skip
1330                    // the relationship in this case (caller provides binding).
1331                    _ => continue,
1332                };
1333
1334                let rel_type = types.first().cloned().unwrap_or_default();
1335                result.push(CreatePair::Rel {
1336                    from_bind,
1337                    to_bind,
1338                    rel_type,
1339                    props: props.as_ref(),
1340                    bind: *bind,
1341                });
1342            }
1343        }
1344    }
1345
1346    result
1347}
1348
1349// ── Public helper: lower a UNION pair ────────────────────────────────────────
1350
1351/// Lower two HIR statements joined by `UNION` / `UNION ALL` into a single
1352/// [`PlanStatement`] whose root is a [`ReadOp::Union`].
1353///
1354/// This helper is provided for callers that have already split a
1355/// `UNION`-joined Cypher query into its left and right arms (e.g. a parser
1356/// pass). Single-statement callers use [`lower_statement`] directly.
1357///
1358/// # Errors
1359///
1360/// Returns the first [`PlanLowerError`] produced by either arm; see
1361/// [`lower_statement`] for the precondition contract.
1362pub fn lower_union_pair(
1363    left: &Statement,
1364    right: &Statement,
1365    kind: UnionKind,
1366) -> Result<PlanStatement, PlanLowerError> {
1367    let mut left_plan = lower_statement(left)?;
1368    let right_plan = lower_statement(right)?;
1369
1370    // The left plan's op arena is the base; we offset the right plan's OpIds.
1371    // Plan arenas are limited to u32::MAX ops in practice; use truncating cast
1372    // intentionally here — a plan with 4+ billion operators is unreachable.
1373    #[allow(clippy::cast_possible_truncation)]
1374    let offset = left_plan.ops.len() as u32;
1375    #[allow(clippy::cast_possible_truncation)]
1376    let right_root = OpId(right_plan.ops.len() as u32 - 1 + offset);
1377
1378    // Append right ops (no OpId rewriting needed — Union references by index).
1379    left_plan.ops.extend(right_plan.ops);
1380    left_plan.write_ops.extend(right_plan.write_ops);
1381    // Merge var_maps (plan VarIds from the right are offset).
1382    for (plan_var, hir_var) in right_plan.var_map {
1383        left_plan
1384            .var_map
1385            .insert(VarId(plan_var.0 + offset), hir_var);
1386    }
1387
1388    let left_root = OpId(offset - 1);
1389    left_plan.ops.push(ReadOp::Union {
1390        left: left_root,
1391        right: right_root,
1392        kind,
1393    });
1394
1395    Ok(left_plan)
1396}
1397
1398// ── Public helper: apply ORDER BY / SKIP / LIMIT ─────────────────────────────
1399
1400/// Wrap the root operator of `plan` with `ORDER BY`, `SKIP`, and/or `LIMIT`
1401/// operators if the corresponding lists/values are non-empty / Some.
1402///
1403/// This is provided as a separate helper so callers that parse `ORDER BY` /
1404/// `SKIP` / `LIMIT` outside the clause list (e.g. as modifiers on `RETURN`)
1405/// can apply them after lowering.
1406pub fn apply_order_skip_limit(
1407    plan: &mut PlanStatement,
1408    order_keys: Vec<OrderKey>,
1409    skip: Option<Expr>,
1410    limit: Option<Expr>,
1411) {
1412    if plan.ops.is_empty() {
1413        return;
1414    }
1415    #[allow(clippy::cast_possible_truncation)]
1416    let mut root = OpId(plan.ops.len() as u32 - 1);
1417
1418    if !order_keys.is_empty() {
1419        let op = ReadOp::OrderBy {
1420            input: root,
1421            keys: order_keys,
1422        };
1423        root = plan.push(op);
1424    }
1425    if let Some(count) = skip {
1426        let op = ReadOp::Skip { input: root, count };
1427        root = plan.push(op);
1428    }
1429    if let Some(count) = limit {
1430        let op = ReadOp::Limit { input: root, count };
1431        root = plan.push(op);
1432    }
1433    let _ = root;
1434}
1435
1436// ── Tests ─────────────────────────────────────────────────────────────────────
1437
1438#[cfg(test)]
1439mod tests {
1440    use super::*;
1441    use crate::SortDir;
1442    use cyrs_hir::desugar::desugar_statement;
1443    use cyrs_hir::lower::lower_statement as hir_lower;
1444
1445    // Helper: lower from source Cypher → plan via HIR.
1446    fn plan_from(src: &str) -> PlanStatement {
1447        let hir = hir_lower(src);
1448        let hir = desugar_statement(hir);
1449        lower_statement(&hir).expect("plan_from: input HIR must be resolved and desugared")
1450    }
1451
1452    // Helper: render a plan to a stable, readable string for snapshots.
1453    fn render(plan: &PlanStatement) -> String {
1454        use std::fmt::Write;
1455        let mut out = String::new();
1456        writeln!(out, "read_ops: {}", plan.ops.len()).unwrap();
1457        writeln!(out, "write_ops: {}", plan.write_ops.len()).unwrap();
1458        writeln!(out, "var_map_entries: {}", plan.var_map.len()).unwrap();
1459        for (i, op) in plan.ops.iter().enumerate() {
1460            writeln!(out, "op[{i}]: {}", op_tag(op)).unwrap();
1461        }
1462        for (i, wop) in plan.write_ops.iter().enumerate() {
1463            writeln!(out, "write[{i}]: {}", write_op_tag(wop)).unwrap();
1464        }
1465        out
1466    }
1467
1468    fn op_tag(op: &ReadOp) -> String {
1469        match op {
1470            ReadOp::Source { label, bind } => format!(
1471                "Source(label={}, bind={})",
1472                label
1473                    .as_ref()
1474                    .map_or("None".into(), |l| format!("{:?}", l.0)),
1475                bind.0
1476            ),
1477            ReadOp::Expand {
1478                from,
1479                bind_rel,
1480                bind_to,
1481                ..
1482            } => {
1483                format!(
1484                    "Expand(from={}, bind_rel={}, bind_to={})",
1485                    from.0, bind_rel.0, bind_to.0
1486                )
1487            }
1488            ReadOp::Filter { input, .. } => format!("Filter(input={})", input.0),
1489            ReadOp::Project { input, items } => {
1490                format!("Project(input={}, cols={})", input.0, items.len())
1491            }
1492            ReadOp::Aggregate { input, keys, aggs } => {
1493                format!(
1494                    "Aggregate(input={}, keys={}, aggs={})",
1495                    input.0,
1496                    keys.len(),
1497                    aggs.len()
1498                )
1499            }
1500            ReadOp::OrderBy { input, keys } => {
1501                format!("OrderBy(input={}, keys={})", input.0, keys.len())
1502            }
1503            ReadOp::Skip { input, .. } => format!("Skip(input={})", input.0),
1504            ReadOp::Limit { input, .. } => format!("Limit(input={})", input.0),
1505            ReadOp::Distinct { input } => format!("Distinct(input={})", input.0),
1506            ReadOp::Unwind { input, bind, .. } => {
1507                format!("Unwind(input={}, bind={})", input.0, bind.0)
1508            }
1509            ReadOp::Union { left, right, kind } => {
1510                format!("Union(left={}, right={}, kind={:?})", left.0, right.0, kind)
1511            }
1512            ReadOp::With {
1513                input,
1514                items,
1515                filter,
1516            } => {
1517                format!(
1518                    "With(input={}, cols={}, has_filter={})",
1519                    input.0,
1520                    items.len(),
1521                    filter.is_some()
1522                )
1523            }
1524            ReadOp::OptionalJoin { input, .. } => format!("OptionalJoin(input={})", input.0),
1525        }
1526    }
1527
1528    fn write_op_tag(op: &WriteOp) -> String {
1529        match op {
1530            WriteOp::CreateNode { labels, bind, .. } => {
1531                format!(
1532                    "CreateNode(labels={:?}, bind={:?})",
1533                    labels,
1534                    bind.map(|v| v.0)
1535                )
1536            }
1537            WriteOp::CreateRel { rel_type, bind, .. } => {
1538                format!("CreateRel(type={rel_type}, bind={:?})", bind.map(|v| v.0))
1539            }
1540            WriteOp::MergeNode { labels, bind, .. } => {
1541                format!(
1542                    "MergeNode(labels={:?}, bind={:?})",
1543                    labels,
1544                    bind.map(|v| v.0)
1545                )
1546            }
1547            WriteOp::MergeRel { rel_type, bind, .. } => {
1548                format!("MergeRel(type={rel_type}, bind={:?})", bind.map(|v| v.0))
1549            }
1550            WriteOp::SetProperty { target, prop, .. } => {
1551                format!("SetProperty(target={}, prop={prop})", target.0)
1552            }
1553            WriteOp::SetLabels { target, labels } => {
1554                format!("SetLabels(target={}, labels={:?})", target.0, labels)
1555            }
1556            WriteOp::RemoveProperty { target, prop } => {
1557                format!("RemoveProperty(target={}, prop={prop})", target.0)
1558            }
1559            WriteOp::RemoveLabels { target, labels } => {
1560                format!("RemoveLabels(target={}, labels={:?})", target.0, labels)
1561            }
1562            WriteOp::Delete { detach, targets } => {
1563                format!("Delete(detach={detach}, targets={})", targets.len())
1564            }
1565        }
1566    }
1567
1568    // ── Snapshot tests (15+) ─────────────────────────────────────────────────
1569
1570    // 1. Single MATCH
1571    #[test]
1572    fn snap_single_match() {
1573        let plan = plan_from("MATCH (n) RETURN n");
1574        insta::assert_snapshot!("plan_single_match", render(&plan));
1575    }
1576
1577    // 2. MATCH with label
1578    #[test]
1579    fn snap_match_with_label() {
1580        let plan = plan_from("MATCH (n:Person) RETURN n");
1581        insta::assert_snapshot!("plan_match_with_label", render(&plan));
1582    }
1583
1584    // 3. MATCH + WHERE
1585    #[test]
1586    fn snap_match_where() {
1587        let plan = plan_from("MATCH (n) WHERE n.age > 18 RETURN n");
1588        insta::assert_snapshot!("plan_match_where", render(&plan));
1589    }
1590
1591    // cy-ypm: canonical MATCH+WHERE must pretty-print as a proper
1592    // Project → Filter → Source chain, not an orphan Filter over
1593    // EMPTY_SOURCE.  This pins the end-to-end lowering shape.
1594    #[test]
1595    fn snap_match_where_pretty_tree() {
1596        use crate::pretty::pretty;
1597        let plan = plan_from("MATCH (a) WHERE a.x = 1 RETURN a");
1598        insta::assert_snapshot!("plan_match_where_pretty_tree", pretty(&plan));
1599    }
1600
1601    // 4. MATCH + WITH
1602    #[test]
1603    fn snap_match_with() {
1604        let plan = plan_from("MATCH (n) WITH n RETURN n");
1605        insta::assert_snapshot!("plan_match_with", render(&plan));
1606    }
1607
1608    // 5. MATCH + RETURN with property projection
1609    #[test]
1610    fn snap_match_return_projection() {
1611        let plan = plan_from("MATCH (n:Person) RETURN n.name, n.age");
1612        insta::assert_snapshot!("plan_match_return_projection", render(&plan));
1613    }
1614
1615    // 6. RETURN DISTINCT
1616    #[test]
1617    fn snap_return_distinct() {
1618        let plan = plan_from("MATCH (n) RETURN DISTINCT n.name");
1619        insta::assert_snapshot!("plan_return_distinct", render(&plan));
1620    }
1621
1622    // 7. UNWIND — build HIR directly because UNWIND's RETURN uses unresolved
1623    // `x` when going through the text path (name resolution cy-b4b not yet
1624    // run). We construct the HIR manually with resolved VarIds.
1625    #[test]
1626    fn snap_unwind() {
1627        use cyrs_hir::{
1628            Binding, Clause, Expr as HirExpr, HirSpan, Statement, VarId as HirVarId, VarKind,
1629        };
1630        let span = HirSpan::default();
1631        let mut stmt = Statement::new(span);
1632        // Synthesise a dummy HirId by using a minimal syntax node from the
1633        // HIR's own test helper — or just use a minimal approach with alloc_id.
1634        // Since Statement::alloc_id requires a SyntaxNode we use an internal
1635        // field instead by pushing a DUMMY id (OK for test — not in prod).
1636        let x_var = HirVarId(0);
1637        stmt.bindings.insert(
1638            x_var,
1639            Binding {
1640                id: x_var,
1641                name: "x".into(),
1642                kind: VarKind::Value,
1643                defined_at: span,
1644            },
1645        );
1646        stmt.clauses.push(Clause::Unwind {
1647            id: cyrs_hir::HirId::DUMMY,
1648            list: HirExpr::List(vec![HirExpr::Int(1), HirExpr::Int(2), HirExpr::Int(3)]),
1649            bind: x_var,
1650            span,
1651        });
1652        stmt.clauses.push(Clause::Return {
1653            id: cyrs_hir::HirId::DUMMY,
1654            projections: vec![cyrs_hir::Projection {
1655                expr: HirExpr::Var(x_var),
1656                alias: Some("x".into()),
1657                span,
1658            }],
1659            distinct: false,
1660            span,
1661        });
1662        let plan = lower_statement(&stmt).expect("manually-built HIR must be resolved");
1663        insta::assert_snapshot!("plan_unwind", render(&plan));
1664    }
1665
1666    // 8. CREATE node
1667    #[test]
1668    fn snap_create_node() {
1669        let plan = plan_from("CREATE (n:Person)");
1670        insta::assert_snapshot!("plan_create_node", render(&plan));
1671    }
1672
1673    // 9. CREATE relationship
1674    #[test]
1675    fn snap_create_rel() {
1676        let plan = plan_from("MATCH (a:Person), (b:Person) CREATE (a)-[:KNOWS]->(b)");
1677        insta::assert_snapshot!("plan_create_rel", render(&plan));
1678    }
1679
1680    // 10. MERGE node
1681    #[test]
1682    fn snap_merge_node() {
1683        let plan = plan_from("MERGE (n:Person {name: 'Alice'})");
1684        insta::assert_snapshot!("plan_merge_node", render(&plan));
1685    }
1686
1687    // 11. SET property
1688    #[test]
1689    fn snap_set_property() {
1690        let plan = plan_from("MATCH (n:Person) SET n.age = 30");
1691        insta::assert_snapshot!("plan_set_property", render(&plan));
1692    }
1693
1694    // 12. REMOVE label
1695    #[test]
1696    fn snap_remove_label() {
1697        let plan = plan_from("MATCH (n:Person) REMOVE n:Person");
1698        insta::assert_snapshot!("plan_remove_label", render(&plan));
1699    }
1700
1701    // 13. DELETE
1702    #[test]
1703    fn snap_delete() {
1704        let plan = plan_from("MATCH (n) DELETE n");
1705        insta::assert_snapshot!("plan_delete", render(&plan));
1706    }
1707
1708    // 14. DETACH DELETE
1709    #[test]
1710    fn snap_detach_delete() {
1711        let plan = plan_from("MATCH (n) DETACH DELETE n");
1712        insta::assert_snapshot!("plan_detach_delete", render(&plan));
1713    }
1714
1715    // 15. Aggregation — count
1716    #[test]
1717    fn snap_aggregation_count() {
1718        let plan = plan_from("MATCH (n) RETURN count(n)");
1719        insta::assert_snapshot!("plan_aggregation_count", render(&plan));
1720    }
1721
1722    // 16. Aggregation — sum
1723    #[test]
1724    fn snap_aggregation_sum() {
1725        let plan = plan_from("MATCH (n) RETURN sum(n.age)");
1726        insta::assert_snapshot!("plan_aggregation_sum", render(&plan));
1727    }
1728
1729    // 17. UNION ALL
1730    #[test]
1731    fn snap_union_all() {
1732        let left_hir = desugar_statement(hir_lower("MATCH (n:Person) RETURN n"));
1733        let right_hir = desugar_statement(hir_lower("MATCH (n:Animal) RETURN n"));
1734        let plan = lower_union_pair(&left_hir, &right_hir, UnionKind::All)
1735            .expect("UNION arms must be resolved/desugared");
1736        insta::assert_snapshot!("plan_union_all", render(&plan));
1737    }
1738
1739    // 18. UNION (distinct)
1740    #[test]
1741    fn snap_union_distinct() {
1742        let left_hir = desugar_statement(hir_lower("MATCH (n:Person) RETURN n"));
1743        let right_hir = desugar_statement(hir_lower("MATCH (n:Animal) RETURN n"));
1744        let plan = lower_union_pair(&left_hir, &right_hir, UnionKind::Distinct)
1745            .expect("UNION arms must be resolved/desugared");
1746        insta::assert_snapshot!("plan_union_distinct", render(&plan));
1747    }
1748
1749    // 19. OPTIONAL MATCH
1750    #[test]
1751    fn snap_optional_match() {
1752        let plan = plan_from("MATCH (n) OPTIONAL MATCH (n)-[:KNOWS]->(m) RETURN n, m");
1753        insta::assert_snapshot!("plan_optional_match", render(&plan));
1754    }
1755
1756    // 20. MATCH relationship chain
1757    #[test]
1758    fn snap_match_rel_chain() {
1759        let plan = plan_from("MATCH (a)-[:KNOWS]->(b) RETURN a, b");
1760        insta::assert_snapshot!("plan_match_rel_chain", render(&plan));
1761    }
1762
1763    // 21. apply_order_skip_limit helper
1764    #[test]
1765    fn snap_order_skip_limit() {
1766        let mut plan = plan_from("MATCH (n) RETURN n");
1767        apply_order_skip_limit(
1768            &mut plan,
1769            vec![OrderKey {
1770                expr: Expr::Var(VarId(0)),
1771                dir: SortDir::Desc,
1772            }],
1773            Some(Expr::Int(10)),
1774            Some(Expr::Int(5)),
1775        );
1776        insta::assert_snapshot!("plan_order_skip_limit", render(&plan));
1777    }
1778
1779    // ── Determinism check ────────────────────────────────────────────────────
1780
1781    #[test]
1782    fn plan_lowering_is_deterministic() {
1783        let plan1 = plan_from("MATCH (n:Person) WHERE n.age > 18 RETURN n.name, n.age");
1784        let plan2 = plan_from("MATCH (n:Person) WHERE n.age > 18 RETURN n.name, n.age");
1785        assert_eq!(render(&plan1), render(&plan2));
1786    }
1787
1788    // ── Structural correctness checks ────────────────────────────────────────
1789
1790    #[test]
1791    fn single_match_returns_source_and_project() {
1792        let plan = plan_from("MATCH (n) RETURN n");
1793        assert!(plan.ops.len() >= 2);
1794        assert!(matches!(plan.ops[0], ReadOp::Source { .. }));
1795        assert!(matches!(plan.ops.last(), Some(ReadOp::Project { .. })));
1796    }
1797
1798    #[test]
1799    fn match_where_inserts_filter() {
1800        let plan = plan_from("MATCH (n) WHERE n.age > 18 RETURN n");
1801        let has_filter = plan
1802            .ops
1803            .iter()
1804            .any(|op| matches!(op, ReadOp::Filter { .. }));
1805        assert!(has_filter, "expected Filter op in plan");
1806    }
1807
1808    #[test]
1809    fn create_node_emits_write_op() {
1810        // Build HIR directly: the cy-nom parser stubs CREATE clauses as ERROR
1811        // nodes so we test the lowering path by constructing the HIR manually.
1812        use cyrs_hir::{
1813            Binding, Clause, HirSpan, Pattern, PatternElement, PatternPart, Statement,
1814            VarId as HirVarId, VarKind,
1815        };
1816        let span = HirSpan::default();
1817        let mut stmt = Statement::new(span);
1818        let n_var = HirVarId(0);
1819        stmt.bindings.insert(
1820            n_var,
1821            Binding {
1822                id: n_var,
1823                name: "n".into(),
1824                kind: VarKind::Node,
1825                defined_at: span,
1826            },
1827        );
1828        stmt.clauses.push(Clause::Create {
1829            id: cyrs_hir::HirId::DUMMY,
1830            pattern: Pattern {
1831                parts: vec![PatternPart {
1832                    named_as: None,
1833                    elements: vec![PatternElement::Node {
1834                        id: cyrs_hir::HirId::DUMMY,
1835                        bind: Some(n_var),
1836                        labels: vec!["Person".into()],
1837                        props: None,
1838                        span,
1839                    }],
1840                }],
1841            },
1842            span,
1843        });
1844        let plan = lower_statement(&stmt).expect("manually-built HIR must be resolved");
1845        assert!(
1846            plan.write_ops
1847                .iter()
1848                .any(|w| matches!(w, WriteOp::CreateNode { .. })),
1849            "expected CreateNode write op; write_ops={:?}",
1850            plan.write_ops.iter().map(write_op_tag).collect::<Vec<_>>()
1851        );
1852    }
1853
1854    #[test]
1855    fn delete_emits_write_op() {
1856        // Build HIR directly since the cy-nom parser stubs DELETE as ERROR nodes.
1857        use cyrs_hir::{
1858            Binding, Clause, Expr as HirExpr, HirSpan, Pattern, PatternElement, PatternPart,
1859            Statement, VarId as HirVarId, VarKind,
1860        };
1861        let span = HirSpan::default();
1862        let mut stmt = Statement::new(span);
1863        let n_var = HirVarId(0);
1864        stmt.bindings.insert(
1865            n_var,
1866            Binding {
1867                id: n_var,
1868                name: "n".into(),
1869                kind: VarKind::Node,
1870                defined_at: span,
1871            },
1872        );
1873        stmt.clauses.push(Clause::Match {
1874            id: cyrs_hir::HirId::DUMMY,
1875            optional: false,
1876            pattern: Pattern {
1877                parts: vec![PatternPart {
1878                    named_as: None,
1879                    elements: vec![PatternElement::Node {
1880                        id: cyrs_hir::HirId::DUMMY,
1881                        bind: Some(n_var),
1882                        labels: vec![],
1883                        props: None,
1884                        span,
1885                    }],
1886                }],
1887            },
1888            span,
1889        });
1890        stmt.clauses.push(Clause::Delete {
1891            id: cyrs_hir::HirId::DUMMY,
1892            targets: vec![HirExpr::Var(n_var)],
1893            detach: false,
1894            span,
1895        });
1896        let plan = lower_statement(&stmt).expect("manually-built HIR must be resolved");
1897        assert!(
1898            plan.write_ops
1899                .iter()
1900                .any(|w| matches!(w, WriteOp::Delete { detach: false, .. })),
1901            "expected Delete(detach=false) write op"
1902        );
1903    }
1904
1905    #[test]
1906    fn detach_delete_emits_write_op() {
1907        // Build HIR directly since the cy-nom parser stubs DETACH DELETE as
1908        // ERROR nodes.
1909        use cyrs_hir::{
1910            Binding, Clause, Expr as HirExpr, HirSpan, Pattern, PatternElement, PatternPart,
1911            Statement, VarId as HirVarId, VarKind,
1912        };
1913        let span = HirSpan::default();
1914        let mut stmt = Statement::new(span);
1915        let n_var = HirVarId(0);
1916        stmt.bindings.insert(
1917            n_var,
1918            Binding {
1919                id: n_var,
1920                name: "n".into(),
1921                kind: VarKind::Node,
1922                defined_at: span,
1923            },
1924        );
1925        stmt.clauses.push(Clause::Match {
1926            id: cyrs_hir::HirId::DUMMY,
1927            optional: false,
1928            pattern: Pattern {
1929                parts: vec![PatternPart {
1930                    named_as: None,
1931                    elements: vec![PatternElement::Node {
1932                        id: cyrs_hir::HirId::DUMMY,
1933                        bind: Some(n_var),
1934                        labels: vec![],
1935                        props: None,
1936                        span,
1937                    }],
1938                }],
1939            },
1940            span,
1941        });
1942        stmt.clauses.push(Clause::Delete {
1943            id: cyrs_hir::HirId::DUMMY,
1944            targets: vec![HirExpr::Var(n_var)],
1945            detach: true,
1946            span,
1947        });
1948        let plan = lower_statement(&stmt).expect("manually-built HIR must be resolved");
1949        assert!(
1950            plan.write_ops
1951                .iter()
1952                .any(|w| matches!(w, WriteOp::Delete { detach: true, .. })),
1953            "expected Delete(detach=true) write op"
1954        );
1955    }
1956
1957    #[test]
1958    fn var_map_populated_for_bound_variables() {
1959        let plan = plan_from("MATCH (n) RETURN n");
1960        assert!(
1961            !plan.var_map.is_empty(),
1962            "var_map should be populated for bound variables"
1963        );
1964    }
1965
1966    // cy-v31: WHERE after WITH must survive end-to-end (source → HIR → plan)
1967    // and materialise in `ReadOp::With { filter: Some(_), .. }`.
1968    #[test]
1969    fn with_where_threads_filter_into_plan() {
1970        let plan = plan_from(
1971            "MATCH (a) UNWIND a.aliases AS alias \
1972             WITH a, alias WHERE alias CONTAINS 'Fancy' \
1973             RETURN DISTINCT a.canonical_name",
1974        );
1975        let has_with_filter = plan.ops.iter().any(|op| {
1976            matches!(
1977                op,
1978                ReadOp::With {
1979                    filter: Some(_),
1980                    ..
1981                }
1982            )
1983        });
1984        assert!(
1985            has_with_filter,
1986            "expected ReadOp::With with a Some(filter); plan ops = {:#?}",
1987            plan.ops
1988        );
1989    }
1990
1991    // ── cy-wlr: precondition violations surface as Err, not panic ────────────
1992
1993    /// Build a skeletal statement whose single RETURN projects `expr`.
1994    fn stmt_with_return_expr(expr: HirExpr) -> Statement {
1995        use cyrs_hir::HirSpan;
1996        let span = HirSpan::default();
1997        let mut stmt = Statement::new(span);
1998        stmt.clauses.push(Clause::Return {
1999            id: cyrs_hir::HirId::DUMMY,
2000            projections: vec![Projection {
2001                expr,
2002                alias: Some("x".into()),
2003                span,
2004            }],
2005            distinct: false,
2006            span,
2007        });
2008        stmt
2009    }
2010
2011    /// An `Expr::Unresolved` surviving into `lower_statement` must not
2012    /// panic — it must return `Err(UnresolvedName { name, .. })`.
2013    #[test]
2014    fn lower_statement_returns_err_on_unresolved_name() {
2015        let stmt = stmt_with_return_expr(HirExpr::Unresolved("foo".into()));
2016        let err = lower_statement(&stmt).expect_err("unresolved name must be rejected");
2017        match err {
2018            PlanLowerError::UnresolvedName { name, .. } => assert_eq!(name, "foo"),
2019            other => panic!("expected UnresolvedName, got {other:?}"),
2020        }
2021    }
2022
2023    /// Un-desugared `ListComprehension` must surface as `UndesugaredExpr`.
2024    #[test]
2025    fn lower_statement_returns_err_on_listcomp() {
2026        let expr = HirExpr::ListComprehension {
2027            filter_var: HirVarId(0),
2028            iterable: Box::new(HirExpr::List(vec![HirExpr::Int(1)])),
2029            filter: None,
2030            map_expr: Box::new(HirExpr::Var(HirVarId(0))),
2031        };
2032        let stmt = stmt_with_return_expr(expr);
2033        let err = lower_statement(&stmt).expect_err("list comprehension must be rejected");
2034        match err {
2035            PlanLowerError::UndesugaredExpr { kind, .. } => assert_eq!(kind, "ListComprehension"),
2036            other => panic!("expected UndesugaredExpr(ListComprehension), got {other:?}"),
2037        }
2038    }
2039
2040    /// Un-desugared `MapProjection` must surface as `UndesugaredExpr`.
2041    #[test]
2042    fn lower_statement_returns_err_on_mapprojection() {
2043        let expr = HirExpr::MapProjection {
2044            base: Box::new(HirExpr::Var(HirVarId(0))),
2045            items: vec![],
2046        };
2047        let stmt = stmt_with_return_expr(expr);
2048        let err = lower_statement(&stmt).expect_err("map projection must be rejected");
2049        match err {
2050            PlanLowerError::UndesugaredExpr { kind, .. } => assert_eq!(kind, "MapProjection"),
2051            other => panic!("expected UndesugaredExpr(MapProjection), got {other:?}"),
2052        }
2053    }
2054
2055    /// cy-863: an `Expr::Unresolved` hidden inside a `PatternPredicate`'s
2056    /// embedded pattern (e.g. an unresolved name in a node-property
2057    /// expression) must be reported via the same `UnresolvedName` error
2058    /// path as a top-level unresolved name — not surface as a deep
2059    /// `debug_assert!` panic from `lower_expr`.
2060    #[test]
2061    fn lower_statement_returns_err_on_unresolved_inside_patternpredicate() {
2062        let element = PatternElement::Node {
2063            id: cyrs_hir::HirId::DUMMY,
2064            bind: None,
2065            labels: vec![],
2066            props: Some(HirExpr::Map(vec![(
2067                "k".into(),
2068                HirExpr::Unresolved("vaext".into()),
2069            )])),
2070            span: HirSpan::default(),
2071        };
2072        let pattern = cyrs_hir::Pattern {
2073            parts: vec![PatternPart {
2074                named_as: None,
2075                elements: vec![element],
2076            }],
2077        };
2078        let stmt = stmt_with_return_expr(HirExpr::PatternPredicate(pattern));
2079        let err = lower_statement(&stmt)
2080            .expect_err("unresolved name inside PatternPredicate must be rejected");
2081        match err {
2082            PlanLowerError::UnresolvedName { name, .. } => assert_eq!(name, "vaext"),
2083            other => panic!("expected UnresolvedName, got {other:?}"),
2084        }
2085    }
2086
2087    /// cy-863 (text path): exercise the same code path the `fuzz_plan`
2088    /// harness uses (parse → HIR lower → desugar → plan lower) on a
2089    /// snippet that puts an unresolved name inside a pattern predicate's
2090    /// node properties. Without the precheck recursion this triggered a
2091    /// `debug_assert!` panic; now it must surface as a clean `Err` (or
2092    /// `Ok` if upstream lowering happens to bind the name some other
2093    /// way — the oracle is "no panic", same as the fuzz target).
2094    #[test]
2095    fn lower_statement_no_panic_on_unresolved_inside_patternpredicate_text() {
2096        let s = "MATCH (n) WHERE (n {k: vaext})-->() RETURN n\n";
2097        let stmt = hir_lower(s);
2098        let stmt = desugar_statement(stmt);
2099        // Must not panic; either Ok (resolved by HIR lowering) or Err.
2100        let _ = lower_statement(&stmt);
2101    }
2102
2103    /// Pattern predicates are now accepted by plan lowering (cy-lve) and
2104    /// emerge as `Expr::Exists { pattern }`. This test locks the new
2105    /// behaviour: an empty pattern still yields a plan, and the
2106    /// projection carries the `Expr::Exists` variant.
2107    #[test]
2108    fn lower_statement_accepts_patternpredicate_as_exists() {
2109        let expr = HirExpr::PatternPredicate(cyrs_hir::Pattern { parts: vec![] });
2110        let stmt = stmt_with_return_expr(expr);
2111        let plan = lower_statement(&stmt).expect("pattern predicate must lower to Exists");
2112        // Walk every projection: at least one must be `Expr::Exists { .. }`.
2113        let mut saw_exists = false;
2114        for op in &plan.ops {
2115            if let ReadOp::Project { items, .. } = op {
2116                for item in items {
2117                    if matches!(item.expr, Expr::Exists { .. }) {
2118                        saw_exists = true;
2119                    }
2120                }
2121            }
2122        }
2123        assert!(
2124            saw_exists,
2125            "expected plan to carry Expr::Exists after PatternPredicate lowering, got {plan:?}"
2126        );
2127    }
2128}