Skip to main content

cyrs_plan/
lib.rs

1//! `cyrs-plan` — logical read/write plan IR (spec 0001 §12).
2//!
3//! The plan is a directed acyclic graph of operators. It is *logical*:
4//! no cost model, no cardinality, no physical operator selection. A
5//! consumer's executor takes the plan and produces rows / effects. This
6//! crate imposes no runtime contract on that executor beyond the shape
7//! of the plan itself (spec §12.5).
8//!
9//! # Key types
10//!
11//! - [`ReadOp`] — read-side operator tree (spec §12.1).
12//! - [`WriteOp`] — write-side operator list (spec §12.1).
13//! - [`Expr`] — plan-level expression IR (spec §12.2).
14//! - [`VarId`] — plan-scoped variable identity (spec §12.3).
15//! - [`OpId`] — operator identity within a plan graph.
16//!
17//! # HIR → Plan lowering
18//!
19//! The [`lower`] module provides the entry point [`lower::lower_statement`]
20//! which lowers a post-resolve, post-desugar HIR [`cyrs_hir::Statement`]
21//! into a [`lower::PlanStatement`] (spec §12, bead cy-foy).
22
23// Embedders: see ../../docs/integration-depth.md before depending on this surface.
24
25#![forbid(unsafe_code)]
26#![doc(html_root_url = "https://docs.rs/cyrs-plan/0.0.1")]
27
28pub mod error;
29pub mod lower;
30pub mod pretty;
31
32#[cfg(feature = "serde")]
33pub mod ser;
34
35pub use error::PlanLowerError;
36
37use smol_str::SmolStr;
38
39// ──────────────────────────────────────────────────────────────────────────────
40// Identity types
41// ──────────────────────────────────────────────────────────────────────────────
42
43/// Stable plan-scoped identifier for a variable.
44///
45/// `VarId`s are plan-scoped — they survive the HIR from which the plan was
46/// lowered. The lowering pass (bead cy-foy) produces a `VarMap` mapping these
47/// back to HIR [`cyrs_hir::VarId`]s. See spec §12.3.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
49pub struct VarId(pub u32);
50
51/// Operator identity within a plan graph.
52///
53/// `OpId`s are dense indices into a consumer-maintained operator arena.
54/// The plan itself does not maintain an arena — consumers choose their own
55/// storage. See spec §12.1.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub struct OpId(pub u32);
58
59// ──────────────────────────────────────────────────────────────────────────────
60// Supporting types
61// ──────────────────────────────────────────────────────────────────────────────
62
63/// A set of node labels used in pattern-matching predicates. Spec §12.1.
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct LabelSet(pub Vec<SmolStr>);
66
67/// Specification for the node endpoint of an expand operator. Spec §12.1.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct NodeSpec {
70    /// Labels the target node must carry.
71    pub labels: LabelSet,
72    /// Optional inline property predicate on the target node.
73    pub properties: Option<Expr>,
74}
75
76/// Specification for the relationship in an expand operator. Spec §12.1.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct RelSpec {
79    /// Allowed relationship types (empty = any type).
80    pub types: Vec<SmolStr>,
81    /// Traversal direction.
82    pub direction: Direction,
83    /// Variable-length qualifier.
84    pub length: RelLength,
85    /// Optional inline property predicate on the relationship.
86    pub properties: Option<Expr>,
87}
88
89/// Relationship traversal direction as written in the source. Spec §5.3.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91#[non_exhaustive]
92pub enum Direction {
93    /// `-[r]->` — left-to-right.
94    Outgoing,
95    /// `<-[r]-` — right-to-left.
96    Incoming,
97    /// `-[r]-` — either direction.
98    Undirected,
99}
100
101/// Variable-length relationship bounds. `Single` means no `*` qualifier.
102/// Spec §5.3.
103#[derive(Debug, Clone, PartialEq, Eq, Hash)]
104#[non_exhaustive]
105pub enum RelLength {
106    /// Exactly one hop (`-[r]->`).
107    Single,
108    /// Variable number of hops (`-[r*min..max]->`). Bounds of `None` mean
109    /// unbounded in that direction.
110    Variable {
111        /// Lower bound on the number of hops; `None` means no lower bound.
112        min: Option<u64>,
113        /// Upper bound on the number of hops; `None` means no upper bound.
114        max: Option<u64>,
115    },
116}
117
118/// Disposition of a `UNION`. Spec §12.1.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
120#[non_exhaustive]
121pub enum UnionKind {
122    /// `UNION ALL` — duplicate rows are preserved.
123    All,
124    /// `UNION` — duplicate rows are removed.
125    Distinct,
126}
127
128/// A single output column of a `PROJECT` or `WITH` operator. Spec §12.1.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Projection {
131    /// Expression to evaluate.
132    pub expr: Expr,
133    /// Column alias (always explicit at plan level — the lowering pass
134    /// synthesises aliases for bare variable references).
135    pub alias: SmolStr,
136}
137
138/// A sort key in an `ORDER BY` operator. Spec §12.1.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct OrderKey {
141    /// Expression to sort on.
142    pub expr: Expr,
143    /// Sort direction.
144    pub dir: SortDir,
145}
146
147/// Sort direction for [`OrderKey`]. Spec §12.1.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
149#[non_exhaustive]
150pub enum SortDir {
151    /// `ASC` / default.
152    Asc,
153    /// `DESC`.
154    Desc,
155}
156
157/// An aggregation call in an `AGGREGATE` operator.
158///
159/// The function name is resolved at this level (cf. [`Expr::Call`] which
160/// carries the resolved function name as well). The `aggregate = true` flag
161/// in the function catalog entry gates whether a call may appear here.
162/// Spec §12.1, §8.3.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct AggExpr {
165    /// Resolved aggregation function name (e.g. `"count"`, `"sum"`).
166    pub func: SmolStr,
167    /// Argument expressions.
168    pub args: Vec<Expr>,
169    /// `true` when `DISTINCT` modifier is present (`count(DISTINCT x)`).
170    pub distinct: bool,
171}
172
173// ──────────────────────────────────────────────────────────────────────────────
174// ReadOp
175// ──────────────────────────────────────────────────────────────────────────────
176
177/// Logical read-plan operator tree. Spec §12.1.
178///
179/// Operators form a DAG — each variant that has an `input` field references
180/// its source by [`OpId`]. The `OptionalJoin` variant embeds a sub-tree
181/// directly via `Box<ReadOp>` because its inner pattern is always a fresh
182/// tree introduced by `OPTIONAL MATCH`.
183///
184/// Consumers iterate the tree in whatever order suits their executor. This
185/// crate imposes no evaluation semantics.
186#[derive(Debug, Clone, PartialEq, Eq)]
187#[non_exhaustive]
188pub enum ReadOp {
189    /// Scan all nodes, optionally filtered to those carrying a set of labels.
190    ///
191    /// When `label` is `None` this is an all-node scan; when `Some` it is a
192    /// label-index scan (or filtered full scan, depending on the consumer's
193    /// storage). Spec §12.1 N1.
194    Source {
195        /// Labels to filter on, or `None` for every node.
196        label: Option<LabelSet>,
197        /// Variable that receives each scanned node.
198        bind: VarId,
199    },
200
201    /// Expand a node into its adjacent relationships and neighbour nodes.
202    ///
203    /// Starting from `from` (already bound in `input`), traverse relationships
204    /// matching `rel` to reach a node matching `to`. Spec §12.1 N2.
205    Expand {
206        /// Source operator that provides the `from` variable.
207        input: OpId,
208        /// Variable holding the start node.
209        from: VarId,
210        /// Relationship type / direction / length specification.
211        rel: RelSpec,
212        /// Target node specification (labels + optional property predicate).
213        to: NodeSpec,
214        /// Variable that receives the traversed relationship.
215        bind_rel: VarId,
216        /// Variable that receives the reached node.
217        bind_to: VarId,
218    },
219
220    /// Predicate filter — keeps only rows where `predicate` is truthy.
221    ///
222    /// Implements `WHERE` and inline pattern predicates. Spec §12.1 N3.
223    Filter {
224        /// Source operator.
225        input: OpId,
226        /// Boolean expression; rows where it evaluates to `false` or `null`
227        /// are dropped.
228        predicate: Expr,
229    },
230
231    /// Column projection — renames / computes output columns.
232    ///
233    /// Implements the output column list of `RETURN` and `WITH`.
234    /// Spec §12.1 N4.
235    Project {
236        /// Source operator.
237        input: OpId,
238        /// Ordered list of output columns.
239        items: Vec<Projection>,
240    },
241
242    /// Aggregation — groups rows and applies aggregate functions.
243    ///
244    /// `keys` are the grouping expressions; `aggs` are the aggregate calls.
245    /// An empty `keys` vec aggregates the entire input into a single row.
246    /// Spec §12.1 N5.
247    Aggregate {
248        /// Source operator.
249        input: OpId,
250        /// Grouping expressions (the non-aggregate columns in the output).
251        keys: Vec<Expr>,
252        /// Aggregate function calls.
253        aggs: Vec<AggExpr>,
254    },
255
256    /// Sort — orders rows by a list of sort keys. Spec §12.1 N6.
257    OrderBy {
258        /// Source operator.
259        input: OpId,
260        /// Ordered list of sort keys (primary first).
261        keys: Vec<OrderKey>,
262    },
263
264    /// Skip — discards the first `count` rows. Spec §12.1 N7.
265    Skip {
266        /// Source operator.
267        input: OpId,
268        /// Number of rows to skip; must evaluate to a non-negative integer.
269        count: Expr,
270    },
271
272    /// Limit — keeps only the first `count` rows. Spec §12.1 N8.
273    Limit {
274        /// Source operator.
275        input: OpId,
276        /// Maximum number of rows to pass through.
277        count: Expr,
278    },
279
280    /// Distinct — removes duplicate rows. Spec §12.1 N9.
281    ///
282    /// Row equality is Cypher value equality (`null != null`).
283    Distinct {
284        /// Source operator.
285        input: OpId,
286    },
287
288    /// Unwind — flattens a list expression into one row per element.
289    ///
290    /// Implements `UNWIND list AS var`. Spec §12.1 N10.
291    Unwind {
292        /// Source operator.
293        input: OpId,
294        /// List expression to iterate.
295        list: Expr,
296        /// Variable that receives each list element.
297        bind: VarId,
298    },
299
300    /// Union — concatenates rows from two sub-plans. Spec §12.1 N11.
301    Union {
302        /// Left source operator.
303        left: OpId,
304        /// Right source operator.
305        right: OpId,
306        /// Whether to deduplicate the combined output.
307        kind: UnionKind,
308    },
309
310    /// With — projects columns and optionally filters, resetting scope.
311    ///
312    /// Implements the `WITH` clause. Differs from [`ReadOp::Project`] in
313    /// that `WITH` starts a new variable scope. Spec §12.1 N12.
314    With {
315        /// Source operator.
316        input: OpId,
317        /// Output columns.
318        items: Vec<Projection>,
319        /// Optional `WHERE` predicate applied after projection.
320        filter: Option<Expr>,
321    },
322
323    /// Optional join — left-outer-join a sub-plan. Spec §12.1 N13.
324    ///
325    /// Implements `OPTIONAL MATCH`. Rows from `input` that have no match in
326    /// `pattern` are kept with `null`-bound variables.
327    OptionalJoin {
328        /// Outer source operator.
329        input: OpId,
330        /// Inner pattern (embedded tree, not an [`OpId`], because it is
331        /// always a fresh sub-tree introduced by `OPTIONAL MATCH`).
332        pattern: Box<ReadOp>,
333    },
334}
335
336// ──────────────────────────────────────────────────────────────────────────────
337// WriteOp
338// ──────────────────────────────────────────────────────────────────────────────
339
340/// Logical write-plan operator. Spec §12.1.
341///
342/// Write operators are applied sequentially after the read phase produces a
343/// row. Consumers own the sequencing and transactional semantics. This crate
344/// describes *what* to write, not *how*.
345#[derive(Debug, Clone, PartialEq, Eq)]
346#[non_exhaustive]
347pub enum WriteOp {
348    /// Create a new node with the given labels and properties.
349    ///
350    /// `props` is evaluated to a map expression at write time. `bind`, if
351    /// present, makes the new node available under that variable for
352    /// subsequent operators. Spec §12.1 W1.
353    CreateNode {
354        /// Labels to apply to the new node.
355        labels: Vec<SmolStr>,
356        /// Map expression supplying the initial properties.
357        props: Expr,
358        /// Optional variable binding for the created node.
359        bind: Option<VarId>,
360    },
361
362    /// Create a new relationship between two already-bound nodes.
363    ///
364    /// Spec §12.1 W2.
365    CreateRel {
366        /// Variable holding the start node.
367        from: VarId,
368        /// Variable holding the end node.
369        to: VarId,
370        /// Relationship type (exactly one, required by Cypher syntax).
371        rel_type: SmolStr,
372        /// Map expression supplying the initial properties.
373        props: Expr,
374        /// Optional variable binding for the created relationship.
375        bind: Option<VarId>,
376    },
377
378    /// Merge a node — create if absent, match if present.
379    ///
380    /// `on_create` / `on_match` are applied depending on whether the node
381    /// was newly created or already existed. Spec §12.1 W3.
382    MergeNode {
383        /// Labels that uniquely identify the node.
384        labels: Vec<SmolStr>,
385        /// Property predicate / initial values.
386        props: Expr,
387        /// Write operations to apply when a new node is created.
388        on_create: Vec<WriteOp>,
389        /// Write operations to apply when an existing node is found.
390        on_match: Vec<WriteOp>,
391        /// Optional variable binding for the merged node.
392        bind: Option<VarId>,
393    },
394
395    /// Merge a relationship — create if absent, match if present.
396    ///
397    /// Analogous to [`WriteOp::MergeNode`] but for relationships.
398    /// Spec §12.1 W4.
399    MergeRel {
400        /// Variable holding the start node.
401        from: VarId,
402        /// Variable holding the end node.
403        to: VarId,
404        /// Relationship type.
405        rel_type: SmolStr,
406        /// Property predicate / initial values.
407        props: Expr,
408        /// Write operations to apply when a new relationship is created.
409        on_create: Vec<WriteOp>,
410        /// Write operations to apply when an existing relationship is found.
411        on_match: Vec<WriteOp>,
412        /// Optional variable binding for the merged relationship.
413        bind: Option<VarId>,
414    },
415
416    /// Set a single property on a node or relationship. Spec §12.1 W5.
417    SetProperty {
418        /// Variable holding the target entity.
419        target: VarId,
420        /// Property key.
421        prop: SmolStr,
422        /// New value expression.
423        value: Expr,
424    },
425
426    /// Add or replace the label set on a node. Spec §12.1 W6.
427    SetLabels {
428        /// Variable holding the target node.
429        target: VarId,
430        /// Labels to add.
431        labels: Vec<SmolStr>,
432    },
433
434    /// Remove a single property from a node or relationship. Spec §12.1 W7.
435    RemoveProperty {
436        /// Variable holding the target entity.
437        target: VarId,
438        /// Property key to remove.
439        prop: SmolStr,
440    },
441
442    /// Remove labels from a node. Spec §12.1 W8.
443    RemoveLabels {
444        /// Variable holding the target node.
445        target: VarId,
446        /// Labels to remove.
447        labels: Vec<SmolStr>,
448    },
449
450    /// Delete nodes or relationships. Spec §12.1 W9.
451    ///
452    /// When `detach` is `true` this is `DETACH DELETE`, which removes all
453    /// incident relationships before deleting the node.
454    Delete {
455        /// Expressions evaluating to the entities to delete.
456        targets: Vec<Expr>,
457        /// `true` for `DETACH DELETE`.
458        detach: bool,
459    },
460}
461
462// ──────────────────────────────────────────────────────────────────────────────
463// Expr
464// ──────────────────────────────────────────────────────────────────────────────
465
466/// Plan-level expression IR. Spec §12.2.
467///
468/// Every variable reference carries its [`VarId`]; every function call is
469/// resolved by name. This is a **distinct** type from [`cyrs_hir::Expr`]:
470/// it is fully resolved (no [`cyrs_hir::Expr::Unresolved`], no
471/// [`cyrs_hir::Expr::PatternPredicate`], no `MapProjection`), and `VarId`s
472/// are plan-scoped rather than HIR-scoped (spec §12.3). The HIR→Plan lowering
473/// pass (bead cy-foy) maps between them.
474///
475/// # Equality of floats
476///
477/// `Eq` is manually implemented so that aggregates of `Expr` can derive
478/// `Eq`. Semantic equality for execution (e.g. `NaN != NaN`) is the
479/// consumer's responsibility. The Plan IR treats float bit patterns as opaque
480/// for structural equality checks (spec §17.14 determinism).
481#[derive(Debug, Clone, PartialEq)]
482#[non_exhaustive]
483pub enum Expr {
484    /// The `null` literal. Spec §12.2 E1.
485    Null,
486    /// A boolean literal (`true` / `false`). Spec §12.2 E2.
487    Bool(bool),
488    /// A 64-bit signed integer literal. Spec §12.2 E3.
489    Int(i64),
490    /// A 64-bit IEEE-754 float literal. Spec §12.2 E4.
491    Float(f64),
492    /// A string literal. Spec §12.2 E5.
493    String(SmolStr),
494    /// A resolved variable reference. Spec §12.2 E6.
495    Var(VarId),
496    /// A property access (`expr.prop`). Spec §12.2 E7.
497    Prop {
498        /// Expression evaluating to a node, relationship, or map.
499        target: Box<Expr>,
500        /// Property key.
501        prop: SmolStr,
502    },
503    /// A subscript / index access (`expr[index]`). Spec §12.2 E8.
504    Index {
505        /// Expression evaluating to a list or map.
506        target: Box<Expr>,
507        /// Index expression (integer for lists, string for maps).
508        index: Box<Expr>,
509    },
510    /// A list slice (`expr[start..end]`). cy-7s6.1 (spec §12.2 E8,
511    /// extended for openCypher list slicing).
512    ///
513    /// Either bound may be `None` to represent the elided form:
514    /// `xs[..j]` -> `start = None, end = Some(j)`; `xs[i..]` ->
515    /// `start = Some(i), end = None`. Negative indices carry their
516    /// from-end meaning at evaluation time; the plan does not
517    /// normalise them.
518    Slice {
519        /// Expression evaluating to a list.
520        target: Box<Expr>,
521        /// Optional lower bound (inclusive, elidable).
522        start: Option<Box<Expr>>,
523        /// Optional upper bound (exclusive, elidable).
524        end: Option<Box<Expr>>,
525    },
526    /// A list literal (`[e1, e2, ...]`). Spec §12.2 E9.
527    List(Vec<Expr>),
528    /// A map literal (`{k1: e1, k2: e2}`). Spec §12.2 E10.
529    Map(Vec<(SmolStr, Expr)>),
530    /// A resolved function call. Spec §12.2 E11.
531    ///
532    /// `func` is the canonical lower-case function name as resolved by the
533    /// built-in catalog (spec §8.3) or the consumer-provided catalog.
534    Call {
535        /// Resolved function name.
536        func: SmolStr,
537        /// Argument expressions.
538        args: Vec<Expr>,
539    },
540    /// A binary operator application. Spec §12.2 E12.
541    BinOp {
542        /// Operator.
543        op: BinOp,
544        /// Left operand.
545        lhs: Box<Expr>,
546        /// Right operand.
547        rhs: Box<Expr>,
548    },
549    /// A unary operator application. Spec §12.2 E13.
550    UnaryOp {
551        /// Operator.
552        op: UnaryOp,
553        /// Operand.
554        operand: Box<Expr>,
555    },
556    /// A `CASE` expression. Spec §12.2 E14.
557    ///
558    /// `scrutinee` is present for simple `CASE x WHEN …` form; absent for
559    /// searched `CASE WHEN cond THEN …` form.
560    Case {
561        /// Optional scrutinee for simple `CASE`.
562        scrutinee: Option<Box<Expr>>,
563        /// `(when, then)` arms, tested in order.
564        arms: Vec<(Expr, Expr)>,
565        /// `ELSE` expression, or `Null` when absent.
566        otherwise: Option<Box<Expr>>,
567    },
568    /// `x IS NULL` / `x IS NOT NULL`. Spec §12.2 E15.
569    IsNull {
570        /// Operand.
571        operand: Box<Expr>,
572        /// `true` for `IS NOT NULL`.
573        negated: bool,
574    },
575    /// `x IN list` membership test. Spec §12.2 E16.
576    InList {
577        /// Value to test.
578        operand: Box<Expr>,
579        /// List to test membership in.
580        list: Box<Expr>,
581    },
582    /// A list predicate — `ANY|ALL|NONE|SINGLE(v IN xs [WHERE p(v)])`
583    /// (cy-8x5). Result type is `Bool`.
584    ///
585    /// Mirrors the HIR shape: the binder `var` is scoped to the
586    /// `predicate` sub-expression only; the `iterable` evaluates in the
587    /// enclosing row scope. `predicate` is `None` for the bare form
588    /// (`ANY(x IN xs)` — true iff xs is non-empty).
589    ListPredicate {
590        /// Which of the four list predicates.
591        kind: ListPredKind,
592        /// Plan-scoped id of the iteration variable.
593        var: VarId,
594        /// Source list expression.
595        iterable: Box<Expr>,
596        /// Optional `Bool` predicate; `None` for the bare form.
597        predicate: Option<Box<Expr>>,
598    },
599    /// A query parameter reference. Spec §12.4.
600    ///
601    /// The consumer binds parameter values at execution time. The plan does
602    /// not carry values. `ty` is deferred to bead cy-foy (HIR→Plan lowering)
603    /// because type inference runs in `cyrs-sema`, which is not a
604    /// dependency of this crate.
605    Param {
606        /// Parameter name as written in the query (`$name` or `{name}`).
607        name: SmolStr,
608    },
609    /// A pattern-predicate existential check — `EXISTS(<pattern>)` in
610    /// expression position (cy-lve, spec §6.1 / §19 row "Pattern
611    /// predicates in expressions").
612    ///
613    /// Evaluates to `true` iff the embedded read sub-plan would yield
614    /// at least one row when evaluated against the enclosing row's
615    /// variable bindings. Result type is `Bool`.
616    ///
617    /// The `pattern` sub-tree is embedded directly (like
618    /// [`ReadOp::OptionalJoin`]) because a pattern predicate is always
619    /// a fresh sub-plan at the point it appears in the enclosing
620    /// expression — its shape cannot be shared with other operators in
621    /// the outer DAG. Variables that appear both inside the pattern and
622    /// in the outer row remain unified via [`VarId`] (plan-scoped),
623    /// consistent with the rest of the plan's variable model.
624    Exists {
625        /// Embedded read-plan sub-tree; existence of ≥1 emitted row
626        /// makes this expression `true`, otherwise `false`.
627        pattern: Box<ReadOp>,
628    },
629}
630
631// `f64` is not `Eq` under stdlib rules (NaN breaks reflexivity), but the
632// Plan IR treats floats as opaque bit patterns for structural equality.
633// See spec §17.14.
634impl Eq for Expr {}
635
636// ──────────────────────────────────────────────────────────────────────────────
637// Operator enums
638// ──────────────────────────────────────────────────────────────────────────────
639
640/// Binary operators. Spec §12.2 E12 / §5.6 / §7.2.
641#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
642#[non_exhaustive]
643pub enum BinOp {
644    /// `+` — arithmetic addition or string/list concatenation (type-directed).
645    Add,
646    /// `-` — arithmetic subtraction.
647    Sub,
648    /// `*` — arithmetic multiplication.
649    Mul,
650    /// `/` — arithmetic division.
651    Div,
652    /// `%` — arithmetic modulo.
653    Mod,
654    /// `^` — exponentiation.
655    Pow,
656    /// `=` — equality.
657    Eq,
658    /// `<>` — inequality.
659    Neq,
660    /// `<` — less than.
661    Lt,
662    /// `<=` — less than or equal.
663    Le,
664    /// `>` — greater than.
665    Gt,
666    /// `>=` — greater than or equal.
667    Ge,
668    /// `AND` — boolean conjunction.
669    And,
670    /// `OR` — boolean disjunction.
671    Or,
672    /// `XOR` — boolean exclusive-or.
673    Xor,
674    /// `IN` — list membership (sugar for [`Expr::InList`]; kept for symmetry).
675    In,
676    /// `STARTS WITH` — string prefix test.
677    StartsWith,
678    /// `ENDS WITH` — string suffix test.
679    EndsWith,
680    /// `CONTAINS` — string substring test.
681    Contains,
682    /// `=~` — regular expression match.
683    RegexMatch,
684    /// `+` on strings / lists (resolved via type context to this variant).
685    Concat,
686}
687
688/// Unary operators. Spec §12.2 E13 / §5.6 / §7.2.
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
690#[non_exhaustive]
691pub enum UnaryOp {
692    /// Unary `-` — arithmetic negation.
693    Neg,
694    /// `NOT` — boolean negation.
695    Not,
696}
697
698/// Discriminant for [`Expr::ListPredicate`] (cy-8x5, spec §19 row
699/// "List predicates"). Mirrors `cyrs_hir::ListPredKind` at the plan
700/// layer so the HIR is not leaked across the plan boundary.
701#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
702#[non_exhaustive]
703pub enum ListPredKind {
704    /// `ANY(v IN xs WHERE p)` — at least one element matches.
705    Any,
706    /// `ALL(v IN xs WHERE p)` — every element matches.
707    All,
708    /// `NONE(v IN xs WHERE p)` — no element matches.
709    None,
710    /// `SINGLE(v IN xs WHERE p)` — exactly one element matches.
711    Single,
712}
713
714// ──────────────────────────────────────────────────────────────────────────────
715// Tests
716// ──────────────────────────────────────────────────────────────────────────────
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    // ── ReadOp constructors ───────────────────────────────────────────────────
723
724    #[test]
725    fn read_op_source_all_nodes() {
726        // MATCH (n) RETURN n
727        let op = ReadOp::Source {
728            label: None,
729            bind: VarId(0),
730        };
731        assert_eq!(
732            op,
733            ReadOp::Source {
734                label: None,
735                bind: VarId(0)
736            }
737        );
738        // Debug must be deterministic.
739        let d1 = format!("{op:?}");
740        let d2 = format!("{op:?}");
741        assert_eq!(d1, d2);
742    }
743
744    #[test]
745    fn read_op_source_with_label() {
746        // MATCH (n:Person) ...
747        let op = ReadOp::Source {
748            label: Some(LabelSet(vec!["Person".into()])),
749            bind: VarId(0),
750        };
751        let debug = format!("{op:?}");
752        assert!(
753            debug.contains("Person"),
754            "label must appear in debug: {debug}"
755        );
756    }
757
758    #[test]
759    fn read_op_expand_composes() {
760        // MATCH (n)-[r:KNOWS]->(m)
761        let expand = ReadOp::Expand {
762            input: OpId(0),
763            from: VarId(0),
764            rel: RelSpec {
765                types: vec!["KNOWS".into()],
766                direction: Direction::Outgoing,
767                length: RelLength::Single,
768                properties: None,
769            },
770            to: NodeSpec {
771                labels: LabelSet(vec![]),
772                properties: None,
773            },
774            bind_rel: VarId(1),
775            bind_to: VarId(2),
776        };
777        assert!(format!("{expand:?}").contains("KNOWS"));
778    }
779
780    #[test]
781    fn read_op_filter_predicate() {
782        let filter = ReadOp::Filter {
783            input: OpId(1),
784            predicate: Expr::Bool(true),
785        };
786        assert_eq!(
787            filter,
788            ReadOp::Filter {
789                input: OpId(1),
790                predicate: Expr::Bool(true)
791            }
792        );
793    }
794
795    #[test]
796    fn read_op_project_items() {
797        let project = ReadOp::Project {
798            input: OpId(0),
799            items: vec![Projection {
800                expr: Expr::Prop {
801                    target: Box::new(Expr::Var(VarId(0))),
802                    prop: "name".into(),
803                },
804                alias: "name".into(),
805            }],
806        };
807        let debug = format!("{project:?}");
808        assert!(debug.contains("name"));
809    }
810
811    #[test]
812    fn read_op_aggregate() {
813        let agg = ReadOp::Aggregate {
814            input: OpId(0),
815            keys: vec![Expr::Var(VarId(0))],
816            aggs: vec![AggExpr {
817                func: "count".into(),
818                args: vec![Expr::Var(VarId(0))],
819                distinct: false,
820            }],
821        };
822        let debug = format!("{agg:?}");
823        assert!(debug.contains("count"));
824    }
825
826    #[test]
827    fn read_op_order_by() {
828        let op = ReadOp::OrderBy {
829            input: OpId(0),
830            keys: vec![OrderKey {
831                expr: Expr::Var(VarId(0)),
832                dir: SortDir::Desc,
833            }],
834        };
835        assert!(format!("{op:?}").contains("Desc"));
836    }
837
838    #[test]
839    fn read_op_skip_limit() {
840        let skip = ReadOp::Skip {
841            input: OpId(0),
842            count: Expr::Int(10),
843        };
844        let limit = ReadOp::Limit {
845            input: OpId(0),
846            count: Expr::Int(5),
847        };
848        assert!(format!("{skip:?}").contains("10"));
849        assert!(format!("{limit:?}").contains('5'));
850    }
851
852    #[test]
853    fn read_op_distinct() {
854        let op = ReadOp::Distinct { input: OpId(0) };
855        assert_eq!(op, ReadOp::Distinct { input: OpId(0) });
856    }
857
858    #[test]
859    fn read_op_unwind() {
860        let op = ReadOp::Unwind {
861            input: OpId(0),
862            list: Expr::Var(VarId(0)),
863            bind: VarId(1),
864        };
865        assert!(format!("{op:?}").contains("VarId(1)"));
866    }
867
868    #[test]
869    fn read_op_union_all_and_distinct() {
870        let all = ReadOp::Union {
871            left: OpId(0),
872            right: OpId(1),
873            kind: UnionKind::All,
874        };
875        let distinct = ReadOp::Union {
876            left: OpId(0),
877            right: OpId(1),
878            kind: UnionKind::Distinct,
879        };
880        assert_ne!(all, distinct);
881    }
882
883    #[test]
884    fn read_op_with_filter() {
885        let with = ReadOp::With {
886            input: OpId(0),
887            items: vec![Projection {
888                expr: Expr::Var(VarId(0)),
889                alias: "n".into(),
890            }],
891            filter: Some(Expr::Bool(true)),
892        };
893        assert!(format!("{with:?}").contains("Bool(true)"));
894    }
895
896    #[test]
897    fn read_op_optional_join_boxed_subtree() {
898        let inner = ReadOp::Source {
899            label: None,
900            bind: VarId(1),
901        };
902        let op = ReadOp::OptionalJoin {
903            input: OpId(0),
904            pattern: Box::new(inner.clone()),
905        };
906        // The inner tree is accessible via the box.
907        assert_eq!(
908            **match &op {
909                ReadOp::OptionalJoin { pattern, .. } => pattern,
910                _ => panic!(),
911            },
912            inner
913        );
914    }
915
916    // ── WriteOp constructors ──────────────────────────────────────────────────
917
918    #[test]
919    fn write_op_create_node() {
920        let op = WriteOp::CreateNode {
921            labels: vec!["Person".into()],
922            props: Expr::Map(vec![("name".into(), Expr::String("Alice".into()))]),
923            bind: Some(VarId(0)),
924        };
925        assert!(format!("{op:?}").contains("Person"));
926    }
927
928    #[test]
929    fn write_op_create_rel() {
930        let op = WriteOp::CreateRel {
931            from: VarId(0),
932            to: VarId(1),
933            rel_type: "KNOWS".into(),
934            props: Expr::Map(vec![]),
935            bind: None,
936        };
937        assert!(format!("{op:?}").contains("KNOWS"));
938    }
939
940    #[test]
941    fn write_op_merge_node_on_create_and_on_match() {
942        let on_create = vec![WriteOp::SetProperty {
943            target: VarId(0),
944            prop: "created".into(),
945            value: Expr::Bool(true),
946        }];
947        let on_match = vec![WriteOp::SetProperty {
948            target: VarId(0),
949            prop: "updated".into(),
950            value: Expr::Bool(true),
951        }];
952        let op = WriteOp::MergeNode {
953            labels: vec!["Person".into()],
954            props: Expr::Map(vec![]),
955            on_create,
956            on_match,
957            bind: Some(VarId(0)),
958        };
959        let debug = format!("{op:?}");
960        assert!(debug.contains("created"));
961        assert!(debug.contains("updated"));
962    }
963
964    #[test]
965    fn write_op_merge_rel() {
966        let op = WriteOp::MergeRel {
967            from: VarId(0),
968            to: VarId(1),
969            rel_type: "FOLLOWS".into(),
970            props: Expr::Map(vec![]),
971            on_create: vec![],
972            on_match: vec![],
973            bind: None,
974        };
975        assert!(format!("{op:?}").contains("FOLLOWS"));
976    }
977
978    #[test]
979    fn write_op_set_and_remove() {
980        let set_prop = WriteOp::SetProperty {
981            target: VarId(0),
982            prop: "age".into(),
983            value: Expr::Int(30),
984        };
985        let set_labels = WriteOp::SetLabels {
986            target: VarId(0),
987            labels: vec!["Admin".into()],
988        };
989        let rm_prop = WriteOp::RemoveProperty {
990            target: VarId(0),
991            prop: "age".into(),
992        };
993        let rm_labels = WriteOp::RemoveLabels {
994            target: VarId(0),
995            labels: vec!["Admin".into()],
996        };
997        assert!(format!("{set_prop:?}").contains("30"));
998        assert!(format!("{set_labels:?}").contains("Admin"));
999        assert!(format!("{rm_prop:?}").contains("age"));
1000        assert!(format!("{rm_labels:?}").contains("Admin"));
1001    }
1002
1003    #[test]
1004    fn write_op_delete_and_detach_delete() {
1005        let del = WriteOp::Delete {
1006            targets: vec![Expr::Var(VarId(0))],
1007            detach: false,
1008        };
1009        let detach = WriteOp::Delete {
1010            targets: vec![Expr::Var(VarId(0))],
1011            detach: true,
1012        };
1013        assert_ne!(del, detach);
1014        assert!(format!("{detach:?}").contains("true"));
1015    }
1016
1017    // ── Expr constructors ─────────────────────────────────────────────────────
1018
1019    #[test]
1020    fn expr_literals_and_var() {
1021        assert_eq!(Expr::Null, Expr::Null);
1022        assert_eq!(Expr::Bool(true), Expr::Bool(true));
1023        assert_ne!(Expr::Bool(true), Expr::Bool(false));
1024        assert_eq!(Expr::Int(42), Expr::Int(42));
1025        assert_eq!(Expr::String("hi".into()), Expr::String("hi".into()));
1026        assert_eq!(Expr::Var(VarId(3)), Expr::Var(VarId(3)));
1027    }
1028
1029    #[test]
1030    fn expr_float_structural_eq() {
1031        // Two identical finite floats compare equal at plan IR level.
1032        assert_eq!(Expr::Float(1.5), Expr::Float(1.5));
1033        assert_ne!(Expr::Float(1.5), Expr::Float(2.0));
1034        // NaN carries IEEE semantics for PartialEq; consumers that need
1035        // bit-pattern equality should normalise NaN before building Exprs.
1036        // The `impl Eq for Expr` declaration only asserts the reflexivity
1037        // invariant is intentionally waived at the caller's discretion
1038        // (spec §17.14 note).
1039        let _ = Expr::Float(f64::NAN); // type-checks, no panic
1040    }
1041
1042    #[test]
1043    fn expr_prop_and_index() {
1044        let prop = Expr::Prop {
1045            target: Box::new(Expr::Var(VarId(0))),
1046            prop: "name".into(),
1047        };
1048        let index = Expr::Index {
1049            target: Box::new(Expr::Var(VarId(0))),
1050            index: Box::new(Expr::Int(0)),
1051        };
1052        assert!(format!("{prop:?}").contains("name"));
1053        assert!(format!("{index:?}").contains("Int(0)"));
1054    }
1055
1056    #[test]
1057    fn expr_list_and_map() {
1058        let list = Expr::List(vec![Expr::Int(1), Expr::Int(2)]);
1059        let map = Expr::Map(vec![("key".into(), Expr::String("val".into()))]);
1060        assert!(format!("{list:?}").contains("Int(1)"));
1061        assert!(format!("{map:?}").contains("key"));
1062    }
1063
1064    #[test]
1065    fn expr_call() {
1066        let call = Expr::Call {
1067            func: "toLower".into(),
1068            args: vec![Expr::Var(VarId(0))],
1069        };
1070        assert!(format!("{call:?}").contains("toLower"));
1071    }
1072
1073    #[test]
1074    fn expr_bin_op_all_variants_are_debug() {
1075        for op in [
1076            BinOp::Add,
1077            BinOp::Sub,
1078            BinOp::Mul,
1079            BinOp::Div,
1080            BinOp::Mod,
1081            BinOp::Pow,
1082            BinOp::Eq,
1083            BinOp::Neq,
1084            BinOp::Lt,
1085            BinOp::Le,
1086            BinOp::Gt,
1087            BinOp::Ge,
1088            BinOp::And,
1089            BinOp::Or,
1090            BinOp::Xor,
1091            BinOp::In,
1092            BinOp::StartsWith,
1093            BinOp::EndsWith,
1094            BinOp::Contains,
1095            BinOp::RegexMatch,
1096            BinOp::Concat,
1097        ] {
1098            let _ = format!("{op:?}");
1099        }
1100    }
1101
1102    #[test]
1103    fn expr_unary_op() {
1104        let neg = Expr::UnaryOp {
1105            op: UnaryOp::Neg,
1106            operand: Box::new(Expr::Int(1)),
1107        };
1108        let not = Expr::UnaryOp {
1109            op: UnaryOp::Not,
1110            operand: Box::new(Expr::Bool(false)),
1111        };
1112        assert!(format!("{neg:?}").contains("Neg"));
1113        assert!(format!("{not:?}").contains("Not"));
1114    }
1115
1116    #[test]
1117    fn expr_case() {
1118        let case = Expr::Case {
1119            scrutinee: None,
1120            arms: vec![(Expr::Bool(true), Expr::Int(1))],
1121            otherwise: Some(Box::new(Expr::Null)),
1122        };
1123        assert!(format!("{case:?}").contains("Int(1)"));
1124    }
1125
1126    #[test]
1127    fn expr_is_null_and_in_list() {
1128        let is_null = Expr::IsNull {
1129            operand: Box::new(Expr::Var(VarId(0))),
1130            negated: false,
1131        };
1132        let in_list = Expr::InList {
1133            operand: Box::new(Expr::Var(VarId(0))),
1134            list: Box::new(Expr::List(vec![])),
1135        };
1136        assert!(format!("{is_null:?}").contains("negated: false"));
1137        let _ = format!("{in_list:?}");
1138    }
1139
1140    #[test]
1141    fn expr_param() {
1142        let p = Expr::Param {
1143            name: "userId".into(),
1144        };
1145        assert!(format!("{p:?}").contains("userId"));
1146    }
1147
1148    #[test]
1149    fn debug_output_is_deterministic() {
1150        // Exercise determinism requirement from spec §17.14.
1151        let plan = ReadOp::Project {
1152            input: OpId(0),
1153            items: vec![
1154                Projection {
1155                    expr: Expr::Var(VarId(1)),
1156                    alias: "a".into(),
1157                },
1158                Projection {
1159                    expr: Expr::Var(VarId(2)),
1160                    alias: "b".into(),
1161                },
1162            ],
1163        };
1164        let first = format!("{plan:?}");
1165        let second = format!("{plan:?}");
1166        assert_eq!(first, second);
1167    }
1168
1169    #[test]
1170    fn build_simple_read_plan() {
1171        // MATCH (n:Person) RETURN n.name
1172        let source = ReadOp::Source {
1173            label: Some(LabelSet(vec!["Person".into()])),
1174            bind: VarId(0),
1175        };
1176        let project = ReadOp::Project {
1177            input: OpId(0),
1178            items: vec![Projection {
1179                expr: Expr::Prop {
1180                    target: Box::new(Expr::Var(VarId(0))),
1181                    prop: "name".into(),
1182                },
1183                alias: "name".into(),
1184            }],
1185        };
1186        // Smoke test: the types compose.
1187        let _ = (source, project);
1188    }
1189
1190    #[test]
1191    fn var_id_and_op_id_are_copy_and_hash() {
1192        use std::collections::HashSet;
1193        let mut ids: HashSet<VarId> = HashSet::new();
1194        ids.insert(VarId(0));
1195        ids.insert(VarId(1));
1196        ids.insert(VarId(0)); // duplicate
1197        assert_eq!(ids.len(), 2);
1198
1199        let v = VarId(7);
1200        let v2 = v; // Copy
1201        assert_eq!(v, v2);
1202
1203        let mut ops: HashSet<OpId> = HashSet::new();
1204        ops.insert(OpId(0));
1205        assert_eq!(ops.len(), 1);
1206    }
1207}