Skip to main content

marsdb_query/
ast.rs

1#[derive(Debug, Clone, PartialEq)]
2pub enum Literal {
3    Int(i64),
4    Float(f64),
5    String(String),
6    Bool(bool),
7    Null,
8    /// `$name` placeholder — resolved to a concrete `Literal` by
9    /// `params::substitute_params` before execution, never seen by the
10    /// executor.
11    Param(String),
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct PropAccess {
16    pub var: String,
17    pub prop: String,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum CompareOp {
22    Eq,
23    Ne,
24    Lt,
25    Le,
26    Gt,
27    Ge,
28    /// String-only predicates (`a.name STARTS WITH 'x'`, etc.) — anything
29    /// but a `String`/`String` operand pair compares `false`, same as every
30    /// other type-mismatched `CompareOp` already does in `compare()`.
31    StartsWith,
32    EndsWith,
33    Contains,
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub enum Expr {
38    And(Box<Expr>, Box<Expr>),
39    Or(Box<Expr>, Box<Expr>),
40    Not(Box<Expr>),
41    Compare(PropAccess, CompareOp, Literal),
42    /// `a.id = b.id` / `x.val < y.val` — a property compared against
43    /// *another* property, not a constant. Never eligible for the
44    /// planner's index-seek fusion (that only matches `Compare`'s
45    /// literal-RHS shape), always evaluated as a generic post-scan filter.
46    PropCompare(PropAccess, CompareOp, PropAccess),
47    /// `n.prop IS NULL` — unlike `Compare`, this is always a definite
48    /// `true`/`false`, never "unknown" (that's the whole point of the
49    /// check). `IS NOT NULL` parses to `Not(IsNull(..))`, reusing the
50    /// existing `Not` variant rather than a fourth boolean-op variant.
51    IsNull(PropAccess),
52    /// Does the node bound to `var` have label `label` among its (possibly
53    /// multiple) labels? Synthesized by the planner for the 2nd+ label in a
54    /// multi-label pattern like `(n:Post:Message)`, *and* user-typed
55    /// directly in a `WHERE` (`WHERE a:A`, `WHERE a:A:B` desugars to an
56    /// `And` chain of one `HasLabel` per label).
57    HasLabel(String, String),
58    /// Do these two row bindings refer to the same node/edge? Synthesized
59    /// by the planner when a pattern's hop variable is a "bound-node
60    /// repetition" — the same variable already bound earlier reappearing
61    /// mid-pattern (e.g. IS7's `p`, bound by an earlier MATCH, reappearing
62    /// as the endpoint of an OPTIONAL MATCH pattern: `(a)-[r:KNOWS]-(p)`
63    /// must mean "KNOWS *this* `p`", not "KNOWS anyone"). Also user-typed
64    /// directly in a `WHERE` (`WHERE a = b`; `WHERE a <> b` desugars to
65    /// `Not(VarEq(a, b))`) — real Cypher's node/relationship identity
66    /// comparison, distinct from comparing two of their *properties*
67    /// (`PropCompare`) or two arbitrary values (`WithExpr::Compare`,
68    /// post-projection only).
69    VarEq(String, String),
70    /// `WHERE toInteger(n.id) = 1`, `WHERE r.weight * 2 > n.threshold`,
71    /// ... -- any comparison whose operand isn't the narrower
72    /// `prop_access`/`literal` shape `Compare`/`PropCompare` cover
73    /// (a function call, arithmetic, a bare variable compared to
74    /// something, ...). Same operand type `WithExpr::Compare` uses
75    /// (`ReturnExpr`, built from the shared `add_expr` grammar rule), but
76    /// this variant keeps pattern-level `Expr`'s own pre-projection
77    /// evaluation context (`Executor::eval_expr`, against the raw
78    /// `BindingRow`, not a post-projection value map) -- never eligible
79    /// for the planner's index-seek fusion, always a generic post-scan
80    /// filter, same as `PropCompare`.
81    GeneralCompare(ReturnExpr, CompareOp, ReturnExpr),
82    /// `WHERE r IS NULL` (a whole bound variable, e.g. checking an
83    /// `OPTIONAL MATCH` miss) or `WHERE toInteger(n.id) IS NULL` -- unlike
84    /// `IsNull`, the operand isn't restricted to a bare `prop_access`.
85    /// Mirrors `WithExpr::IsNull` exactly, just evaluated pre-projection.
86    GeneralIsNull(ReturnExpr),
87    /// A boolean-valued expression used directly as a predicate with no
88    /// comparison operator at all -- `WHERE single(x IN list WHERE x = 2)
89    /// OR all(x IN list WHERE x = 2)`, `WHERE n.flag`, `WHERE NOT
90    /// exists(n.prop)`. Three-valued (`Null` is "unknown", same as every
91    /// other `Expr` leaf), evaluated via `value_to_bool3`. Mirrors
92    /// `WithExpr::Bare` exactly, just evaluated pre-projection.
93    GeneralBare(ReturnExpr),
94    /// `WHERE (n)-[:REL]->(m)` etc (TCK's Pattern1 "Pattern predicate") --
95    /// existential: true iff at least one real match of `Pattern` exists
96    /// against the graph, with every named endpoint already bound in the
97    /// current row held fixed to that binding rather than searched freely
98    /// (a pattern predicate never introduces a new variable -- real
99    /// Cypher's `UndefinedVariable`, checked at compile time by
100    /// `semantic::validate_pattern_predicate`). Evaluated by
101    /// `Executor::eval_expr` via the same `build_match_plan` "already-
102    /// bound var -> Seed" mechanism `eval_merge`'s own "try as an
103    /// ordinary MATCH first" half already uses.
104    Pattern(Pattern),
105    /// `WHERE exists { (n)-->(m) WHERE n.prop = m.prop }` (TCK's
106    /// ExistentialSubquery1, the "simple" form) -- unlike `Pattern`
107    /// above, `pattern` here *can* introduce brand-new variables (`m`
108    /// above; real Cypher allows that inside an `exists {}` block, unlike
109    /// a bare pattern predicate), and carries its own inline `where?`
110    /// clause (the grammar's `patternWhere` rule, shared with `MATCH`'s
111    /// own pattern -- `where_clause` is threaded into `build_match_plan`
112    /// directly, same as an ordinary `MATCH ... WHERE ...`, rather than
113    /// evaluated as a separate post-filter step). Existence check only
114    /// (`Some(1)`-limited, same as `Pattern`) -- see `ExistsSubquery` below
115    /// for the "full" `exists { MATCH ... RETURN ... }` subquery form.
116    Exists {
117        pattern: Box<Pattern>,
118        where_clause: Option<Box<Expr>>,
119    },
120    /// `WHERE exists { MATCH ... RETURN ... }` (TCK's
121    /// ExistentialSubquery2/3) -- runs an arbitrary read-only
122    /// `Statement::Match` correlated against the current row (its already-
123    /// bound variables seed the nested statement's own scope, same
124    /// already-bound-var -> Seed mechanism `Exists`/`Pattern` above use),
125    /// true iff it produces at least one output row. Unlike `Exists`
126    /// above, the nested statement can carry its own aggregation/multiple
127    /// clauses/nested `exists {}` -- `semantic::validate_statement`
128    /// rejects any mutating clause inside it at compile time (real
129    /// Cypher's `InvalidClauseComposition`, TCK's ExistentialSubquery2
130    /// `[3]`).
131    ExistsSubquery(Box<Statement>),
132    /// Real Cypher's edge-isomorphism rule (`VarEq`'s own docs), extended
133    /// to a variable-length hop: `edge_var`'s bound edge must not be among
134    /// the edges an *earlier* variable-length hop in the same pattern
135    /// already traversed for this row (`edge_set_var`, a `Binding::Path`
136    /// segment `planner::build_match_plan` always threads through a
137    /// `VarExpand` for exactly this purpose -- see `LogicalPlan::
138    /// VarExpand::exclude_edge_var`'s own docs). Unlike `VarEq` (one edge
139    /// vs one edge), this checks one edge against a *set* -- each row can
140    /// carry a different set, one per distinct earlier traversal.
141    /// Synthesized by the planner only; no surface syntax constructs this
142    /// directly.
143    EdgeNotInSet {
144        edge_var: String,
145        edge_set_var: String,
146    },
147}
148
149#[derive(Debug, Clone, PartialEq)]
150pub enum ReturnExpr {
151    Var(String),
152    Prop(PropAccess),
153    /// `<expr>.prop` where `<expr>` is anything other than a bare variable
154    /// (`startNode(r).id`, `head(nodes(p)).name`, `{a: 1}.a`) — `Prop`
155    /// only covers the flat `var.prop` case (matching pest's own
156    /// `prop_access` rule, a dedicated `identifier DOT identifier`
157    /// production). TCK's Graph6 [4]/[8], Map1 [3], Merge5 [11].
158    PropOf(Box<ReturnExpr>, String),
159    Lit(Literal),
160    Call {
161        name: String,
162        args: Vec<ReturnExpr>,
163        distinct: bool,
164    },
165    /// `count(*)` — its own variant, not `Call` with a magic `"*"`-sentinel
166    /// argument, so evaluation physically cannot mishandle it as an
167    /// ordinary function call (no args to evaluate, no DISTINCT target —
168    /// it counts rows, not values).
169    CountStar,
170    /// `CASE <test> WHEN <value> THEN <result> ... [ELSE <else>] END`
171    /// (simple form, `test: Some`) or `CASE WHEN <bool_expr> THEN <result>
172    /// ... [ELSE <else>] END` (searched form, `test: None` -- each `WHEN`
173    /// carries its own full condition instead of a value compared against
174    /// `test`).
175    Case {
176        test: Option<Box<ReturnExpr>>,
177        whens: Vec<(ReturnExpr, ReturnExpr)>,
178        else_: Option<Box<ReturnExpr>>,
179    },
180    /// `lhs op rhs` — `+ - * / %`, real precedence (`*`/`/`/`%` bind
181    /// tighter than `+`/`-`), usable anywhere a `ReturnExpr` is (RETURN/
182    /// WITH items, `CASE` branches, function args, `ORDER BY` keys).
183    /// Deliberately not threaded into pattern-level `WHERE` (`Expr`) or
184    /// `WITH ... WHERE` (`WithExpr`)'s comparison operands in this pass --
185    /// both currently take a bare `PropAccess`/`Literal` on each side, and
186    /// widening that is a separate, larger change to the planner's
187    /// pre-projection `Filter` pushdown.
188    Arith(Box<ReturnExpr>, ArithOp, Box<ReturnExpr>),
189    /// `-x` — general unary negation (`-n.prop`, `-(1 + 2)`, `-f()`, ...),
190    /// distinct from a negative numeric *literal* (`-3` is still just
191    /// `Lit(Int(-3))`, parsed directly by `int_literal`/`float_literal`'s
192    /// own optional leading `-` — see `cypher.pest`'s `unary_minus_expr`
193    /// docs for why that path is deliberately left untouched, rather than
194    /// this variant subsuming it, to avoid losing the planner's index-seek
195    /// fusion for `MATCH (n {x: -5})`-shaped literal patterns). Binds
196    /// tighter than every other arithmetic operator, including `^`.
197    Neg(Box<ReturnExpr>),
198    /// `[a, b, c]` — a general expression list, not `UnwindSource::List`'s
199    /// literal-only cousin (that one's deliberately scoped to right after
200    /// `UNWIND`; this one is a real `ReturnExpr`, usable anywhere one is).
201    ListLit(Vec<ReturnExpr>),
202    /// `list[index]` — a negative index counts from the end
203    /// (`list[-1]` is the last element); out of bounds either way is
204    /// `Null`, not an error (matches real Cypher).
205    Index(Box<ReturnExpr>, Box<ReturnExpr>),
206    /// `list[start..end]` — either bound omitted means "from/to the edge
207    /// of the list". Same negative-counts-from-end rule as `Index`, but
208    /// out-of-range bounds clamp instead of nulling out, and a start at or
209    /// past the (clamped) end yields `[]` rather than erroring.
210    Slice(
211        Box<ReturnExpr>,
212        Option<Box<ReturnExpr>>,
213        Option<Box<ReturnExpr>>,
214    ),
215    /// `[x IN <source> WHERE <cond> | <project>]` — `WHERE`/`| project` are
216    /// each independently optional (`[x IN list]` is a legal no-op
217    /// identity-filter comprehension). `where_clause` is a `ReturnExpr`
218    /// (not pattern-level `Expr`) for the same reason `UnwindClause`'s own
219    /// filter used to reuse `WithExpr` — `var` is very often a bare
220    /// scalar/node/edge, not something `Expr::Compare`'s
221    /// `prop_access`-only LHS can express. Now that boolean logic/
222    /// comparisons are real `ReturnExpr` variants (`And`/`Or`/`Not`/
223    /// `Compare`), this is the wider type `WithExpr` used to be, letting a
224    /// bare `WHERE x`/`WHERE true` parse (previously rejected — `WithExpr`
225    /// only ever wrapped a `Compare`, never a standalone boolean value).
226    ListComp {
227        var: String,
228        source: Box<ReturnExpr>,
229        where_clause: Option<Box<ReturnExpr>>,
230        project: Option<Box<ReturnExpr>>,
231    },
232    /// `ALL(x IN list WHERE cond)` / `ANY(...)` / `NONE(...)` / `SINGLE(...)`
233    /// — shares `ListComp`'s "one bound variable over a list, optionally
234    /// filtered" shape (no `project` half; a quantifier always yields a
235    /// `Bool`, never a projected list). `where_clause` absent means "every
236    /// element's own truthiness", same convention `CASE`'s subject-less
237    /// `WHEN` branch already uses (`matches!(v, Literal(Bool(true)))`).
238    Quantifier {
239        kind: QuantifierKind,
240        var: String,
241        source: Box<ReturnExpr>,
242        where_clause: Option<Box<ReturnExpr>>,
243    },
244    /// `{a: 1, b: 2 + 1}` — a general expression map. `NodePattern`/
245    /// `RelPattern`'s own `props` reuse this same `ReturnExpr` value type
246    /// (not a separate `Literal`-only map) for the identical `{...}`
247    /// pattern syntax — a `CREATE`/`MERGE` prop value can be any
248    /// expression too (`{date: date({year: 1984, ...})}`), evaluated
249    /// against the row already bound so far (`Executor::
250    /// eval_props_to_values`). `MATCH`/`MERGE`'s own inline pattern props
251    /// specifically are further restricted back down to plain literals at
252    /// plan-build time (`planner::require_literal_pattern_prop`) — a
253    /// computed value there doesn't make sense before any row exists to
254    /// evaluate it against, matching real Cypher's own restriction.
255    MapLit(Vec<(String, ReturnExpr)>),
256    /// `lhs AND/OR/XOR rhs`, `NOT rhs` — real three-valued logic (`Null`
257    /// propagates per Cypher's truth tables, see `and3`/`or3`/`xor3` in
258    /// executor.rs), evaluating to `Value::Literal(Bool(_))` or
259    /// `Value::Null`, not `Option<bool>` the way pattern-level `Expr`/
260    /// `WithExpr` do — a `ReturnExpr` always evaluates to one `Value`,
261    /// there's no separate "unbound" state to fold in beyond `Null`
262    /// itself. A non-bool, non-null operand is a real error (`1 AND
263    /// true`), not silently coerced.
264    And(Box<ReturnExpr>, Box<ReturnExpr>),
265    Or(Box<ReturnExpr>, Box<ReturnExpr>),
266    Xor(Box<ReturnExpr>, Box<ReturnExpr>),
267    Not(Box<ReturnExpr>),
268    /// `lhs op rhs` — a single comparison between two arbitrary
269    /// expressions (both operands can be a variable/property/arithmetic
270    /// expression, same as `WithExpr::Compare`'s two `ReturnExpr`
271    /// operands). A chain (`1 < x < 10`) parses into nested `And`s of
272    /// each adjacent pair (`(1 < x) AND (x < 10)`), same as real Cypher's
273    /// own chained-comparison semantics — not a separate AST shape.
274    /// `WithExpr::Compare` doesn't chain this way (its own grammar level
275    /// doesn't recurse into itself), just a single comparison per node.
276    Compare(Box<ReturnExpr>, CompareOp, Box<ReturnExpr>),
277    /// `x IS NULL` — always a definite `true`/`false`, never "unknown"
278    /// (that's the whole point of the check). `IS NOT NULL` parses to
279    /// `Not(IsNull(..))`, reusing the existing `Not` variant.
280    IsNull(Box<ReturnExpr>),
281    /// `x IN list` — real Cypher's list membership test, three-valued
282    /// like `=` (`null IN [1]` and `1 IN [null]` are both "unknown", not
283    /// `false`): a definite element match wins outright even past a later
284    /// `null` element, no match with at least one `null` element compared
285    /// is "unknown", no match and no `null` anywhere is a definite
286    /// `false`. Binds *tighter* than a surrounding comparison, same
287    /// precedence tier as `IsNull` (`a = b IN list` is `a = (b IN
288    /// list)`) — see `compare_expr`'s grammar comment.
289    In(Box<ReturnExpr>, Box<ReturnExpr>),
290    /// `(n:Foo)`/`(n:Foo:Bar)` used as a boolean expression — `true` iff
291    /// the bound node has every listed label, `false` otherwise (a
292    /// definite bool, not three-valued — same as `Expr::HasLabel`, the
293    /// pattern-position sibling this mirrors, but reachable directly from
294    /// `RETURN`/`WITH`/`WHERE` instead of only ever being synthesized by
295    /// the planner for a multi-label pattern token).
296    HasLabel(String, Vec<String>),
297    /// `(n)-[]->()` etc used directly as a boolean expression -- existential
298    /// pattern-predicate syntax, same shape as `Expr::Pattern` (the
299    /// pattern-level sibling this mirrors), but reachable from generic
300    /// expression position (parsed via the same `atom` alternative
301    /// anything else is). In practice only ever meaningful inside `WHERE`
302    /// -- `return_expr_to_expr` folds it into `Expr::Pattern` there before
303    /// it ever reaches `RETURN`/`WITH` position or the executor. Reaching
304    /// `RETURN (n)-->()`/a RETURN item/property value/etc directly (no
305    /// `Expr`-folding step in between) is a real error, not evaluated --
306    /// existence can't be checked without a bound row to check it against,
307    /// which only `WHERE`'s evaluation context has.
308    PatternPredicate(Pattern),
309    /// `[p = (n)-->() | p]` / `[(n)-[:T]->(b) | b.name]` -- unlike
310    /// `PatternPredicate` (existence-only, folded into `Expr::Pattern`
311    /// before it ever reaches the executor), this enumerates *every*
312    /// match of `pattern` (each held-fixed against already-bound named
313    /// endpoints, same as `Expr::Pattern`/`PatternPredicate`) and projects
314    /// `projection` against each match's own bindings -- which include
315    /// any *new* node/relationship variables the pattern introduces
316    /// (e.g. `b`/`r` above; real Cypher allows that here, unlike a
317    /// pattern predicate) -- collecting the results into a `Value::List`.
318    /// `where_clause` is pattern-level `Expr` (not `ReturnExpr`), reusing
319    /// the exact same evaluation `Executor::eval_expr`'s `Expr::Pattern`
320    /// arm already does for a pattern's own `WHERE` -- the grammar's
321    /// `patternComprehension` rule shares its `where?` production with
322    /// ordinary `MATCH`, not with `ListComp`'s post-projection
323    /// `WithExpr`-shaped filter.
324    PatternComprehension {
325        path_var: Option<String>,
326        pattern: Box<Pattern>,
327        where_clause: Option<Box<Expr>>,
328        projection: Box<ReturnExpr>,
329    },
330    /// `exists { (n)-->(m) WHERE ... }` reached from general expression
331    /// position -- same "only meaningful inside WHERE" story as
332    /// `PatternPredicate` (`return_expr_to_expr` folds it into
333    /// `Expr::Exists` there before it ever reaches the executor;
334    /// reaching `eval_return_expr` directly is a real error).
335    ExistsPattern {
336        pattern: Box<Pattern>,
337        where_clause: Option<Box<Expr>>,
338    },
339    /// `exists { MATCH ... RETURN ... }` reached from general expression
340    /// position -- the "full" nested-subquery form of `exists {}`
341    /// (`ExistsPattern`'s complement, TCK's ExistentialSubquery2/3), an
342    /// arbitrary read-only `Statement::Match` (any number of MATCH/UNWIND/
343    /// WITH clauses, its own aggregation/WHERE, ending in a RETURN) run
344    /// correlated against whatever's already bound in the enclosing row --
345    /// same "only meaningful inside WHERE" story as `ExistsPattern`
346    /// (`return_expr_to_expr` folds it into `Expr::ExistsSubquery` there
347    /// before it ever reaches the executor; reaching `eval_return_expr`
348    /// directly is a real error). The inner statement's own RETURN items
349    /// are never actually projected out -- only whether it produces at
350    /// least one row matters, same as `ExistsPattern`.
351    ExistsSubquery(Box<Statement>),
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum QuantifierKind {
356    All,
357    Any,
358    None,
359    Single,
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub enum ArithOp {
364    Add,
365    Sub,
366    Mul,
367    Div,
368    Mod,
369    /// `a ^ b` -- always produces a `Float`, even for two `Int` operands
370    /// (real Cypher's own rule; unlike every other `ArithOp`, there's no
371    /// Int/Int-stays-Int case). Right-associative and binds tighter than
372    /// `*`/`/`/`%` but looser than unary minus (`-3 ^ 2` is `(-3) ^ 2`,
373    /// not `-(3 ^ 2)`) -- see `cypher.pest`'s `pow_expr`/`unary_minus_expr`.
374    Pow,
375}
376
377/// Case-insensitive aggregate-function recognition, shared by `parser.rs`
378/// (DISTINCT-validity check) and `executor.rs` (grouping classification —
379/// a RETURN/WITH item list "has an aggregate" iff any item's top-level
380/// expression is `CountStar` or a `Call` whose name passes this check).
381pub fn is_aggregate_name(name: &str) -> bool {
382    matches!(
383        name.to_ascii_lowercase().as_str(),
384        "count" | "sum" | "avg" | "min" | "max" | "collect" | "percentilecont" | "percentiledisc"
385    )
386}
387
388/// `percentileCont`/`percentileDisc` are the only aggregates that take a
389/// second argument (the percentile, `0.0..=1.0`) alongside the value being
390/// aggregated -- every other name in `is_aggregate_name` takes exactly one.
391pub fn is_percentile_name(name: &str) -> bool {
392    matches!(
393        name.to_ascii_lowercase().as_str(),
394        "percentilecont" | "percentiledisc"
395    )
396}
397
398#[derive(Debug, Clone, PartialEq)]
399pub struct ReturnItem {
400    pub expr: ReturnExpr,
401    pub alias: Option<String>,
402}
403
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub enum SortDir {
406    Asc,
407    Desc,
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411pub enum RelDirection {
412    /// (a)-[..]->(b)
413    Right,
414    /// (a)<-[..]-(b)
415    Left,
416    /// (a)-[..]-(b) — matches either direction.
417    Either,
418}
419
420#[derive(Debug, Clone, PartialEq)]
421pub struct NodePattern {
422    pub var: Option<String>,
423    pub labels: Vec<String>,
424    /// `ReturnExpr`, not `Literal` — a CREATE prop value can be any
425    /// expression (`{date: date({year: 1984, ...})}`, `{x: 1 + 2}`), not
426    /// just a literal; see `cypher.pest`'s `map_expr` docs and
427    /// `Executor::eval_props_to_values`, which evaluates each one against
428    /// the row already bound so far in the same CREATE.
429    pub props: Vec<(String, ReturnExpr)>,
430    /// Whether an inline `{...}` map token was actually written, even an
431    /// empty one (`(n {})`) -- `props` alone can't distinguish that from
432    /// no map token at all (`(n)`), both giving an empty `Vec`, but real
433    /// Cypher's `VariableAlreadyBound` check cares about the distinction:
434    /// `MATCH (n) CREATE (n {})` is still "imposing a new predicate" on
435    /// an already-bound node even though the map is empty (TCK's Create1
436    /// `[19]`), while `MATCH (n) CREATE (n)-->()` (no map token at all)
437    /// is fine.
438    pub has_explicit_props: bool,
439}
440
441#[derive(Debug, Clone, PartialEq)]
442pub struct RelPattern {
443    pub var: Option<String>,
444    /// `[:A]` -- one element; `[:A|B]`/`[:A|:B]` (real Cypher accepts
445    /// either separator form) -- more than one, matched if the edge's
446    /// type is ANY of them (TCK's Match2 [6]/Match3 [8], Pattern1 [13]).
447    /// Empty means untyped (`[]`/`[r]`, any type matches).
448    pub rel_types: Vec<String>,
449    pub props: Vec<(String, ReturnExpr)>,
450    pub direction: RelDirection,
451    /// `[:TYPE*min..max]` — `None` means a fixed single hop (existing
452    /// behavior). `max: None` means unbounded, capped at a safety depth by
453    /// the executor.
454    pub hop_range: Option<(u32, Option<u32>)>,
455    /// Set only by `executor::name_pattern_for_path` on a variable-length
456    /// hop, when assembling a named-path capture (`p = (a)-[*1..3]->(b)`,
457    /// TCK's Quantifier1-4 `[8]`/`[9]`) -- asks the planner's `VarExpand`
458    /// to also expose its own internally-traversed edge/node sequence (in
459    /// order) for `executor::assemble_path` to splice into the whole
460    /// pattern's path, via a fresh internal binding name that `var` gets
461    /// overwritten to hold (see `name_pattern_for_path`'s own docs). The
462    /// user's own real relationship-list variable, if this hop had one
463    /// (`p = (a)-[r*1..3]->(b)`, TCK's Match9 `[9]`), is preserved
464    /// separately in `rel_list_var` below rather than lost to that
465    /// overwrite.
466    pub capture_path_segment: bool,
467    /// The user's own `[r:TYPE*1..3]` relationship-list variable name, for
468    /// a hop that has `capture_path_segment` set -- `None` for an
469    /// anonymous hop (`p = (a)-[*1..3]->(b)`, nothing to bind). For a
470    /// var-length hop *without* named-path capture, `var` itself already
471    /// holds this directly (this field stays `None` in that case; the
472    /// planner reads whichever of the two applies). Two separate fields
473    /// rather than reusing `var` for both, since `capture_path_segment`
474    /// already needs `var` for its own internal bookkeeping name.
475    pub rel_list_var: Option<String>,
476}
477
478/// A linear chain: node, (rel, node)*.
479#[derive(Debug, Clone, PartialEq)]
480pub struct Pattern {
481    pub start: NodePattern,
482    pub hops: Vec<(RelPattern, NodePattern)>,
483}
484
485#[derive(Debug, Clone, PartialEq)]
486pub enum Tail {
487    /// `distinct`: `RETURN DISTINCT ...` -- a result-set-level dedup of the
488    /// whole projected row, applied after projection (and after grouping,
489    /// for an aggregating RETURN) -- not the same knob as `DISTINCT` inside
490    /// an aggregate call (`count(DISTINCT x)`), which only affects that one
491    /// aggregate's own accumulation.
492    Return(Vec<ReturnItem>, bool),
493    /// `RETURN *` (`distinct`: `RETURN DISTINCT *`) -- every currently-
494    /// bound variable, alphabetically. Can't be resolved into a concrete
495    /// `Return(Vec<ReturnItem>, _)` at parse time (no scope exists yet);
496    /// resolved independently wherever the real bound-variable-name set
497    /// is already on hand (`execute_match`'s own `carried_vars` in
498    /// `executor.rs`, `Scope`'s keys in `semantic.rs`) rather than via a
499    /// separate AST-mutation pass, avoiding a `&mut Statement` ripple
500    /// through `Executor::execute`'s public signature. `MATCH ()
501    /// RETURN *` (nothing bound at all) is a compile-time
502    /// `NoVariablesInScope` error, not an empty projection.
503    ReturnStar(bool),
504    /// Every mutating tail variant's trailing `Option<ReturnTail>` is real
505    /// Cypher: `MATCH (n) SET n.x = 1 RETURN n`, `MATCH (n) DELETE n RETURN
506    /// count(n)`, etc — see `ReturnTail`'s docs for why it's `None` (the
507    /// pre-existing terminal-mutation shape) vs `Some` (this statement's
508    /// final clause is actually this RETURN, projected off whatever the
509    /// mutation left in scope).
510    /// Each target is any expression, not just a bare variable — real
511    /// Cypher allows `DELETE list[0]`/`DELETE map.key`/`DELETE aPath`
512    /// (deletes every node/edge in the path). Evaluated per row
513    /// (`executor::materialize_delete`); `Value::Null` is a documented
514    /// no-op, anything that isn't a node/relationship/path is a real
515    /// `QueryError::Type`.
516    Delete(Vec<ReturnExpr>, Option<ReturnTail>),
517    DetachDelete(Vec<ReturnExpr>, Option<ReturnTail>),
518    Set(Vec<SetItem>, Option<ReturnTail>),
519    Remove(Vec<RemoveItem>, Option<ReturnTail>),
520    /// `MATCH ... CREATE ...` — same pattern syntax as `Statement::Create`,
521    /// but runs once per row already bound by the preceding MATCH/WITH: a
522    /// node pattern token whose variable is already bound in that row
523    /// reuses the existing node instead of creating a new one. This is
524    /// the only way to add an edge between two nodes that already exist —
525    /// `Statement::Create` alone can't (every node token it sees is
526    /// always fresh).
527    Create(Vec<Pattern>, Option<ReturnTail>),
528}
529
530/// A `RETURN` trailing a mutating `Tail` (`SET`/`DELETE`/`DETACH DELETE`/
531/// `REMOVE`/`MATCH ... CREATE`) in the same statement, e.g. `MATCH (n) SET
532/// n.prop = 1 RETURN n`. Same two fields `Tail::Return` itself carries
533/// (`items`, `distinct`) — wrapped in its own type so every mutating `Tail`
534/// variant can carry one `Option<ReturnTail>` instead of repeating a
535/// `(Vec<ReturnItem>, bool)` tuple five times. Arbitrary multi-clause
536/// chaining (`SET ... DELETE ... RETURN`, a mutating clause followed by
537/// `WITH` before the final `RETURN`) isn't supported yet — this only covers
538/// exactly one mutating clause directly followed by exactly one `RETURN`,
539/// which is the shape the real TCK scenarios for SET/DELETE/REMOVE
540/// overwhelmingly use.
541#[derive(Debug, Clone, PartialEq)]
542pub struct ReturnTail {
543    pub items: Vec<ReturnItem>,
544    pub distinct: bool,
545}
546
547#[derive(Debug, Clone, PartialEq)]
548pub enum SetItem {
549    /// `SET n.prop = <expr>` — the value is any `ReturnExpr` (arithmetic,
550    /// a property read, a function call, ...), not just a literal, same
551    /// as `CREATE`'s inline `{...}` prop values already are. Evaluating
552    /// to `Value::Null` removes the property (matches real Cypher; see
553    /// `executor::apply_set_item`'s docs), not storing a literal null.
554    Prop(PropAccess, ReturnExpr),
555    /// `SET n:A:B` — adds each label to the node's label set (idempotent,
556    /// not an error if already present).
557    Labels(String, Vec<String>),
558    /// `SET n = {...}` (`merge: false`, replaces every existing property)
559    /// or `SET n += {...}` (`merge: true`, only overrides/adds/removes
560    /// -- a `null` value -- the map's own listed keys, everything else on
561    /// `n` stays as-is). `value` must evaluate to a `Value::Map` — TCK's
562    /// Set4/Set5.
563    MapAssign {
564        var: String,
565        value: ReturnExpr,
566        merge: bool,
567    },
568}
569
570#[derive(Debug, Clone, PartialEq)]
571pub enum RemoveItem {
572    Prop(PropAccess),
573    /// `REMOVE n:A:B` — removes each label from the node's label set (not
574    /// an error if it wasn't there).
575    Labels(String, Vec<String>),
576}
577
578/// WITH's HAVING-equivalent: filters on the already-projected/aggregated
579/// row (e.g. `WITH p, count(f) AS c WHERE c > 10`, or `WITH a, b WHERE
580/// a = b`). Same And/Or/Not/Compare shape as `Expr`, but both comparison
581/// operands are a `ReturnExpr` (a WITH alias or raw expression, so either
582/// side can be a bound variable/property, not just the LHS against a
583/// constant) instead of a raw-property `PropAccess`/`Literal` pair —
584/// deliberately a separate type from `Expr` rather than a widened reuse
585/// of it, since `Expr::Compare` is what the planner pushes into
586/// pre-projection `Filter`/`Expand` nodes, and this filter fundamentally
587/// belongs *post*-projection instead (see `materialize_with`).
588#[derive(Debug, Clone, PartialEq)]
589pub enum WithExpr {
590    And(Box<WithExpr>, Box<WithExpr>),
591    Or(Box<WithExpr>, Box<WithExpr>),
592    Not(Box<WithExpr>),
593    Compare(ReturnExpr, CompareOp, ReturnExpr),
594    /// `x IS NULL` -- `x` is any `add_expr`, not just a property access
595    /// (`WHERE r IS NULL`, checking an OPTIONAL MATCH miss on a whole
596    /// bound var). `IS NOT NULL` parses to `Not(IsNull(..))`, same
597    /// convention as pattern-level `Expr::IsNull`.
598    IsNull(ReturnExpr),
599    /// A boolean-valued expression used directly as a predicate with no
600    /// comparison operator at all -- `WHERE single(x IN list WHERE x = 2)
601    /// OR all(x IN list WHERE x = 2)`, `WHERE n.flag`, `WHERE
602    /// exists(n.prop)`. Three-valued (`Null` is "unknown"), evaluated via
603    /// `value_to_bool3` (real Cypher: a non-boolean value here is a type
604    /// error, not silently coerced).
605    Bare(ReturnExpr),
606}
607
608/// A `WITH` clause: projects/renames the current bindings, optionally
609/// filtered/sorted/limited at that boundary, and becomes the binding scope
610/// for whatever follows (the next `QueryPart`, or the final `Tail`).
611#[derive(Debug, Clone, PartialEq)]
612pub struct WithClause {
613    pub items: Vec<ReturnItem>,
614    /// `WITH *` (optionally followed by more items, `WITH *, x AS y`) --
615    /// every currently-bound variable, alphabetically, same convention
616    /// `Tail::ReturnStar`'s own `return_star_items` already established
617    /// for `RETURN *`. Can't be resolved into concrete `items` at parse
618    /// time (no scope exists yet) -- resolved independently wherever the
619    /// real bound-variable-name set entering this WITH is already on
620    /// hand (`executor::apply_with_or_carry`'s own `carried_vars`,
621    /// `semantic::project_with`'s `input: &Scope`, `explain.rs`'s own
622    /// `carried_vars`), mirroring `ReturnStar`'s "resolve at each call
623    /// site" approach rather than a separate whole-AST-mutation pass.
624    /// When both `star` and `items` are present, star-expanded names
625    /// come first (real Cypher has no TCK-tested requirement either way
626    /// for this combination, but this ordering is the common convention).
627    pub star: bool,
628    /// `WITH DISTINCT ...` -- dedups the projected rows, same as `RETURN
629    /// DISTINCT` (`Tail::Return`'s own `distinct` flag), applied right
630    /// after projection/aggregation, before `where_clause` (matching
631    /// `WHERE`'s own "only sees the projected/aggregated names" rule for
632    /// an aggregating `WITH` -- `DISTINCT` puts `WITH` in that same
633    /// post-projection-only regime).
634    pub distinct: bool,
635    pub where_clause: Option<WithExpr>,
636    pub order_by: Option<Vec<(ReturnExpr, SortDir)>>,
637    /// Always applied *after* `order_by` (real Cypher's own rule — skip N,
638    /// then take the following `limit`, against the sorted sequence when
639    /// one exists), regardless of which field a caller happens to read
640    /// first.
641    ///
642    /// Any expression, not just a literal integer — `SKIP $n`, `SKIP
643    /// toInteger(rand()*9)` (TCK's `ReturnSkipLimit1 [2]`/`[3]`) are real
644    /// Cypher. Evaluated exactly once against an empty row (no pattern
645    /// variable can be in scope here) — see
646    /// `executor::resolve_skip_limit`.
647    pub skip: Option<ReturnExpr>,
648    pub limit: Option<ReturnExpr>,
649}
650
651/// One `MATCH <pattern>[, <pattern>...] [WHERE ...] [WITH ...]` segment.
652/// Comma-separated patterns that continue each other (a later pattern's
653/// start is the previous one's last-introduced variable) are spliced into
654/// a single linear `Pattern` at parse time (see
655/// `parser::group_into_linear_patterns`) — this only ever holds one
656/// already-combined `Pattern`, not several. A genuine disjoint cross join
657/// (`MATCH (a:A), (b:B)`) instead becomes *multiple* `QueryPart`s, one per
658/// disjoint group (see `parser::parse_match_part`'s docs).
659///
660/// `path_var` is `Some` for `p = (a)-->(b)` / `p = shortestPath(...)` —
661/// capturing the whole matched path, not just its endpoints. General
662/// named-path capture (`shortest_path: false`) is limited to fixed-hop
663/// patterns — `pattern` must contain no variable-length (`*`) hop, parser-
664/// enforced, since reconstructing a path over `VarExpand`'s BFS would need
665/// the same parent-pointer tracking `shortestPath()` already has, but
666/// generalized, which isn't worth it for the narrow payoff. `shortest_path
667/// : true` is the opposite: `pattern` must be exactly one variable-length
668/// hop (`shortestPath((a)-[:TYPE*..N]-(b))`), and both endpoints must
669/// already be bound by a preceding clause (see `executor::eval_shortest_
670/// path`'s docs for why unbound endpoints aren't supported in v1).
671#[derive(Debug, Clone, PartialEq)]
672pub struct QueryPart {
673    pub optional: bool,
674    pub path_var: Option<String>,
675    pub shortest_path: bool,
676    pub pattern: Pattern,
677    pub where_clause: Option<Expr>,
678    pub with: Option<WithClause>,
679}
680
681/// `UNWIND <source> AS <var> [WHERE ...] [WITH ...]` — fans a list out into
682/// one row per element, cross-joined against whatever rows already exist
683/// (same "row-vector-in, row-vector-out, no graph traversal" shape as a
684/// `WithClause`, not a graph-traversal `LogicalPlan` node — see
685/// `executor::eval_unwind`). Its own `where_clause` (rather than requiring
686/// a `WITH` right after it just to filter) is what makes `UNWIND [1,2,3]
687/// AS x WHERE x > 2` — or `WITH ... collect(m) AS ms UNWIND ms AS m2
688/// WHERE m2.x > 1` — work within the one-`WITH`-per-statement cap (see
689/// `QueryClause`'s docs). Deliberately typed as `WithExpr`, not the
690/// pattern-level `Expr`: an unwound variable is very often a bare scalar
691/// (`x > 2`), which `Expr::Compare`'s always-`PropAccess` LHS structurally
692/// cannot express (only `x.prop > 2` is) — `WithExpr::Compare`'s
693/// `ReturnExpr` LHS covers both.
694#[derive(Debug, Clone, PartialEq)]
695pub struct UnwindClause {
696    pub source: UnwindSource,
697    pub var: String,
698    pub where_clause: Option<WithExpr>,
699    pub with: Option<WithClause>,
700}
701
702/// Where an `UNWIND`'s list comes from — any expression (`range(0, 2)`,
703/// `n.tags`, a bound `collect()` result, an inline `[1, 2, 3]`, ...),
704/// evaluated per input row and required to produce a `Value::List`
705/// (`executor::eval_unwind`). Element bindings restore graph identity via
706/// the same `value_to_binding_restore` regardless of where the list came
707/// from — there's no `PropertyValue::List` yet, so a `$param` bound
708/// directly to a list still isn't reachable here on its own (every
709/// `$param` is a single scalar); a `$param` used *inside* an inline list
710/// literal (`[1, 2, $p]`) still works, since each element substitutes
711/// independently.
712#[derive(Debug, Clone, PartialEq)]
713pub struct UnwindSource(pub ReturnExpr);
714
715/// `MERGE <pattern> [ON CREATE SET ...] [ON MATCH SET ...] [WITH ...]` —
716/// match-or-create: try the pattern as an ordinary MATCH first (reusing
717/// `build_match_plan`/`eval_plan` — this already does the right "search
718/// the *connected* sub-pattern, not each node in isolation" thing for a
719/// hop pattern, since `Expand` only follows real edges and `Filter` only
720/// keeps matches against the target's own constraints); if that finds
721/// nothing, create exactly one new pattern instance (reusing
722/// `resolve_or_create_node`, the same "reuse if the token's var is
723/// already bound in the row" logic `Tail::Create` uses). `pattern.hops`
724/// is capped at one relationship by the parser — whole-pattern atomicity
725/// across multiple simultaneously-unbound hops isn't attempted in v1, see
726/// `executor::eval_merge`'s docs for why.
727#[derive(Debug, Clone, PartialEq)]
728pub struct MergeClause {
729    pub pattern: Pattern,
730    /// `MERGE p = (a)-[:R]->(b)` -- captures the whole matched-or-created
731    /// pattern as a path, same as ordinary `MATCH`'s own named-path
732    /// capture (`Pattern::path_var`) but on `MergeClause` directly, since
733    /// `Pattern` itself has no notion of a MERGE-vs-MATCH distinction.
734    /// `MergeClause::pattern` caps at one relationship hop (see this
735    /// struct's own construction site), so assembling the path is just
736    /// "the start node, plus the one hop's edge and node if present" --
737    /// no BFS/parent-pointer tracking needed the way a general
738    /// variable-length pattern's path capture would (TCK's Merge1 [13],
739    /// Merge5 [10]).
740    pub path_var: Option<String>,
741    pub on_create: Vec<SetItem>,
742    pub on_match: Vec<SetItem>,
743    pub with: Option<WithClause>,
744}
745
746/// `CALL proc.name(args) [YIELD ...]` -- both the in-query reading-clause
747/// form (`QueryClause::Call`, `args` always `Some` per the grammar's own
748/// `queryCallSt : CALL invocationName parenExpressionChain (YIELD
749/// yieldItems)?`, parens mandatory) and the standalone top-level form
750/// (`Statement::StandaloneCall`, whose `standaloneCall` rule's own
751/// `parenExpressionChain?` is optional -- `args: None` is that implicit-
752/// argument shape, `CALL proc` with no parens at all, where each declared
753/// input instead resolves from a same-named `$param`, TCK's Call1
754/// `[2]`/`[11]`, Call2 `[3]`).
755#[derive(Debug, Clone, PartialEq)]
756pub struct CallClause {
757    pub name: String,
758    pub args: Option<Vec<ReturnExpr>>,
759    /// A trailing `WITH` (TCK's Call6 `[1]`: `CALL ... YIELD label WITH
760    /// count(*) AS c CALL ... YIELD label RETURN *`) -- same "glued onto
761    /// the preceding reading clause while walking `multiPartQ`'s children"
762    /// mechanism `QueryPart`/`UnwindClause`/`MergeClause` already use for
763    /// their own trailing `with`. Always `None` on a `Statement::
764    /// StandaloneCall` (never read there -- a standalone call is always
765    /// the whole statement, nothing can follow it).
766    pub with: Option<WithClause>,
767    /// `None` -- no `YIELD` written at all. For the in-query form this
768    /// means every output is discarded, nothing bound into scope (TCK's
769    /// Call1 `[12]`: referencing an un-yielded output afterward is
770    /// `UndefinedVariable`); for the standalone form it instead means
771    /// "auto-yield every output," same as `Some(CallYield::Star)` would,
772    /// since a standalone `CALL` *is* the whole query (TCK's Call1 `[5]`,
773    /// Call2 `[2]`) -- `executor::eval_standalone_call` is what actually
774    /// applies that standalone-only distinction; this AST shape alone
775    /// can't tell the two apart.
776    pub yield_items: Option<CallYield>,
777}
778
779#[derive(Debug, Clone, PartialEq)]
780pub enum CallYield {
781    /// `YIELD *` -- every declared output, bound under its own name.
782    Star,
783    /// `YIELD a, b AS c, ...` -- explicit output name (optionally
784    /// renamed), plus `yieldItems`' own optional trailing `WHERE` (only
785    /// this variant's grammar production carries one -- `YIELD *` has no
786    /// `where?` of its own).
787    Items(Vec<(String, Option<String>)>, Option<Box<Expr>>),
788}
789
790/// One reading clause in a `MATCH`/`UNWIND`/`MERGE` sequence. `Match` is
791/// today's `MATCH`/`OPTIONAL MATCH ... [WHERE] [WITH]` segment; `Unwind`
792/// fans out a list; `Merge` matches-or-creates. All three can optionally
793/// end in a `WITH` — see `Statement::Match`'s docs for the WITH-
794/// separation/one-WITH-total rules this enum's variants are validated
795/// against.
796#[derive(Debug, Clone, PartialEq)]
797pub enum QueryClause {
798    Match(QueryPart),
799    Unwind(UnwindClause),
800    Merge(MergeClause),
801    /// A statement-leading `WITH` -- no pattern to match, just projects/
802    /// aliases values (`WITH [1,2,3] AS list ...`). Distinct from the
803    /// trailing `with: Option<WithClause>` every other clause kind
804    /// already carries (that one follows a real pattern match; this one
805    /// has nothing preceding it at all).
806    With(WithClause),
807    /// `SET ...` immediately followed by `WITH` -- continues the query
808    /// past the mutation instead of only ever allowing one trailing
809    /// `RETURN` (the pre-existing `Tail::Set`'s own `ReturnTail`).
810    /// Doesn't itself change any row's bindings, only the underlying
811    /// graph -- `execute_match`'s clause loop applies each item per row
812    /// and passes `current_rows` through unchanged, same as `Merge`
813    /// already does for its own non-binding side effects.
814    Set(Vec<SetItem>),
815    /// `DELETE`/`DETACH DELETE ...` immediately followed by `WITH` -- same
816    /// reasoning as `Set` above (TCK's Delete6 "Persistence of delete
817    /// clause side effects"). `detach`: whether `DETACH` was present
818    /// (mirrors `Tail::Delete` vs `Tail::DetachDelete`'s own split).
819    Delete {
820        items: Vec<ReturnExpr>,
821        detach: bool,
822    },
823    /// `REMOVE ...` immediately followed by `WITH` -- same reasoning as
824    /// `Set` above (TCK's Remove3 "Persistence of remove clause side
825    /// effects").
826    Remove(Vec<RemoveItem>),
827    /// `CREATE ...` immediately followed by `WITH` -- same positive-
828    /// lookahead reasoning as `Set` above, but unlike `Set`/`Delete`/
829    /// `Remove`, CREATE *does* change row bindings (each pattern's
830    /// fresh/reused node-and-relationship vars) -- `execute_match`'s
831    /// clause loop reuses `materialize_create` (the exact same function
832    /// `Tail::Create`/`Executor::execute_create` already call) and
833    /// extends `carried_vars` with every pattern's own vars, same as
834    /// `Merge`'s own binding-changing clause already does.
835    Create(Vec<Pattern>),
836    /// `CALL proc.name(args) [YIELD ...]` used as a reading clause --
837    /// `CallClause::args` is always `Some` here (the grammar's own
838    /// `queryCallSt` requires parens), see `CallClause`'s own docs.
839    Call(CallClause),
840}
841
842#[derive(Debug, Clone, PartialEq)]
843pub enum Statement {
844    Create(Vec<Pattern>),
845    /// `CREATE INDEX ON :Label(prop)`, optionally `UNIQUE`.
846    CreateIndex {
847        label: String,
848        prop: String,
849        unique: bool,
850    },
851    /// `EXPLAIN <statement>` — describes the plan `<statement>` would run
852    /// (scan vs seek, pushdown applied) without executing any of it. Never
853    /// nests (the parser only ever wraps a `create_index_stmt`/
854    /// `create_stmt`/`match_stmt`, not another `explain_stmt`).
855    Explain(Box<Statement>),
856    Match {
857        /// One or more `MATCH`/`UNWIND`/`MERGE ... [WITH ...]` clauses. The
858        /// parser enforces every `Match` clause except the statement's
859        /// last has a `with` before the next `Match` clause (matching real
860        /// Cypher's rule that multiple reading clauses must be separated
861        /// by WITH) — `Unwind`/`Merge` clauses are exempt from this
862        /// specific check (they share one binding scope the same way
863        /// `OPTIONAL MATCH` already does, real Cypher needs no WITH around
864        /// a bare UNWIND/MERGE either) — and that at most one clause (of
865        /// any kind) has a `with` at all across the whole statement (v1
866        /// doesn't support chaining past one WITH boundary — nothing in
867        /// the target query set needs it, and it keeps a hand-rolled
868        /// parser's untested-path surface smaller).
869        clauses: Vec<QueryClause>,
870        /// `None` only when a `MERGE` clause is present with nothing after
871        /// it (`MERGE (n:Label)` alone, no `RETURN`/etc — a pure write,
872        /// same as standalone `CREATE`). The parser rejects a missing tail
873        /// otherwise (`MATCH (n)` alone is almost certainly a mistake, not
874        /// a deliberate no-op).
875        tail: Option<Tail>,
876        /// Only meaningful for `Tail::Return`; evaluated against the
877        /// projected/aliased output row, not the raw pattern bindings —
878        /// every ORDER BY key in practice is a RETURN alias, not a bare
879        /// pattern variable.
880        order_by: Option<Vec<(ReturnExpr, SortDir)>>,
881        /// Applied after `order_by`, before `limit` — same convention as
882        /// `WithClause::skip`. Boxed (clippy's `large_enum_variant`) --
883        /// `Statement::Match` would otherwise be far larger than
884        /// `Statement`'s other variants just for this rarely-non-`None`
885        /// field.
886        skip: Option<Box<ReturnExpr>>,
887        limit: Option<Box<ReturnExpr>>,
888    },
889    /// `<match_stmt> UNION [ALL] <match_stmt> (UNION [ALL] <match_stmt>)*`
890    /// — each `parts` entry is itself a `Statement::Match`, own scope, no
891    /// bindings shared across parts (real Cypher: a UNION member can't see
892    /// a preceding member's variables). `all` applies uniformly to the
893    /// whole statement — real Cypher rejects mixing bare `UNION` and
894    /// `UNION ALL` in one statement (a semantic check, `parser::
895    /// parse_union_stmt`, since it's only checkable once every part's own
896    /// `UNION`/`UNION ALL` keyword is in hand). `false` = dedup the
897    /// combined rows (`UNION`'s default); `true` = keep every row
898    /// (`UNION ALL`).
899    Union {
900        parts: Vec<Statement>,
901        all: bool,
902    },
903    /// A bare `CALL proc.name(args) [YIELD ...]` with nothing else in the
904    /// statement (the grammar's own `standaloneCall`, a top-level
905    /// alternative alongside `regularQuery` -- never wrapped in
906    /// `Statement::Match`, since there's no pattern to match at all). See
907    /// `CallClause`'s own docs for why `args`/`yield_items` mean something
908    /// subtly different here than in `QueryClause::Call`. Boxed (clippy's
909    /// `large_enum_variant`) -- `CallClause` is far bigger than
910    /// `Statement`'s other variants just for this rarely-taken one.
911    StandaloneCall(Box<CallClause>),
912}