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}