Skip to main content

marsdb_query/
executor.rs

1use std::cell::{Cell, RefCell};
2use std::collections::{BTreeMap, HashMap, HashSet};
3use std::rc::Rc;
4use std::sync::{
5    atomic::{AtomicBool, Ordering as AtomicOrdering},
6    Arc,
7};
8use std::time::{Duration, Instant};
9
10use marsdb_graph::{
11    AdjEntry, Direction, Edge, EdgeId, GraphStore, Node, NodeId, PropertyValue, Txn,
12    TzId as GraphTzId, WriteTransaction,
13};
14
15use crate::aggregate::{property_value_hash_key, value_hash_key, AggAcc, HashKey};
16use crate::ast::{
17    is_aggregate_name, is_percentile_name, ArithOp, CallClause, CallYield, CompareOp, Expr,
18    Literal, MergeClause, NodePattern, Pattern, PropAccess, QuantifierKind, QueryClause, QueryPart,
19    RelDirection, RemoveItem, ReturnExpr, ReturnItem, ReturnTail, SetItem, SortDir, Statement,
20    Tail, UnwindClause, WithClause, WithExpr,
21};
22use crate::error::QueryError;
23use crate::ir::{ExpandDirection, IndexSeekValue, LogicalPlan};
24use crate::parse_helpers::validate_named_path_pattern;
25use crate::planner::{
26    apply_index_seeks, build_match_plan, pattern_all_vars, pattern_new_vars, plan_edge_scan,
27    plan_reversed_pattern,
28};
29use crate::procedure::{ProcedureProvider, ProcedureSignature};
30use crate::result::{QueryResult, QueryStats};
31use crate::temporal;
32use crate::value::{PathElem, Value};
33
34mod arith;
35mod scalar_fns;
36mod temporal_fns;
37mod value_cmp;
38
39use arith::*;
40use scalar_fns::*;
41pub(crate) use temporal_fns::tz_from_graph;
42use temporal_fns::*;
43pub(crate) use value_cmp::comparable_ordering;
44use value_cmp::*;
45
46/// Hidden key used to correlate `OPTIONAL MATCH` results back to the outer
47/// row that seeded them — never visible to user Cypher (not a valid
48/// identifier prefix a parsed pattern could ever produce).
49const OPTIONAL_SEED_IDX_KEY: &str = "__seed_idx";
50
51/// Hidden key tagging whether a `MERGE`d row came from the create-path or
52/// the match-path, consumed (and stripped) by `apply_merge_set` before the
53/// row becomes visible to the rest of the query.
54const MERGE_CREATED_KEY: &str = "__merge_created";
55
56/// Cooperative cancellation handle for a running query. Clone it before
57/// execution and call [`cancel`](Self::cancel) from another thread.
58#[derive(Debug, Clone, Default)]
59pub struct CancellationToken(Arc<AtomicBool>);
60
61impl CancellationToken {
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    pub fn cancel(&self) {
67        self.0.store(true, AtomicOrdering::Release);
68    }
69
70    pub fn is_cancelled(&self) -> bool {
71        self.0.load(AtomicOrdering::Acquire)
72    }
73}
74
75/// Coarse, stable outcome category for telemetry. Error messages and query
76/// text are deliberately excluded to avoid leaking user data through an
77/// observer by default.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ExecutionOutcome {
80    Success,
81    /// The query text itself never parsed — see `QueryError::Syntax`.
82    SyntaxError,
83    /// The query parsed but is structurally invalid, independent of any
84    /// data/parameters — see `QueryError::Semantic`.
85    SemanticError,
86    /// A real value (from stored data or a `$parameter`) turned out to be
87    /// the wrong shape for what the query does with it — see
88    /// `QueryError::Type`.
89    TypeError,
90    GraphError,
91    UnboundVariable,
92    MissingParameter,
93    Cancelled,
94    Timeout,
95    ResourceLimit,
96}
97
98impl ExecutionOutcome {
99    pub fn from_error(error: &QueryError) -> Self {
100        match error {
101            QueryError::Syntax(_) => Self::SyntaxError,
102            QueryError::Semantic(_) => Self::SemanticError,
103            QueryError::Type(_) => Self::TypeError,
104            QueryError::Graph(_) => Self::GraphError,
105            QueryError::UnboundVariable(_) => Self::UnboundVariable,
106            QueryError::MissingParam(_) => Self::MissingParameter,
107            QueryError::Cancelled => Self::Cancelled,
108            QueryError::Timeout => Self::Timeout,
109            QueryError::ResourceLimit(_) => Self::ResourceLimit,
110        }
111    }
112}
113
114#[derive(Debug, Clone)]
115pub struct ExecutionEvent {
116    pub elapsed: Duration,
117    /// Unknown when parsing failed before a statement was available.
118    pub statement_read_only: Option<bool>,
119    pub result_rows: Option<usize>,
120    pub relationship_expansions: u64,
121    pub outcome: ExecutionOutcome,
122}
123
124/// Dependency-free callback adapter for sending execution events to an
125/// application's logger, metrics collector, or tracing system.
126#[derive(Clone)]
127pub struct ExecutionObserver(Arc<dyn Fn(&ExecutionEvent) + Send + Sync>);
128
129impl ExecutionObserver {
130    pub fn new(callback: impl Fn(&ExecutionEvent) + Send + Sync + 'static) -> Self {
131        Self(Arc::new(callback))
132    }
133
134    pub fn observe(&self, event: &ExecutionEvent) {
135        // Observability must never turn a committed query into a reported
136        // failure (or unwind through FFI callers), so observer panics are
137        // contained at this boundary.
138        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (self.0)(event)));
139    }
140}
141
142impl std::fmt::Debug for ExecutionObserver {
143    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        formatter.write_str("ExecutionObserver(..)")
145    }
146}
147
148/// Per-statement safety limits and optional telemetry. Limit fields default
149/// to `None`, preserving unlimited behavior for trusted embedded callers.
150#[derive(Debug, Clone, Default)]
151pub struct ExecutionOptions {
152    pub max_intermediate_rows: Option<usize>,
153    pub max_result_rows: Option<usize>,
154    pub max_relationship_expansions: Option<u64>,
155    pub timeout: Option<Duration>,
156    pub cancellation_token: Option<CancellationToken>,
157    pub observer: Option<ExecutionObserver>,
158    /// `None` (the default) means `CALL` always fails with "procedure not
159    /// found" -- MarsDB ships no built-in procedures itself, see
160    /// `procedure::ProcedureProvider`'s own docs.
161    pub procedures: Option<crate::procedure::Procedures>,
162    /// The statement's own `$name` parameters, verbatim -- every other
163    /// `$param` position is already resolved to a concrete `Literal`
164    /// before `Executor` ever sees the statement (`substitute_params`,
165    /// run during `marsdb::prepare_statement`, well before this point),
166    /// but a *standalone* `CALL proc` written with no parens at all (TCK's
167    /// Call1 `[2]`/`[11]`, Call2 `[3]`) resolves each declared input from
168    /// a same-named `$param` -- which declared names even exist isn't
169    /// knowable until the procedure's signature is looked up here, at
170    /// execution time (the registry itself, `procedures` above, isn't
171    /// available any earlier either), so this is the one place `Executor`
172    /// still needs the raw map instead of already-substituted AST nodes.
173    pub params: HashMap<String, PropertyValue>,
174}
175
176struct ExecutionGuard<'a> {
177    options: &'a ExecutionOptions,
178    deadline: Option<Instant>,
179    relationship_expansions: Cell<u64>,
180    /// A relationship's *type* is immutable for its whole lifetime, so
181    /// `type(r)` is one of the few things real Cypher still lets a
182    /// statement read off `r` after `DELETE r` deleted it earlier in the
183    /// same statement -- unlike properties/labels (mutable, and a genuine
184    /// `DeletedEntityAccess` error, TCK's Return2 `[15]`-`[17]`), it
185    /// needs no live record at all, just whatever type it had at match
186    /// time. `delete_targets`/`delete_binding`/`delete_value` populate
187    /// this right before actually deleting each edge; `type()`'s own
188    /// evaluation (`Executor::eval_type_call`) falls back to it only when
189    /// the ordinary live lookup fails. `RefCell`, not `&mut` -- `guard`
190    /// is threaded everywhere as a shared reference, same interior-
191    /// mutability precedent `relationship_expansions` above already sets.
192    deleted_edge_types: RefCell<HashMap<EdgeId, String>>,
193}
194
195impl<'a> ExecutionGuard<'a> {
196    fn new(options: &'a ExecutionOptions) -> Self {
197        Self {
198            options,
199            deadline: options
200                .timeout
201                .and_then(|timeout| Instant::now().checked_add(timeout)),
202            relationship_expansions: Cell::new(0),
203            deleted_edge_types: RefCell::new(HashMap::new()),
204        }
205    }
206
207    fn record_deleted_edge_type(&self, id: EdgeId, label: String) {
208        self.deleted_edge_types.borrow_mut().insert(id, label);
209    }
210
211    fn deleted_edge_type(&self, id: EdgeId) -> Option<String> {
212        self.deleted_edge_types.borrow().get(&id).cloned()
213    }
214
215    fn procedure_provider(&self) -> Option<&dyn ProcedureProvider> {
216        self.options.procedures.as_ref().map(|p| p.0.as_ref())
217    }
218
219    fn checkpoint(&self) -> Result<(), QueryError> {
220        if self
221            .options
222            .cancellation_token
223            .as_ref()
224            .is_some_and(CancellationToken::is_cancelled)
225        {
226            return Err(QueryError::Cancelled);
227        }
228        if self
229            .deadline
230            .is_some_and(|deadline| Instant::now() >= deadline)
231        {
232            return Err(QueryError::Timeout);
233        }
234        Ok(())
235    }
236
237    fn check_intermediate_rows(&self, rows: usize) -> Result<(), QueryError> {
238        self.checkpoint()?;
239        if self
240            .options
241            .max_intermediate_rows
242            .is_some_and(|limit| rows > limit)
243        {
244            return Err(QueryError::ResourceLimit(format!(
245                "intermediate row count {rows} exceeds configured maximum {}",
246                self.options.max_intermediate_rows.unwrap()
247            )));
248        }
249        Ok(())
250    }
251
252    fn check_result_rows(&self, rows: usize) -> Result<(), QueryError> {
253        self.checkpoint()?;
254        if self
255            .options
256            .max_result_rows
257            .is_some_and(|limit| rows > limit)
258        {
259            return Err(QueryError::ResourceLimit(format!(
260                "result row count {rows} exceeds configured maximum {}",
261                self.options.max_result_rows.unwrap()
262            )));
263        }
264        Ok(())
265    }
266
267    fn relationship_expansion(&self) -> Result<(), QueryError> {
268        self.checkpoint()?;
269        let count = self
270            .relationship_expansions
271            .get()
272            .checked_add(1)
273            .ok_or_else(|| {
274                QueryError::ResourceLimit("relationship expansion counter overflow".into())
275            })?;
276        self.relationship_expansions.set(count);
277        if self
278            .options
279            .max_relationship_expansions
280            .is_some_and(|limit| count > limit)
281        {
282            return Err(QueryError::ResourceLimit(format!(
283                "relationship expansion count {count} exceeds configured maximum {}",
284                self.options.max_relationship_expansions.unwrap()
285            )));
286        }
287        Ok(())
288    }
289}
290
291#[derive(Debug, Clone)]
292enum Binding {
293    Node(NodeId),
294    Edge(EdgeId),
295    /// A scalar carried through a `WITH` projection (e.g. `WITH message.id
296    /// AS messageId`) — no graph identity, just a value along for the ride
297    /// to the next `QueryPart`/the final `Tail`.
298    Value(PropertyValue),
299    /// A `collect()` result carried through a `WITH` projection. Separate
300    /// from `Binding::Value` because `PropertyValue` (storage-layer) has no
301    /// list variant — lists are a query-layer-only concept, never
302    /// persisted — so a materialized `collect()` has nowhere else to live
303    /// between one `QueryPart` and the next. Elements are already-resolved
304    /// `Value`s, not `Binding`s — `UNWIND` restores graph identity on the
305    /// way back out via `value_to_binding_restore`, a separate step from
306    /// how this is stored here.
307    List(Vec<Value>),
308    /// A map literal (`{a: 1, b: 2}`) carried through a `WITH` projection
309    /// — same reasoning as `List`: `PropertyValue` has no map variant, so
310    /// this is the only place a materialized map has to live between one
311    /// `QueryPart` and the next.
312    Map(BTreeMap<String, Value>),
313    /// A named path (`p = (a)-->(b)`) or `shortestPath()` result — see
314    /// `assemble_path`/`eval_shortest_path`. `PathBinding` (not `Binding`
315    /// again) because a path element only ever needs graph identity
316    /// (`NodeId`/`EdgeId`), never any of `Binding`'s other cases — using
317    /// `Binding` itself here would make "a path containing a path" a type
318    /// state nothing ever produces or handles.
319    Path(Vec<PathBinding>),
320}
321
322/// One element of a `Binding::Path`, alternating node/edge/node/.../node
323/// — the row-carried counterpart to `Value::Path`'s `PathElem` (which
324/// carries full `Node`/`Edge` records instead of just their ids, the same
325/// "keep identity in the row, resolve to a full record only when
326/// materializing for display" split every other `Binding`/`Value` pair
327/// already uses).
328#[derive(Debug, Clone)]
329enum PathBinding {
330    Node(NodeId),
331    Edge(EdgeId),
332}
333
334struct ShortestPathSpec<'a> {
335    direction: ExpandDirection,
336    rel_labels: &'a [String],
337    min_hops: u32,
338    max_hops: Option<u32>,
339}
340
341struct VarExpandSpec<'a> {
342    from_var: &'a str,
343    to_var: &'a str,
344    rel_labels: &'a [String],
345    direction: ExpandDirection,
346    min_hops: u32,
347    max_hops: Option<u32>,
348    /// Rel-vars bound by earlier fixed hops of the same pattern — see
349    /// `LogicalPlan::VarExpand`'s own docs.
350    exclude_edge_vars: &'a [String],
351    /// See `LogicalPlan::VarExpand::exclude_edge_sets`'s own docs.
352    exclude_edge_sets: &'a [String],
353    /// See `LogicalPlan::VarExpand::exclude_edge_var`'s own docs.
354    exclude_edge_var: &'a str,
355    /// See `LogicalPlan::VarExpand::path_segment_var`'s own docs.
356    path_segment_var: Option<&'a str>,
357    /// See `LogicalPlan::VarExpand::rel_list_var`'s own docs.
358    rel_list_var: Option<&'a str>,
359    /// See `LogicalPlan::VarExpand::rel_props`'s own docs.
360    rel_props: &'a [(String, ReturnExpr)],
361}
362
363struct MatchRelListSpec<'a> {
364    from_var: &'a str,
365    to_var: &'a str,
366    rel_list_var: &'a str,
367    rel_labels: &'a [String],
368    direction: ExpandDirection,
369    min_hops: u32,
370    max_hops: Option<u32>,
371}
372
373struct PatternComprehensionSpec<'a> {
374    path_var: &'a Option<String>,
375    pattern: &'a Pattern,
376    where_clause: &'a Option<Box<Expr>>,
377    projection: &'a ReturnExpr,
378}
379
380struct IndexSeekSpec<'a> {
381    var: &'a str,
382    label: &'a str,
383    prop: &'a str,
384    value: &'a IndexSeekValue,
385}
386
387/// Read-only context `Executor::rewrite_composed_item` needs to resolve a
388/// composed aggregate item's non-aggregate leaves -- see its own docs.
389struct GroupFinishCtx<'a> {
390    items: &'a [ReturnItem],
391    key_bindings: &'a [Option<Binding>],
392}
393
394/// `ORDER BY`/`SKIP`/`LIMIT` bundled into one argument for
395/// `execute_match` (clippy's `too_many_arguments`, capped at 7) --
396/// mirrors `Statement::Match`'s own trailing fields, always applied in
397/// this order regardless of which fields are actually present (`SKIP`
398/// after `ORDER BY`, `LIMIT` after `SKIP`).
399struct ResultModifiers<'a> {
400    order_by: &'a Option<Vec<(ReturnExpr, SortDir)>>,
401    skip: Option<i64>,
402    limit: Option<i64>,
403}
404
405type BindingRow = HashMap<String, Binding>;
406/// A fast-path hit: the finished (grouped/ordered/limited) rows plus the
407/// clause's output names for `carried_vars`.
408type FastCountResult = (Vec<BindingRow>, HashSet<String>);
409type RowStream<'a> = Box<dyn Iterator<Item = Result<BindingRow, QueryError>> + 'a>;
410
411/// Borrowed field bundle for `stream_edge_type_scan` (clippy's
412/// too-many-arguments, structured).
413struct EdgeTypeScanSpec<'s> {
414    src_var: &'s str,
415    rel_var: &'s str,
416    dst_var: &'s str,
417    rel_types: &'s [String],
418    src_label: Option<&'s str>,
419    dst_label: Option<&'s str>,
420    rel_predicate: Option<&'s Expr>,
421}
422
423/// `None` = untyped hop (any edge matches); `Some(ids)` = the interned
424/// ids of the named types, names never interned simply absent (an
425/// all-unknown list yields `Some(vec![])` -- matches nothing).
426fn resolve_type_ids(txn: Txn, rel_types: &[String]) -> Result<Option<Vec<u32>>, QueryError> {
427    if rel_types.is_empty() {
428        return Ok(None);
429    }
430    let mut ids = Vec::with_capacity(rel_types.len());
431    for name in rel_types {
432        if let Some(id) = GraphStore::label_id_for(txn, name)? {
433            ids.push(id);
434        }
435    }
436    Ok(Some(ids))
437}
438
439/// The definite-answer predicate evaluator over raw edge-record bytes
440/// -- exactly the shapes `planner::edge_scan_evaluable` admits, with
441/// `value_cmp::compare`'s three-valued semantics collapsed the same
442/// way the generic Filter collapses them (unknown => not-true). `Not`
443/// only ever wraps `IS NULL` here (a definite value), so the collapse
444/// never flips an unknown.
445fn eval_scan_predicate(
446    bytes: &[u8],
447    pred: &Expr,
448    prop_ids: &HashMap<String, Option<u32>>,
449) -> Result<bool, QueryError> {
450    let lookup = |prop: &str| -> Result<Option<PropertyValue>, QueryError> {
451        match prop_ids.get(prop).copied().flatten() {
452            Some(id) => Ok(GraphStore::edge_record_prop(bytes, id)?),
453            None => Ok(None),
454        }
455    };
456    Ok(match pred {
457        Expr::And(l, r) => {
458            eval_scan_predicate(bytes, l, prop_ids)? && eval_scan_predicate(bytes, r, prop_ids)?
459        }
460        Expr::Compare(pa, op, lit) => {
461            let value = lookup(&pa.prop)?;
462            compare(&value, *op, lit) == Some(true)
463        }
464        Expr::IsNull(pa) => matches!(lookup(&pa.prop)?, None | Some(PropertyValue::Null)),
465        Expr::Not(inner) => match inner.as_ref() {
466            Expr::IsNull(pa) => !matches!(lookup(&pa.prop)?, None | Some(PropertyValue::Null)),
467            other => {
468                return Err(QueryError::Semantic(format!(
469                    "internal: non-scan-evaluable NOT reached EdgeTypeScan: {other:?}"
470                )))
471            }
472        },
473        other => {
474            return Err(QueryError::Semantic(format!(
475                "internal: non-scan-evaluable predicate reached EdgeTypeScan: {other:?}"
476            )))
477        }
478    })
479}
480
481/// Receiver for `Executor::execute_streaming_with_options` — rows are
482/// pushed one at a time, never materialized as a whole result.
483/// `columns` is called exactly once, before the first row. Returning
484/// `Break` from `row` stops the scan cleanly (early termination, not an
485/// error).
486pub trait RowSink {
487    fn columns(&mut self, columns: &[String]);
488    fn row(&mut self, row: Vec<Value>) -> std::ops::ControlFlow<()>;
489}
490
491/// Safety cap on unbounded variable-length traversal (`[:TYPE*0..]`) depth.
492/// Hitting it errors rather than silently truncating — see `VarExpand`
493/// evaluation. Expansion uses relationship uniqueness per path: a node may
494/// be revisited and two distinct paths to the same node remain distinct, but
495/// a relationship cannot occur twice in one path.
496const VAR_EXPAND_DEPTH_CAP: u32 = 30;
497
498pub struct Executor<'a> {
499    store: &'a GraphStore,
500    /// Lazily captured on first use, then reused for every no-arg
501    /// `date()`/`localtime()`/`time()`/`localdatetime()`/`datetime()`
502    /// call for the rest of this `Executor`'s lifetime (one per
503    /// statement execution, see `Executor::new`'s callers) -- real
504    /// Cypher's guarantee that every such call *within one query*
505    /// returns the same value (see `temporal::NowSnapshot`'s docs).
506    now: Cell<Option<temporal::NowSnapshot>>,
507    /// `NodeId -> Node` memo, cleared at the start of every statement --
508    /// both entry points (`execute_with_guard` and
509    /// `execute_in_write_transaction_with_guard`, see their own reset
510    /// lines) must do this, since `node_cache` is a field on `Executor`
511    /// shared by both, not private to either. Serves *every* statement:
512    /// read-only ones have one consistent snapshot for their whole
513    /// duration, and write statements stay coherent by evicting a node's
514    /// entry at every site that mutates or deletes that node's record
515    /// (`uncache_node` -- SET/REMOVE on props or labels, node DELETE).
516    /// An earlier version disabled the cache for write statements
517    /// wholesale ("the write path was never the hot case") -- wrong for
518    /// a predicate-driven bulk `DELETE r`, whose MATCH phase
519    /// label-checks both endpoint nodes of every expanded edge: with the
520    /// cache off that's a full node decode per *row* (~380ms of a ~490ms
521    /// statement on the recommendations benchmark, users re-decoded
522    /// ~150x each), with it on it's one decode per *distinct* node.
523    /// Found via a real flamegraph both times: `get_node_in_txn`'s
524    /// postcard decode of the full `NodeRecord` (every property, not
525    /// just the ones a query reads) is the dominant term, much of it the
526    /// *same* node decoded repeatedly (`RETURN n.a, n.b ORDER BY n.c`
527    /// decodes `n` three times).
528    ///
529    /// Currently unbounded -- a statement that scans wide retains an
530    /// `Rc<Node>` for every node it touches until the statement ends,
531    /// where the pre-cache code decoded-and-dropped per row. On a
532    /// dataset larger than RAM this can turn a slow query into an OOM
533    /// risk; see mars-kvb for a size-capped follow-up (stop inserting
534    /// past N entries, keep serving existing hits).
535    node_cache: RefCell<HashMap<NodeId, Rc<Node>>>,
536    /// Whether the executing statement is read-only. Gates the one memo
537    /// entry kind that can go stale mid-write-statement: `prop_id_for`'s
538    /// `None` ("name never interned") answers -- a later `CREATE`/`SET`
539    /// in the same statement can intern that very name. `Some(id)`
540    /// entries are immutable facts and are memoized unconditionally.
541    read_only_stmt: Cell<bool>,
542    /// Prop-name -> interned-id memo for the per-property read path
543    /// (`lookup_prop`), cleared at every statement entry point alongside
544    /// `node_cache`. See `read_only_stmt` for the `None`-entry gating.
545    prop_id_memo: RefCell<HashMap<String, Option<u32>>>,
546    /// Per-statement write counters (`QueryResult::stats`), accumulated
547    /// at every mutation site, reset at both statement entry points
548    /// (same lifecycle as `node_cache`), and taken into the returned
549    /// `QueryResult` on the way out.
550    stats: RefCell<QueryStats>,
551}
552
553impl<'a> Executor<'a> {
554    pub fn new(store: &'a GraphStore) -> Self {
555        Self {
556            store,
557            now: Cell::new(None),
558            node_cache: RefCell::new(HashMap::new()),
559            read_only_stmt: Cell::new(false),
560            prop_id_memo: RefCell::new(HashMap::new()),
561            stats: RefCell::new(QueryStats::default()),
562        }
563    }
564
565    /// Bump one statement-stats counter — the single mutation-site hook.
566    fn count(&self, bump: impl FnOnce(&mut QueryStats)) {
567        bump(&mut self.stats.borrow_mut());
568    }
569
570    /// Cached equivalent of `GraphStore::get_node_in_txn` -- see
571    /// `node_cache`'s own docs. Always caches: write statements keep the
572    /// cache coherent by evicting a node's entry at every site that
573    /// mutates or deletes that node's record (`uncache_node`), so a
574    /// statement that never touches node records -- a predicate-driven
575    /// bulk `DELETE r`, whose MATCH phase label-checks both endpoints of
576    /// every expanded edge -- gets the same per-distinct-node decode a
577    /// read-only statement does instead of a full record decode per row.
578    fn get_node_cached(&self, txn: Txn, id: NodeId) -> Result<Option<Rc<Node>>, QueryError> {
579        if let Some(cached) = self.node_cache.borrow().get(&id) {
580            return Ok(Some(Rc::clone(cached)));
581        }
582        let node = GraphStore::get_node_in_txn(txn, id)?.map(Rc::new);
583        if let Some(n) = &node {
584            self.node_cache.borrow_mut().insert(id, Rc::clone(n));
585        }
586        Ok(node)
587    }
588
589    /// Evict one node from `node_cache`. Every write-path site that
590    /// mutates or deletes an *existing* node's record (SET/REMOVE on
591    /// props or labels, DELETE of the node) must call this with the id
592    /// it just changed -- that eviction is the entire coherence story
593    /// that lets `get_node_cached` serve write statements at all. Node
594    /// *creation* sites don't need it: a fresh id can't have been cached.
595    fn uncache_node(&self, id: NodeId) {
596        self.node_cache.borrow_mut().remove(&id);
597    }
598
599    fn now_snapshot(&self) -> temporal::NowSnapshot {
600        if let Some(n) = self.now.get() {
601            return n;
602        }
603        let n = temporal::capture_now();
604        self.now.set(Some(n));
605        n
606    }
607
608    /// Dispatches on whether `stmt` ever mutates anything. A read-only
609    /// statement (`MATCH ... RETURN`, `is_read_only` below) runs inside a
610    /// `ReadTransaction` — a consistent snapshot that doesn't contend for
611    /// redb's single-writer lock, so concurrent readers run in parallel
612    /// instead of queueing behind each other. Everything else runs inside
613    /// a `WriteTransaction`, committed or aborted as a whole — the
614    /// crash-safety boundary from the plan (one statement = one commit).
615    /// Every graph access below this point must go through the `*_in_txn`
616    /// GraphStore methods, never the standalone `self.store.*` methods,
617    /// which open (and would deadlock trying to re-open) their own
618    /// transaction.
619    pub fn execute(&self, stmt: &Statement) -> Result<QueryResult, QueryError> {
620        self.execute_with_options(stmt, &ExecutionOptions::default())
621    }
622
623    pub fn execute_with_options(
624        &self,
625        stmt: &Statement,
626        options: &ExecutionOptions,
627    ) -> Result<QueryResult, QueryError> {
628        let started = Instant::now();
629        let guard = ExecutionGuard::new(options);
630        let result = self.execute_with_guard(stmt, &guard);
631        Self::notify_observer(options, stmt, started, &guard, &result);
632        result
633    }
634
635    fn execute_with_guard(
636        &self,
637        stmt: &Statement,
638        guard: &ExecutionGuard<'_>,
639    ) -> Result<QueryResult, QueryError> {
640        crate::semantic::validate_statement(stmt)?;
641        guard.checkpoint()?;
642        // Fresh cache generation per statement -- an `Executor` is reused
643        // across many statements (`execute_batch`, group commit), so a
644        // cache that outlived one statement would return stale records
645        // for a node a *later* statement mutated.
646        self.node_cache.borrow_mut().clear();
647        self.prop_id_memo.borrow_mut().clear();
648        self.read_only_stmt.set(is_read_only(stmt));
649        *self.stats.borrow_mut() = QueryStats::default();
650        if let Statement::Explain(inner) = stmt {
651            // Never opens a WriteTransaction, regardless of what `inner`
652            // itself would otherwise mutate -- EXPLAIN describes a plan,
653            // it never runs one.
654            return self.execute_explain(inner);
655        }
656        if is_read_only(stmt) {
657            let read_txn = self.store.begin_read()?;
658            // No explicit commit/abort — a ReadTransaction is a pure
659            // snapshot view with nothing to roll back; it releases on drop.
660            return match stmt {
661                Statement::Union { parts, all } => {
662                    self.materialize_union(Txn::Read(&read_txn), parts, *all, guard)
663                }
664                Statement::Match {
665                    clauses,
666                    tail,
667                    order_by,
668                    skip,
669                    limit,
670                } => {
671                    let skip = self.resolve_skip_limit(
672                        Txn::Read(&read_txn),
673                        skip.as_deref(),
674                        "SKIP",
675                        guard,
676                    )?;
677                    let limit = self.resolve_skip_limit(
678                        Txn::Read(&read_txn),
679                        limit.as_deref(),
680                        "LIMIT",
681                        guard,
682                    )?;
683                    self.execute_match(
684                        Txn::Read(&read_txn),
685                        clauses,
686                        tail,
687                        ResultModifiers {
688                            order_by,
689                            skip,
690                            limit,
691                        },
692                        guard,
693                    )
694                }
695                _ => unreachable!("is_read_only only returns true for Statement::Match/Union"),
696            };
697        }
698        let write_txn = self.store.begin_write()?;
699        let outcome = self.execute_in_write_transaction_validated(stmt, &write_txn, guard);
700        match outcome {
701            Ok(mut result) => {
702                GraphStore::commit(write_txn)?;
703                result.stats = std::mem::take(&mut self.stats.borrow_mut());
704                Ok(result)
705            }
706            Err(e) => {
707                // Best-effort rollback; the original error is what matters.
708                let _ = GraphStore::abort(write_txn);
709                Err(e)
710            }
711        }
712    }
713
714    /// Stream a read-only statement's rows to `sink` instead of
715    /// materializing a `QueryResult` — bounded memory no matter how many
716    /// rows match, the bulk-export path. Only the genuinely streamable
717    /// shape is accepted: one `MATCH` clause (no `WITH` pipeline, no
718    /// `OPTIONAL`, no `shortestPath`, no named path) with a plain
719    /// `RETURN` (no aggregation, no `DISTINCT`, no `ORDER BY`; `SKIP`/
720    /// `LIMIT` are fine — they stream naturally). Anything else is a
721    /// `Semantic` error naming the blocker, NOT a silent fall-back to
722    /// materialization — an API that promises bounded memory must never
723    /// quietly break the promise. The sink returning `Break` stops the
724    /// scan cleanly (early termination, `Ok(())`).
725    ///
726    /// `max_result_rows`/`timeout`/cancellation apply per streamed row.
727    pub fn execute_streaming_with_options(
728        &self,
729        stmt: &Statement,
730        options: &ExecutionOptions,
731        sink: &mut dyn RowSink,
732    ) -> Result<(), QueryError> {
733        crate::semantic::validate_statement(stmt)?;
734        let guard = ExecutionGuard::new(options);
735        self.node_cache.borrow_mut().clear();
736        self.prop_id_memo.borrow_mut().clear();
737        self.read_only_stmt.set(true);
738        *self.stats.borrow_mut() = QueryStats::default();
739
740        let not_streamable = |what: &str| {
741            Err(QueryError::Semantic(format!(
742                "statement is not streamable ({what}) -- use execute() instead"
743            )))
744        };
745        if !is_read_only(stmt) {
746            return not_streamable("only read-only statements stream");
747        }
748        let Statement::Match {
749            clauses,
750            tail,
751            order_by,
752            skip,
753            limit,
754        } = stmt
755        else {
756            return not_streamable("UNION does not stream");
757        };
758        if order_by.is_some() {
759            return not_streamable("ORDER BY must see every row before emitting any");
760        }
761        let [QueryClause::Match(part)] = clauses.as_slice() else {
762            return not_streamable("multi-clause pipelines materialize between clauses");
763        };
764        if part.with.is_some() || part.optional || part.shortest_path || part.path_var.is_some() {
765            return not_streamable("WITH/OPTIONAL/shortestPath/named-path forms materialize");
766        }
767        let Some(Tail::Return(items, false)) = tail else {
768            return not_streamable("DISTINCT must see every row to dedup");
769        };
770        if has_aggregate(items) {
771            return not_streamable("aggregation must consume every row before emitting any");
772        }
773
774        let read_txn = self.store.begin_read()?;
775        let txn = Txn::Read(&read_txn);
776        let skip_n = self
777            .resolve_skip_limit(txn, skip.as_deref(), "SKIP", &guard)?
778            .unwrap_or(0)
779            .max(0) as usize;
780        let limit_n = self
781            .resolve_skip_limit(txn, limit.as_deref(), "LIMIT", &guard)?
782            .map(|l| l.max(0) as usize);
783
784        let carried_vars = HashSet::new();
785        let plan = match plan_edge_scan(&part.pattern, &part.where_clause, &carried_vars, txn)? {
786            Some(plan) => plan,
787            None => {
788                let reversed =
789                    plan_reversed_pattern(&part.pattern, &part.where_clause, &carried_vars, txn)?;
790                let pattern = reversed.as_ref().unwrap_or(&part.pattern);
791                apply_index_seeks(
792                    build_match_plan(pattern, &part.where_clause, &carried_vars)?,
793                    txn,
794                )?
795            }
796        };
797
798        let columns: Vec<String> = items
799            .iter()
800            .enumerate()
801            .map(|(i, item)| {
802                item.alias
803                    .clone()
804                    .unwrap_or_else(|| default_column_name(&item.expr, i))
805            })
806            .collect();
807        sink.columns(&columns);
808
809        let seed = [BindingRow::new()];
810        let stream_cap = limit_n.map(|l| skip_n + l);
811        let stream = self.stream_plan(txn, &plan, &seed, &guard, stream_cap);
812        let mut skipped = 0usize;
813        let mut emitted = 0usize;
814        for row in stream {
815            let row = row?;
816            if skipped < skip_n {
817                skipped += 1;
818                continue;
819            }
820            let mut out = Vec::with_capacity(items.len());
821            for item in items {
822                out.push(self.eval_return_expr(txn, &item.expr, &row, &guard)?);
823            }
824            emitted += 1;
825            guard.check_result_rows(emitted)?;
826            if sink.row(out).is_break() {
827                return Ok(());
828            }
829            if limit_n.is_some_and(|l| emitted >= l) {
830                return Ok(());
831            }
832        }
833        Ok(())
834    }
835
836    /// Execute without committing against a caller-owned write transaction.
837    /// The caller must commit or abort the transaction. This is the low-level
838    /// primitive used by `marsdb::Transaction` for atomic multi-statement
839    /// units of work.
840    pub fn execute_in_write_transaction(
841        &self,
842        stmt: &Statement,
843        write_txn: &WriteTransaction,
844    ) -> Result<QueryResult, QueryError> {
845        self.execute_in_write_transaction_with_options(
846            stmt,
847            write_txn,
848            &ExecutionOptions::default(),
849        )
850    }
851
852    pub fn execute_in_write_transaction_with_options(
853        &self,
854        stmt: &Statement,
855        write_txn: &WriteTransaction,
856        options: &ExecutionOptions,
857    ) -> Result<QueryResult, QueryError> {
858        let started = Instant::now();
859        let guard = ExecutionGuard::new(options);
860        let result = self.execute_in_write_transaction_with_guard(stmt, write_txn, &guard);
861        Self::notify_observer(options, stmt, started, &guard, &result);
862        result
863    }
864
865    fn execute_in_write_transaction_with_guard(
866        &self,
867        stmt: &Statement,
868        write_txn: &WriteTransaction,
869        guard: &ExecutionGuard<'_>,
870    ) -> Result<QueryResult, QueryError> {
871        crate::semantic::validate_statement(stmt)?;
872        guard.checkpoint()?;
873        // Same cache-generation reset as the top-level path
874        // (`execute_with_guard`) -- this is a second, separate entry
875        // point into statement execution (an explicit multi-statement
876        // `Transaction`, or a group-commit loop, calls this directly with
877        // an already-open `write_txn` instead of going through
878        // `execute`/`execute_with_options`), and `node_cache` is a field
879        // on `Executor`, not something either entry point owns privately
880        // -- skipping the reset here left the flag/map from whatever this
881        // `Executor` last did through the *other* entry point in effect.
882        self.node_cache.borrow_mut().clear();
883        self.prop_id_memo.borrow_mut().clear();
884        self.read_only_stmt.set(is_read_only(stmt));
885        *self.stats.borrow_mut() = QueryStats::default();
886        if let Statement::Explain(inner) = stmt {
887            // Same "never mutates" contract as the top-level path -- opens
888            // its own ReadTransaction rather than touching the caller's
889            // already-open `write_txn`, even when this runs inside an
890            // explicit multi-statement transaction.
891            return self.execute_explain(inner);
892        }
893        let mut result = self.execute_in_write_transaction_validated(stmt, write_txn, guard)?;
894        result.stats = std::mem::take(&mut self.stats.borrow_mut());
895        Ok(result)
896    }
897
898    /// `EXPLAIN <statement>` — always opens its own `ReadTransaction`
899    /// (never the caller's write transaction, never a fresh write
900    /// transaction of its own) so describing a plan can never itself
901    /// mutate anything, no matter what `inner` would otherwise do.
902    fn execute_explain(&self, inner: &Statement) -> Result<QueryResult, QueryError> {
903        let read_txn = self.store.begin_read()?;
904        let lines = crate::explain::explain_statement(inner, Txn::Read(&read_txn))?;
905        Ok(QueryResult {
906            columns: vec!["plan".to_string()],
907            rows: lines
908                .into_iter()
909                .map(|line| vec![Value::Literal(Literal::String(line))])
910                .collect(),
911            stats: QueryStats::default(),
912        })
913    }
914
915    fn notify_observer(
916        options: &ExecutionOptions,
917        stmt: &Statement,
918        started: Instant,
919        guard: &ExecutionGuard<'_>,
920        result: &Result<QueryResult, QueryError>,
921    ) {
922        let Some(observer) = &options.observer else {
923            return;
924        };
925        let (result_rows, outcome) = match result {
926            Ok(result) => (Some(result.rows.len()), ExecutionOutcome::Success),
927            Err(error) => (None, ExecutionOutcome::from_error(error)),
928        };
929        observer.observe(&ExecutionEvent {
930            elapsed: started.elapsed(),
931            statement_read_only: Some(is_read_only(stmt)),
932            result_rows,
933            relationship_expansions: guard.relationship_expansions.get(),
934            outcome,
935        });
936    }
937
938    fn execute_in_write_transaction_validated(
939        &self,
940        stmt: &Statement,
941        write_txn: &WriteTransaction,
942        guard: &ExecutionGuard<'_>,
943    ) -> Result<QueryResult, QueryError> {
944        match stmt {
945            // Session statements never reach a correctly-wired call path:
946            // `marsdb::Database` intercepts them before any executor entry
947            // point. Reachable only through a caller with its own
948            // transaction handling (`marsdb::Transaction::execute`, the
949            // group-commit loop, or direct `Executor` use) -- where a
950            // nested BEGIN/COMMIT/ROLLBACK has no session to act on and
951            // must be a real error, not a silent no-op.
952            Statement::Begin | Statement::Commit | Statement::Rollback => {
953                Err(QueryError::Semantic(
954                    "BEGIN/COMMIT/ROLLBACK are session statements -- valid only through \
955                     Database::execute/execute_batch, not inside an explicit Transaction \
956                     or a grouped batch"
957                        .into(),
958                ))
959            }
960            Statement::Create(patterns) => {
961                guard.checkpoint()?;
962                self.execute_create(write_txn, patterns, guard)
963            }
964            Statement::CreateIndex {
965                label,
966                prop,
967                unique,
968            } => {
969                guard.checkpoint()?;
970                GraphStore::create_index_in_txn(write_txn, label, prop, *unique)?;
971                Ok(QueryResult {
972                    columns: vec![],
973                    rows: vec![],
974                    stats: QueryStats::default(),
975                })
976            }
977            Statement::Match {
978                clauses,
979                tail,
980                order_by,
981                skip,
982                limit,
983            } => {
984                let skip =
985                    self.resolve_skip_limit(Txn::Write(write_txn), skip.as_deref(), "SKIP", guard)?;
986                let limit = self.resolve_skip_limit(
987                    Txn::Write(write_txn),
988                    limit.as_deref(),
989                    "LIMIT",
990                    guard,
991                )?;
992                self.execute_match(
993                    Txn::Write(write_txn),
994                    clauses,
995                    tail,
996                    ResultModifiers {
997                        order_by,
998                        skip,
999                        limit,
1000                    },
1001                    guard,
1002                )
1003            }
1004            Statement::Explain(inner) => {
1005                // Only reachable if a future caller invokes this directly,
1006                // bypassing `execute_in_write_transaction_with_guard`'s own
1007                // interception above -- kept as a real (not `unreachable!`)
1008                // fallback so that stays true even if this function's
1009                // caller set ever changes, rather than becoming a latent
1010                // panic.
1011                self.execute_explain(inner)
1012            }
1013            Statement::Union { parts, all } => {
1014                self.materialize_union(Txn::Write(write_txn), parts, *all, guard)
1015            }
1016            Statement::StandaloneCall(call) => {
1017                self.eval_standalone_call(Txn::Write(write_txn), call, guard)
1018            }
1019        }
1020    }
1021
1022    /// `CALL proc(args) [YIELD ...]` with nothing else in the statement
1023    /// (TCK's Call1 `[1]`/`[2]`/`[5]`, Call2 `[2]`/`[3]`) -- unlike the
1024    /// in-query form, this *is* the whole query: no outer rows to run the
1025    /// call once per, and no YIELD at all means "auto-yield every output"
1026    /// (`CallYield::Star`) rather than "discard everything."
1027    /// `QueryClause::Call`'s own in-query handling -- calls the procedure
1028    /// once per input row (TCK's Call1 `[3]`/`[4]`: even a `WHERE`-less,
1029    /// output-less call still runs once per already-matched row, same as
1030    /// any other reading clause). `None` (no `YIELD` at all) discards
1031    /// every output and keeps `row` unchanged -- see `CallClause::
1032    /// yield_items`'s own docs for why that's not the same as `Star`
1033    /// (which never actually reaches here, `queryCallSt`'s grammar has no
1034    /// `YIELD *` alternative). `Items` fans each input row out into one
1035    /// output row per matching procedure result row (same cross-join
1036    /// shape `eval_unwind` already gives its own per-row fan-out), each
1037    /// carrying `row`'s own bindings forward plus the newly yielded ones,
1038    /// filtered by `yieldItems`' own optional trailing `WHERE`.
1039    fn eval_call_clause(
1040        &self,
1041        txn: Txn,
1042        call: &CallClause,
1043        current_rows: &[BindingRow],
1044        guard: &ExecutionGuard<'_>,
1045    ) -> Result<Vec<BindingRow>, QueryError> {
1046        let mut out = Vec::new();
1047        for row in current_rows {
1048            guard.checkpoint()?;
1049            let (sig, proc_rows) = self.call_procedure(txn, call, row, guard)?;
1050            let Some(yield_items) = &call.yield_items else {
1051                out.push(row.clone());
1052                continue;
1053            };
1054            let names: Vec<String> = match yield_items {
1055                CallYield::Star => sig.outputs.clone(),
1056                CallYield::Items(items, _) => items
1057                    .iter()
1058                    .map(|(name, alias)| alias.clone().unwrap_or_else(|| name.clone()))
1059                    .collect(),
1060            };
1061            for proc_row in &proc_rows {
1062                let projected = project_call_row(&sig, proc_row, yield_items)?;
1063                let mut new_row = row.clone();
1064                for (name, value) in names.iter().zip(&projected) {
1065                    new_row.insert(name.clone(), value_to_binding_restore(value));
1066                }
1067                if let CallYield::Items(_, Some(where_expr)) = yield_items {
1068                    if self.eval_expr(txn, where_expr, &new_row, guard)? != Some(true) {
1069                        continue;
1070                    }
1071                }
1072                out.push(new_row);
1073                guard.check_intermediate_rows(out.len())?;
1074            }
1075        }
1076        Ok(out)
1077    }
1078
1079    fn eval_standalone_call(
1080        &self,
1081        txn: Txn,
1082        call: &CallClause,
1083        guard: &ExecutionGuard<'_>,
1084    ) -> Result<QueryResult, QueryError> {
1085        let empty_row = BindingRow::new();
1086        let (sig, proc_rows) = self.call_procedure(txn, call, &empty_row, guard)?;
1087        let yield_items = call.yield_items.clone().unwrap_or(CallYield::Star);
1088        let columns: Vec<String> = match &yield_items {
1089            CallYield::Star => sig.outputs.clone(),
1090            CallYield::Items(items, _) => items
1091                .iter()
1092                .map(|(name, alias)| alias.clone().unwrap_or_else(|| name.clone()))
1093                .collect(),
1094        };
1095        let mut rows = Vec::with_capacity(proc_rows.len());
1096        for proc_row in &proc_rows {
1097            rows.push(project_call_row(&sig, proc_row, &yield_items)?);
1098        }
1099        if let CallYield::Items(_, Some(where_expr)) = &yield_items {
1100            let mut filtered = Vec::with_capacity(rows.len());
1101            for row_values in &rows {
1102                let mut binding_row = BindingRow::new();
1103                for (col, v) in columns.iter().zip(row_values) {
1104                    binding_row.insert(col.clone(), value_to_binding_restore(v));
1105                }
1106                if self.eval_expr(txn, where_expr, &binding_row, guard)? == Some(true) {
1107                    filtered.push(row_values.clone());
1108                }
1109            }
1110            rows = filtered;
1111        }
1112        Ok(QueryResult {
1113            columns,
1114            rows,
1115            stats: QueryStats::default(),
1116        })
1117    }
1118
1119    /// Shared by `eval_standalone_call` and `QueryClause::Call`'s own
1120    /// in-query handling -- looks up `call.name`'s signature, resolves and
1121    /// type-checks its arguments against `row`'s already-bound variables
1122    /// (explicit args) or `guard.options.params` (the implicit-argument
1123    /// form, `call.args: None`), then invokes the provider. Returns the
1124    /// signature alongside the raw output rows since both callers need it
1125    /// again afterward (`sig.outputs`' names, for `YIELD *`/column
1126    /// naming).
1127    fn call_procedure(
1128        &self,
1129        txn: Txn,
1130        call: &CallClause,
1131        row: &BindingRow,
1132        guard: &ExecutionGuard<'_>,
1133    ) -> Result<(ProcedureSignature, Vec<Vec<Value>>), QueryError> {
1134        // Built-in `db.*` procedures resolve first and are not
1135        // shadowable by an embedder provider -- see
1136        // `builtin_procedures`'s module docs. Args are still evaluated
1137        // (and thereby arity-checked against the empty input list) so
1138        // `CALL db.labels('x')` errors the same way any procedure would.
1139        if let Some(sig) = crate::builtin_procedures::signature(&call.name) {
1140            self.eval_call_args(txn, call, &sig, row, guard)?;
1141            let rows = crate::builtin_procedures::call(txn, &call.name)?;
1142            return Ok((sig, rows));
1143        }
1144        let provider = guard.procedure_provider().ok_or_else(|| {
1145            QueryError::Semantic(format!(
1146                "procedure '{}' not found -- no procedure provider is configured",
1147                call.name
1148            ))
1149        })?;
1150        let sig = provider
1151            .signature(&call.name)
1152            .ok_or_else(|| QueryError::Semantic(format!("procedure '{}' not found", call.name)))?;
1153        let args = self.eval_call_args(txn, call, &sig, row, guard)?;
1154        let rows = provider.call(&call.name, &args)?;
1155        Ok((sig, rows))
1156    }
1157
1158    fn eval_call_args(
1159        &self,
1160        txn: Txn,
1161        call: &CallClause,
1162        sig: &ProcedureSignature,
1163        row: &BindingRow,
1164        guard: &ExecutionGuard<'_>,
1165    ) -> Result<Vec<Value>, QueryError> {
1166        let values: Vec<Value> = match &call.args {
1167            Some(args) => {
1168                if args.len() != sig.inputs.len() {
1169                    return Err(QueryError::Semantic(format!(
1170                        "'{}' expects {} argument(s), got {}",
1171                        call.name,
1172                        sig.inputs.len(),
1173                        args.len()
1174                    )));
1175                }
1176                args.iter()
1177                    .map(|a| self.eval_return_expr(txn, a, row, guard))
1178                    .collect::<Result<_, _>>()?
1179            }
1180            // The implicit-argument form (`CALL proc`, no parens) --
1181            // each declared input resolves from a same-named `$param`
1182            // (TCK's Call1 `[11]`, Call2 `[3]`); missing is a
1183            // `MissingParam`, same error real Cypher's own
1184            // `ParameterMissing`/`MissingParameter` reports.
1185            None => sig
1186                .inputs
1187                .iter()
1188                .map(|input_name| {
1189                    guard
1190                        .options
1191                        .params
1192                        .get(input_name)
1193                        .cloned()
1194                        .map(property_value_to_value)
1195                        .ok_or_else(|| QueryError::MissingParam(input_name.clone()))
1196                })
1197                .collect::<Result<_, _>>()?,
1198        };
1199        for (value, (input_name, declared_type)) in
1200            values.iter().zip(sig.inputs.iter().zip(&sig.input_types))
1201        {
1202            if !value_matches_declared_type(value, declared_type) {
1203                return Err(QueryError::Type(format!(
1204                    "'{}' argument '{input_name}' expects {declared_type}, got {value:?}",
1205                    call.name
1206                )));
1207            }
1208        }
1209        Ok(values)
1210    }
1211
1212    fn execute_create(
1213        &self,
1214        write_txn: &WriteTransaction,
1215        patterns: &[Pattern],
1216        guard: &ExecutionGuard<'_>,
1217    ) -> Result<QueryResult, QueryError> {
1218        // A standalone CREATE is a MATCH...CREATE tail run against a
1219        // single empty row -- `resolve_or_create_node` below never finds
1220        // any variable already bound in an empty `BindingRow`, so every
1221        // node token is fresh, exactly like standalone CREATE always was.
1222        // No trailing RETURN is possible on a standalone `CREATE` statement
1223        // (that's the `MATCH ... CREATE ... RETURN` tail's job instead), so
1224        // the resulting bindings are just discarded here.
1225        self.materialize_create(write_txn, patterns, &[BindingRow::new()], guard)?;
1226        Ok(QueryResult {
1227            columns: vec![],
1228            rows: vec![],
1229            stats: QueryStats::default(),
1230        })
1231    }
1232
1233    /// Runs CREATE patterns once per row in `rows`, returning each row's
1234    /// bindings extended with whatever the CREATE patterns bound (newly
1235    /// created node/edge ids, or the reused id for an already-bound
1236    /// variable) -- this is what lets a trailing `RETURN` after a `MATCH
1237    /// ... CREATE` tail (e.g. `MATCH (a) CREATE (a)-[:R]->(b) RETURN b`)
1238    /// see the newly created `b`. Shared by a standalone `CREATE` statement
1239    /// (`execute_create`, a single empty row, return value discarded -- no
1240    /// RETURN is possible there) and a `MATCH ... CREATE` tail
1241    /// (`execute_match`, rows carry bindings from the preceding
1242    /// MATCH/WITH). The only real difference between the two is what
1243    /// `resolve_or_create_node` finds already bound in a row -- nothing for
1244    /// standalone CREATE, real nodes for a MATCH...CREATE tail, which is
1245    /// what lets the tail form add an edge between two nodes that already
1246    /// exist.
1247    fn materialize_create(
1248        &self,
1249        write_txn: &WriteTransaction,
1250        patterns: &[Pattern],
1251        rows: &[BindingRow],
1252        guard: &ExecutionGuard<'_>,
1253    ) -> Result<Vec<BindingRow>, QueryError> {
1254        let mut out = Vec::with_capacity(rows.len());
1255        for row in rows {
1256            // A variable bound earlier in this same CREATE (an earlier hop,
1257            // or an earlier comma-separated pattern) must be visible to
1258            // later tokens naming it again -- e.g. a self-loop `(a)-[:R]->(a)`
1259            // -- so track newly-created bindings in a local, per-row copy
1260            // instead of just consulting the original incoming `row`.
1261            let mut row = row.clone();
1262            for pattern in patterns {
1263                let mut prev_id =
1264                    self.resolve_or_create_node(write_txn, &pattern.start, &row, guard)?;
1265                if let Some(var) = &pattern.start.var {
1266                    row.insert(var.clone(), Binding::Node(prev_id));
1267                }
1268                for (rel, node) in &pattern.hops {
1269                    if rel.hop_range.is_some() {
1270                        return Err(QueryError::Semantic(
1271                            "CREATE doesn't support variable-length relationship patterns (e.g. [:TYPE*1..3])".into(),
1272                        ));
1273                    }
1274                    let node_id = self.resolve_or_create_node(write_txn, node, &row, guard)?;
1275                    if let Some(var) = &node.var {
1276                        row.insert(var.clone(), Binding::Node(node_id));
1277                    }
1278
1279                    let rel_label = rel.rel_types.first().cloned().expect(
1280                        "CREATE relationship has exactly one type -- checked by \
1281                         semantic::bind_create_pattern",
1282                    );
1283                    let rel_props =
1284                        self.eval_props_to_values(Txn::Write(write_txn), &rel.props, &row, guard)?;
1285                    let (src, dst) = match rel.direction {
1286                        RelDirection::Right => (prev_id, node_id),
1287                        RelDirection::Left => (node_id, prev_id),
1288                        RelDirection::Either => {
1289                            return Err(QueryError::Semantic(
1290                                "CREATE requires a directed relationship (-> or <-), not an undirected pattern".into(),
1291                            ))
1292                        }
1293                    };
1294                    let edge_id =
1295                        GraphStore::create_edge_in_txn(write_txn, &rel_label, src, dst, rel_props)?;
1296                    self.count(|s| s.relationships_created += 1);
1297                    if let Some(var) = &rel.var {
1298                        row.insert(var.clone(), Binding::Edge(edge_id));
1299                    }
1300                    prev_id = node_id;
1301                }
1302            }
1303            out.push(row);
1304        }
1305        Ok(out)
1306    }
1307
1308    /// A node pattern token reuses an existing binding iff it names a
1309    /// variable already bound in `row` (from a preceding MATCH/WITH) --
1310    /// restating labels/props on that token is rejected at compile time
1311    /// (`semantic::check_create_node_not_already_bound`), since silently
1312    /// dropping user-written labels/props would be a correctness trap.
1313    /// Anything else (no variable, or a variable not yet bound in this
1314    /// row) creates a brand-new node, exactly like standalone CREATE
1315    /// always has for every node token.
1316    fn resolve_or_create_node(
1317        &self,
1318        write_txn: &WriteTransaction,
1319        node: &NodePattern,
1320        row: &BindingRow,
1321        guard: &ExecutionGuard<'_>,
1322    ) -> Result<NodeId, QueryError> {
1323        if let Some(var) = &node.var {
1324            if let Some(binding) = row.get(var) {
1325                let Binding::Node(id) = binding else {
1326                    return Err(QueryError::Type(format!(
1327                        "'{var}' is not a node — can't use it as a CREATE pattern endpoint"
1328                    )));
1329                };
1330                // Reusing an already-bound var with new labels/props is
1331                // rejected at compile time (`semantic::check_create_node_
1332                // not_already_bound`) -- unreachable here in practice.
1333                return Ok(*id);
1334            }
1335        }
1336        let labels: Vec<&str> = node.labels.iter().map(String::as_str).collect();
1337        let props = self.eval_props_to_values(Txn::Write(write_txn), &node.props, row, guard)?;
1338        let id = GraphStore::create_node_in_txn(write_txn, &labels, props)?;
1339        self.count(|s| s.nodes_created += 1);
1340        Ok(id)
1341    }
1342
1343    /// Evaluates a CREATE pattern's `{...}` prop map -- each value is any
1344    /// `ReturnExpr` (`self.eval_return_expr`), not just a literal, which
1345    /// is what lets `CREATE (:Val {d: date({year: 1984, ...})})` work
1346    /// (see `cypher.pest`'s `map_expr` docs). `row` is whatever's already
1347    /// bound so far in this same CREATE (earlier hops, earlier
1348    /// comma-separated patterns) -- a prop expression referencing one of
1349    /// those (unusual, but not disallowed) resolves the same as anywhere
1350    /// else `eval_return_expr` runs.
1351    fn eval_props_to_values(
1352        &self,
1353        txn: Txn,
1354        props: &[(String, ReturnExpr)],
1355        row: &BindingRow,
1356        guard: &ExecutionGuard<'_>,
1357    ) -> Result<BTreeMap<String, PropertyValue>, QueryError> {
1358        props
1359            .iter()
1360            .filter_map(|(k, expr)| {
1361                let value = match self.eval_return_expr(txn, expr, row, guard) {
1362                    Ok(v) => v,
1363                    Err(e) => return Some(Err(e)),
1364                };
1365                // `CREATE (n {prop: null})` never actually stores `prop`
1366                // at all in real Cypher -- the same "setting to null
1367                // removes/never-creates the property" rule
1368                // `apply_set_item`'s own `SET n.prop = null` handling
1369                // already has (see its docs), just never applied here
1370                // too. Observable via `keys(n)`/property enumeration
1371                // (TCK's Graph8 [8]) -- a stored `PropertyValue::Null`
1372                // still shows up as a key, where a real missing property
1373                // wouldn't.
1374                if matches!(value, Value::Null) {
1375                    return None;
1376                }
1377                let pv = match value_to_storable_property(&value).ok_or_else(|| {
1378                    QueryError::Type(format!(
1379                        "property '{k}' can't be stored -- MarsDB's node/edge properties are limited to null/\
1380                         bool/int/float/string/date/duration; a list/map/node/edge/path value (got {value:?}) \
1381                         isn't storable, matching PropertyValue's real, deliberately fixed set of variants (see \
1382                         its doc comment)"
1383                    ))
1384                }) {
1385                    Ok(pv) => pv,
1386                    Err(e) => return Some(Err(e)),
1387                };
1388                Some(Ok((k.clone(), pv)))
1389            })
1390            .collect()
1391    }
1392
1393    /// Runs `MERGE` once per row in `rows` (`clause.pattern.hops.len() <=
1394    /// 1`, enforced at parse time — whole-pattern atomicity across
1395    /// multiple simultaneously-unbound hops isn't attempted in v1: which
1396    /// hop's "not found" should trigger creation of what, in what order,
1397    /// gets genuinely hard to reason about correctly for longer chains).
1398    fn eval_merge(
1399        &self,
1400        write_txn: &WriteTransaction,
1401        clause: &MergeClause,
1402        rows: &[BindingRow],
1403        guard: &ExecutionGuard<'_>,
1404    ) -> Result<Vec<BindingRow>, QueryError> {
1405        let mut out = Vec::new();
1406        for row in rows {
1407            guard.checkpoint()?;
1408            out.extend(self.merge_one_row(write_txn, clause, row, guard)?);
1409            guard.check_intermediate_rows(out.len())?;
1410        }
1411        self.apply_merge_set(write_txn, clause, &mut out, guard)?;
1412        Ok(out)
1413    }
1414
1415    /// Whether any property expression across `clause.pattern` (the
1416    /// start node, and every hop's relationship + node) evaluates to
1417    /// null for this row -- see `merge_one_row`'s call site for why
1418    /// that's always a real error, never a value MERGE can act on.
1419    fn merge_pattern_has_null_property(
1420        &self,
1421        txn: Txn,
1422        clause: &MergeClause,
1423        row: &BindingRow,
1424        guard: &ExecutionGuard<'_>,
1425    ) -> Result<bool, QueryError> {
1426        let any_null = |props: &[(String, ReturnExpr)]| -> Result<bool, QueryError> {
1427            for (_, expr) in props {
1428                if matches!(self.eval_return_expr(txn, expr, row, guard)?, Value::Null) {
1429                    return Ok(true);
1430                }
1431            }
1432            Ok(false)
1433        };
1434        if any_null(&clause.pattern.start.props)? {
1435            return Ok(true);
1436        }
1437        for (rel, node) in &clause.pattern.hops {
1438            if any_null(&rel.props)? || any_null(&node.props)? {
1439                return Ok(true);
1440            }
1441        }
1442        Ok(false)
1443    }
1444
1445    fn merge_one_row(
1446        &self,
1447        write_txn: &WriteTransaction,
1448        clause: &MergeClause,
1449        row: &BindingRow,
1450        guard: &ExecutionGuard<'_>,
1451    ) -> Result<Vec<BindingRow>, QueryError> {
1452        // The bare-already-bound-start and reused-relationship-variable
1453        // cases are rejected at compile time (`semantic::bind_merge`),
1454        // not only here -- a zero-row MATCH would otherwise skip both
1455        // entirely even though real Cypher's `VariableAlreadyBound` is a
1456        // structural/scope error, not a data-dependent one. A completely
1457        // unconstrained, unbound token (bare `MERGE (a)`, no label/
1458        // property) is real, valid Cypher -- searches for/creates any
1459        // node with no constraints at all (TCK's Merge1 [1]), not an
1460        // error; an earlier version of this codebase treated it as an
1461        // "ambiguous shape" mistake to reject, which real Cypher's own
1462        // TCK disproves.
1463        for (rel, _node) in &clause.pattern.hops {
1464            if rel.hop_range.is_some() {
1465                return Err(QueryError::Semantic(
1466                    "MERGE doesn't support variable-length relationship patterns (e.g. [:TYPE*1..3])".into(),
1467                ));
1468            }
1469        }
1470        // `MERGE p = ...` -- give every anonymous token in the pattern a
1471        // synthetic name first (same convention ordinary MATCH's own
1472        // named-path capture uses, see `execute_match`'s `QueryClause::
1473        // Match` arm), so `assemble_path` below has a real row binding to
1474        // read at every position regardless of whether the user wrote one
1475        // -- then strip those synthetic keys back out before this row
1476        // becomes visible to the rest of the query. A no-`path_var` MERGE
1477        // clones `clause.pattern` once here rather than working with it
1478        // by reference throughout, so this function has exactly one
1479        // pattern to work from either way.
1480        let (pattern, synthesized) = if clause.path_var.is_some() {
1481            name_pattern_for_path(&clause.pattern)
1482        } else {
1483            (clause.pattern.clone(), HashSet::new())
1484        };
1485        let pattern = &pattern;
1486        // A MERGE pattern's own inline `{...}` property evaluating to
1487        // null can never be searched-or-created consistently: a null
1488        // property is never equal to anything (so the search half can
1489        // never find a node/edge that "has" it), but storing a
1490        // property as null is equivalent to not storing it at all (see
1491        // `apply_set_item`'s own SET-to-null convention) -- so the
1492        // create half would silently produce something that doesn't
1493        // structurally match the pattern that created it. Real Cypher's
1494        // MergeReadOwnWrites error, checked once per row (a property
1495        // expression can reference this row's other bindings, e.g.
1496        // `MERGE (n {x: m.missing})`).
1497        if self.merge_pattern_has_null_property(Txn::Write(write_txn), clause, row, guard)? {
1498            return Err(QueryError::Semantic(
1499                "MERGE pattern property is null — a MERGE's own {...} properties can never be \
1500                 null (searching for null never matches anything, but storing null is the same \
1501                 as not storing the property at all)"
1502                    .into(),
1503            ));
1504        }
1505
1506        // Try the pattern as an ordinary MATCH first. Whatever's already
1507        // bound in `row` (e.g. `a` from a preceding MATCH) becomes a Seed,
1508        // not a fresh scan — build_match_plan already knows how to do
1509        // this, the same mechanism every ordinary MATCH clause uses. For a
1510        // one-hop pattern this already searches the *connected*
1511        // sub-pattern (Expand from the resolved source, Filter by the
1512        // target's own constraints), not each node independently — which
1513        // is exactly the correctness property MERGE needs and gets for
1514        // free by reusing this instead of inventing bespoke search logic.
1515        let carried_vars: HashSet<String> = row.keys().cloned().collect();
1516        let plan = apply_index_seeks(
1517            build_match_plan(pattern, &None, &carried_vars)?,
1518            Txn::Write(write_txn),
1519        )?;
1520        let found = self.eval_plan(
1521            Txn::Write(write_txn),
1522            &plan,
1523            std::slice::from_ref(row),
1524            guard,
1525        )?;
1526        if !found.is_empty() {
1527            return Ok(found
1528                .into_iter()
1529                .map(|mut r| {
1530                    if let Some(path_var) = &clause.path_var {
1531                        let path_binding = assemble_path(pattern, &r);
1532                        for key in &synthesized {
1533                            r.remove(key);
1534                        }
1535                        r.insert(path_var.clone(), path_binding);
1536                    }
1537                    tag_merge_created(r, false)
1538                })
1539                .collect());
1540        }
1541
1542        // Nothing found — create exactly one new instance. Reuses
1543        // resolve_or_create_node, the same "reuse if the token's var is
1544        // already bound in the row, else create fresh" logic
1545        // Tail::Create/materialize_create already use.
1546        let mut new_row = row.clone();
1547        let start_id = self.resolve_or_create_node(write_txn, &pattern.start, &new_row, guard)?;
1548        if let Some(var) = &pattern.start.var {
1549            new_row.insert(var.clone(), Binding::Node(start_id));
1550        }
1551        // At most one hop (enforced at parse time) -- a plain `if let`,
1552        // not a loop, so there's no dangling "previous node" state to
1553        // thread once a 2nd+ hop is ever supported.
1554        if let Some((rel, node)) = pattern.hops.first() {
1555            let node_id = self.resolve_or_create_node(write_txn, node, &new_row, guard)?;
1556            if let Some(var) = &node.var {
1557                new_row.insert(var.clone(), Binding::Node(node_id));
1558            }
1559            let rel_label = rel.rel_types.first().cloned().expect(
1560                "MERGE relationship has exactly one type -- checked by semantic::bind_merge",
1561            );
1562            let rel_props =
1563                self.eval_props_to_values(Txn::Write(write_txn), &rel.props, &new_row, guard)?;
1564            // An undirected pattern (`-[r]-`) with nothing to match
1565            // defaults to an outgoing relationship when creating -- real
1566            // Cypher's own rule (TCK's Merge5 [11], "Use outgoing
1567            // direction when unspecified").
1568            let (src, dst) = match rel.direction {
1569                RelDirection::Right | RelDirection::Either => (start_id, node_id),
1570                RelDirection::Left => (node_id, start_id),
1571            };
1572            let edge_id =
1573                GraphStore::create_edge_in_txn(write_txn, &rel_label, src, dst, rel_props)?;
1574            self.count(|s| s.relationships_created += 1);
1575            if let Some(var) = &rel.var {
1576                new_row.insert(var.clone(), Binding::Edge(edge_id));
1577            }
1578        }
1579        if let Some(path_var) = &clause.path_var {
1580            let path_binding = assemble_path(pattern, &new_row);
1581            for key in &synthesized {
1582                new_row.remove(key);
1583            }
1584            new_row.insert(path_var.clone(), path_binding);
1585        }
1586        Ok(vec![tag_merge_created(new_row, true)])
1587    }
1588
1589    /// Applies `ON CREATE SET`/`ON MATCH SET` to the right rows (matching
1590    /// real Cypher semantics exactly: `ON CREATE` fires whenever anything
1591    /// in the pattern was newly created, `ON MATCH` only when the whole
1592    /// pattern already existed as-is — the single per-row
1593    /// `MERGE_CREATED_KEY` tag is the correct model for this, not a
1594    /// simplification of it — see `eval_optional_part`'s
1595    /// `OPTIONAL_SEED_IDX_KEY` for the same hidden-tag precedent), then
1596    /// strips the tag before the rows become visible to the rest of the
1597    /// query.
1598    fn apply_merge_set(
1599        &self,
1600        write_txn: &WriteTransaction,
1601        clause: &MergeClause,
1602        rows: &mut [BindingRow],
1603        guard: &ExecutionGuard<'_>,
1604    ) -> Result<(), QueryError> {
1605        for row in rows.iter_mut() {
1606            let created = match row.remove(MERGE_CREATED_KEY) {
1607                Some(Binding::Value(PropertyValue::Bool(b))) => b,
1608                other => unreachable!(
1609                    "{MERGE_CREATED_KEY} tagged internally as Binding::Value(Bool), got {other:?}"
1610                ),
1611            };
1612            let items = if created {
1613                &clause.on_create
1614            } else {
1615                &clause.on_match
1616            };
1617            for item in items {
1618                self.apply_set_item(Txn::Write(write_txn), write_txn, row, item, guard)?;
1619            }
1620        }
1621        Ok(())
1622    }
1623
1624    fn execute_match(
1625        &self,
1626        txn: Txn,
1627        clauses: &[QueryClause],
1628        tail: &Option<Tail>,
1629        modifiers: ResultModifiers<'_>,
1630        guard: &ExecutionGuard<'_>,
1631    ) -> Result<QueryResult, QueryError> {
1632        self.execute_match_seeded(txn, clauses, tail, modifiers, None, guard)
1633    }
1634
1635    /// `execute_match`'s general form -- `seed` is `None` for an ordinary
1636    /// top-level statement (nothing carried in, same as `execute_match`'s
1637    /// old fixed behavior) or `Some(row)` for a correlated `exists { MATCH
1638    /// ... RETURN ... }` subquery (`eval_exists_subquery`): the outer row's
1639    /// own bindings become this statement's starting `current_rows`/
1640    /// `carried_vars`, so a pattern referencing an outer-bound name (`(n)
1641    /// -->(m)` where `n` is already bound) seeds from it (`LogicalPlan::
1642    /// Seed`) instead of scanning fresh, exactly like a later clause in an
1643    /// ordinary multi-clause statement already does with an earlier
1644    /// clause's bindings.
1645    fn execute_match_seeded(
1646        &self,
1647        txn: Txn,
1648        clauses: &[QueryClause],
1649        tail: &Option<Tail>,
1650        modifiers: ResultModifiers<'_>,
1651        seed: Option<&BindingRow>,
1652        guard: &ExecutionGuard<'_>,
1653    ) -> Result<QueryResult, QueryError> {
1654        let ResultModifiers {
1655            order_by,
1656            skip,
1657            limit,
1658        } = modifiers;
1659        // Threads bindings through each MATCH/UNWIND/WITH clause.
1660        // `carried_vars` tells the planner which of the next MATCH clause's
1661        // pattern variables are already bound (-> LogicalPlan::Seed) rather
1662        // than fresh (-> a scan). Starts empty (except for `seed`'s own
1663        // vars, if any): the first clause never has anything else carried
1664        // into it.
1665        let mut carried_vars: HashSet<String> = match seed {
1666            Some(row) => row.keys().cloned().collect(),
1667            None => HashSet::new(),
1668        };
1669        let mut current_rows: Vec<BindingRow> = vec![seed.cloned().unwrap_or_default()];
1670        // A plain, non-blocking RETURN can stop the final MATCH pipeline as
1671        // soon as SKIP+LIMIT rows have arrived (SKIP rows still have to
1672        // physically flow through the pipeline to be counted and dropped
1673        // below -- only the *count* the stream stops at grows, not
1674        // anything about what SKIP itself does). ORDER BY, DISTINCT,
1675        // aggregation, mutations, and WITH must still consume/materialize
1676        // their complete input before applying a final limit.
1677        let final_stream_limit = match (order_by, limit, tail) {
1678            (None, Some(limit), Some(Tail::Return(items, false))) if !has_aggregate(items) => {
1679                Some(skip.unwrap_or(0).max(0) as usize + limit.max(0) as usize)
1680            }
1681            _ => None,
1682        };
1683        for (clause_index, clause) in clauses.iter().enumerate() {
1684            let is_final_clause = clause_index + 1 == clauses.len();
1685            match clause {
1686                QueryClause::Match(part) => {
1687                    let plan_limit = is_final_clause
1688                        .then_some(final_stream_limit)
1689                        .flatten()
1690                        .filter(|_| !part.shortest_path && !part.optional && part.with.is_none());
1691                    current_rows = if part.shortest_path {
1692                        // Not a LogicalPlan/eval_plan traversal at all —
1693                        // see eval_shortest_path's docs.
1694                        self.eval_shortest_path(txn, part, &current_rows, guard)?
1695                    } else if let Some(path_var) = &part.path_var {
1696                        let (named_pattern, synthesized) = name_pattern_for_path(&part.pattern);
1697                        // A named path's own inline `WHERE` can reference
1698                        // the path variable itself (`WHERE length(p) =
1699                        // 1`, TCK's MatchWhere1 `[12]`/`[13]`) -- `p`
1700                        // isn't in the row until *after* `assemble_path`
1701                        // below, so (for a plain, non-`OPTIONAL` MATCH)
1702                        // it can't be pushed into the plan the way an
1703                        // ordinary pattern's `WHERE` is; applied as a
1704                        // post-filter instead, once every row really has
1705                        // `p`. `OPTIONAL MATCH` still pushes it into the
1706                        // plan -- its own null-padding semantics need the
1707                        // filter fused into the "did this seed row match
1708                        // anything" check `eval_optional_part` does, and
1709                        // a `WHERE` referencing `p` there is a narrower,
1710                        // untested-by-the-TCK edge case left as-is.
1711                        let defer_where = !part.optional && part.where_clause.is_some();
1712                        let plan_where = if defer_where {
1713                            &None
1714                        } else {
1715                            &part.where_clause
1716                        };
1717                        let plan = apply_index_seeks(
1718                            build_match_plan(&named_pattern, plan_where, &carried_vars)?,
1719                            txn,
1720                        )?;
1721                        let mut rows = if part.optional {
1722                            let new_vars = pattern_new_vars(&named_pattern, &carried_vars);
1723                            self.eval_optional_part(txn, &plan, &current_rows, &new_vars, guard)?
1724                        } else {
1725                            // `plan_limit`'s own early-stop assumes every
1726                            // emitted row is already a real, final row --
1727                            // not true when the WHERE filter above got
1728                            // deferred (a limited prefix could still get
1729                            // filtered further below), so it's skipped
1730                            // for that case (limiting instead happens
1731                            // naturally via the smaller `rows` this
1732                            // clause returns).
1733                            let limit = plan_limit.filter(|_| !defer_where);
1734                            self.eval_plan_with_limit(txn, &plan, &current_rows, guard, limit)?
1735                        };
1736                        for row in &mut rows {
1737                            let path_binding = assemble_path(&named_pattern, row);
1738                            for key in &synthesized {
1739                                row.remove(key);
1740                            }
1741                            row.insert(path_var.clone(), path_binding);
1742                        }
1743                        if defer_where {
1744                            let where_clause = part
1745                                .where_clause
1746                                .as_ref()
1747                                .expect("defer_where implies where_clause is Some");
1748                            let mut filtered = Vec::with_capacity(rows.len());
1749                            for row in rows {
1750                                if self.eval_expr(txn, where_clause, &row, guard)? == Some(true) {
1751                                    filtered.push(row);
1752                                }
1753                            }
1754                            rows = filtered;
1755                        }
1756                        rows
1757                    } else if let Some(plan) = if part.optional {
1758                        // OPTIONAL MATCH needs eval_optional_part's
1759                        // null-padding semantics -- never the sweep.
1760                        None
1761                    } else {
1762                        plan_edge_scan(&part.pattern, &part.where_clause, &carried_vars, txn)?
1763                    } {
1764                        // Whole single-hop pattern bound by one sequential
1765                        // EDGES sweep -- see plan_edge_scan's cost gate.
1766                        // No fast-path/tail-hint interplay: the sweep is
1767                        // already the fast path for this shape.
1768                        self.eval_plan_with_limit(txn, &plan, &current_rows, guard, plan_limit)?
1769                    } else {
1770                        // Start-point selection: walk the pattern from its
1771                        // cheaper endpoint (see `plan_reversed_pattern`).
1772                        // Only this plain branch — a named path or
1773                        // shortestPath exposes traversal order, and MERGE's
1774                        // match phase stays as-written.
1775                        let reversed = plan_reversed_pattern(
1776                            &part.pattern,
1777                            &part.where_clause,
1778                            &carried_vars,
1779                            txn,
1780                        )?;
1781                        let pattern = reversed.as_ref().unwrap_or(&part.pattern);
1782                        let plan = apply_index_seeks(
1783                            build_match_plan(pattern, &part.where_clause, &carried_vars)?,
1784                            txn,
1785                        )?;
1786                        if part.optional {
1787                            let new_vars = pattern_new_vars(&part.pattern, &carried_vars);
1788                            self.eval_optional_part(txn, &plan, &current_rows, &new_vars, guard)?
1789                        } else {
1790                            // Aggregating-expansion fast path: when the
1791                            // plan+WITH match the counted-double-expand
1792                            // shape, the tight loop replaces BOTH the row
1793                            // materialization and the WITH's own grouping
1794                            // pass — so on a hit, this clause is done.
1795                            let tail_hint = if is_final_clause {
1796                                match (order_by, limit, tail) {
1797                                    (
1798                                        Some(keys),
1799                                        Some(tail_limit),
1800                                        Some(Tail::Return(items, false)),
1801                                    ) if keys.len() == 1 && !has_aggregate(items) => {
1802                                        let (key, dir) = &keys[0];
1803                                        Some((
1804                                            key,
1805                                            *dir,
1806                                            skip.unwrap_or(0).max(0) as usize
1807                                                + tail_limit.max(0) as usize,
1808                                        ))
1809                                    }
1810                                    _ => None,
1811                                }
1812                            } else {
1813                                None
1814                            };
1815                            if let Some((rows, out_names)) = self.try_fast_expand_expand_count(
1816                                txn,
1817                                &plan,
1818                                &part.with,
1819                                &current_rows,
1820                                tail_hint,
1821                                guard,
1822                            )? {
1823                                current_rows = rows;
1824                                carried_vars = out_names;
1825                                continue;
1826                            }
1827                            self.eval_plan_with_limit(txn, &plan, &current_rows, guard, plan_limit)?
1828                        }
1829                    };
1830                    let mut new_vars = pattern_all_vars(&part.pattern);
1831                    if let Some(path_var) = &part.path_var {
1832                        new_vars.insert(path_var.clone());
1833                    }
1834                    current_rows = self.apply_with_or_carry(
1835                        txn,
1836                        &part.with,
1837                        current_rows,
1838                        new_vars,
1839                        &mut carried_vars,
1840                        guard,
1841                    )?;
1842                }
1843                QueryClause::Unwind(u) => {
1844                    current_rows = self.eval_unwind(txn, u, &current_rows, guard)?;
1845                    current_rows = self.apply_with_or_carry(
1846                        txn,
1847                        &u.with,
1848                        current_rows,
1849                        HashSet::from([u.var.clone()]),
1850                        &mut carried_vars,
1851                        guard,
1852                    )?;
1853                }
1854                QueryClause::Call(call) => {
1855                    current_rows = self.eval_call_clause(txn, call, &current_rows, guard)?;
1856                    let new_vars: HashSet<String> = match &call.yield_items {
1857                        Some(CallYield::Items(items, _)) => items
1858                            .iter()
1859                            .map(|(name, alias)| alias.clone().unwrap_or_else(|| name.clone()))
1860                            .collect(),
1861                        // `Star` never reaches here (`queryCallSt`'s own
1862                        // grammar has no `YIELD *` alternative) and `None`
1863                        // binds nothing new.
1864                        Some(CallYield::Star) | None => HashSet::new(),
1865                    };
1866                    current_rows = self.apply_with_or_carry(
1867                        txn,
1868                        &call.with,
1869                        current_rows,
1870                        new_vars,
1871                        &mut carried_vars,
1872                        guard,
1873                    )?;
1874                }
1875                QueryClause::Merge(m) => {
1876                    // MERGE always needs real `.insert`-capable write
1877                    // access, whether or not the rest of the statement
1878                    // would otherwise be read-only (e.g. `MERGE (n) RETURN
1879                    // n`) — see `is_read_only`, which already accounts for
1880                    // this by checking `clauses` too, so `txn` is
1881                    // guaranteed to be `Txn::Write` here.
1882                    let write_txn = require_write_txn(txn);
1883                    current_rows = self.eval_merge(write_txn, m, &current_rows, guard)?;
1884                    let mut new_vars = pattern_all_vars(&m.pattern);
1885                    if let Some(path_var) = &m.path_var {
1886                        new_vars.insert(path_var.clone());
1887                    }
1888                    current_rows = self.apply_with_or_carry(
1889                        txn,
1890                        &m.with,
1891                        current_rows,
1892                        new_vars,
1893                        &mut carried_vars,
1894                        guard,
1895                    )?;
1896                }
1897                // A statement-leading WITH -- no pattern was matched, so
1898                // there's nothing to seed `new_vars` with beyond what the
1899                // WITH clause itself projects (`apply_with_or_carry`
1900                // always takes the `Some(with)` branch here, never the
1901                // "no WITH, just extend carried_vars" one, since `with` is
1902                // always present on this variant by construction).
1903                QueryClause::With(with) => {
1904                    current_rows = self.apply_with_or_carry(
1905                        txn,
1906                        &Some(with.clone()),
1907                        current_rows,
1908                        HashSet::new(),
1909                        &mut carried_vars,
1910                        guard,
1911                    )?;
1912                }
1913                // `SET ... WITH ...` -- same real `.set_*_prop_in_txn`
1914                // write access `materialize_set`'s own per-row loop
1915                // already needs (guaranteed `Txn::Write` here for the
1916                // same reason its own docs give). Doesn't change any
1917                // row's bindings, only mutates the underlying graph --
1918                // `current_rows`/`carried_vars` both pass through
1919                // unchanged, the following `clause` (always a `WITH`,
1920                // see `set_as_clause`'s grammar) handles its own
1921                // projection/`WHERE`/`ORDER BY` normally from there.
1922                QueryClause::Set(items) => {
1923                    let write_txn = require_write_txn(txn);
1924                    for row in &current_rows {
1925                        for item in items {
1926                            self.apply_set_item(txn, write_txn, row, item, guard)?;
1927                        }
1928                    }
1929                }
1930                // `DELETE/DETACH DELETE ... WITH ...` -- same passthrough
1931                // reasoning as `QueryClause::Set` above (see
1932                // `delete_as_clause`'s grammar docs). Reuses the same
1933                // `delete_binding`/`delete_value` helpers `materialize_delete`
1934                // itself calls.
1935                QueryClause::Delete { items, detach } => {
1936                    let write_txn = require_write_txn(txn);
1937                    self.delete_targets(txn, write_txn, items, &current_rows, *detach, guard)?;
1938                }
1939                // `REMOVE ... WITH ...` -- same passthrough reasoning as
1940                // `QueryClause::Set` above (see `remove_as_clause`'s
1941                // grammar docs).
1942                QueryClause::Remove(items) => {
1943                    let write_txn = require_write_txn(txn);
1944                    for row in &current_rows {
1945                        for item in items {
1946                            apply_remove_item(self, write_txn, row, item)?;
1947                        }
1948                    }
1949                }
1950                // `CREATE ... WITH ...` -- unlike Set/Delete/Remove above,
1951                // this DOES change every row's bindings (each pattern's
1952                // own fresh/reused vars), so `current_rows` is replaced,
1953                // not passed through, and `carried_vars` is extended
1954                // directly (no bundled `.with` field on this variant to
1955                // route through `apply_with_or_carry` the way `Merge`
1956                // does above -- the following `WITH` is its own separate
1957                // `QueryClause::With` entry, picked up by this same loop's
1958                // next iteration, which needs `carried_vars` to already
1959                // reflect these new names by then).
1960                QueryClause::Create(patterns) => {
1961                    let write_txn = require_write_txn(txn);
1962                    current_rows =
1963                        self.materialize_create(write_txn, patterns, &current_rows, guard)?;
1964                    carried_vars.extend(patterns.iter().flat_map(pattern_all_vars));
1965                }
1966            }
1967            guard.check_intermediate_rows(current_rows.len())?;
1968        }
1969        // ORDER BY must see every matching row before LIMIT truncates —
1970        // sort, then take N, not the other way around. Only pre-truncate
1971        // (the v1 "doesn't short-circuit" path) when there's no ORDER BY to
1972        // invalidate it; DELETE/SET+LIMIT keep their "stop after N
1973        // bindings" behavior since they have no ORDER BY position in the
1974        // grammar. RETURN DISTINCT is excluded too, same reasoning as
1975        // ORDER BY: DISTINCT can still drop rows *after* this point, so
1976        // pre-truncating the raw input here could return fewer than
1977        // `limit` distinct rows even when more exist -- its LIMIT gets
1978        // applied after dedup instead, below.
1979        let distinct_return = tail_is_distinct_return(tail);
1980        if order_by.is_none() && !distinct_return {
1981            let skip_n = skip.unwrap_or(0).max(0) as usize;
1982            if skip_n > 0 {
1983                current_rows.drain(0..skip_n.min(current_rows.len()));
1984            }
1985            if let Some(count) = limit {
1986                current_rows.truncate(count.max(0) as usize);
1987            }
1988        }
1989        // Delete/Set need real `.insert`/`.remove`-capable write access,
1990        // not just `Txn`'s read-only `get`/`iter` — but they're only ever
1991        // reached via `Executor::execute`'s write-dispatch path (see
1992        // `is_read_only`), which always opens a `WriteTransaction`, so
1993        // `txn` is guaranteed to be `Txn::Write` here.
1994        // A non-aggregating RETURN's ORDER BY can reference either a
1995        // RETURN-introduced alias (`RETURN friend.id AS friendId ORDER BY
1996        // friendId`) or a variable still in scope that isn't returned at
1997        // all (`RETURN n.num AS prop ORDER BY n.num` — `n` itself never
1998        // appears in the RETURN list) — real Cypher allows both. Sorting
1999        // needs both the pre-projection bindings *and* the post-projection
2000        // output columns available at once, so it happens after
2001        // `materialize_return`, against a combined view of the two (see
2002        // `apply_order_by_with_scope`) rather than either alone. The
2003        // aggregating case can't use pre-projection bindings at all
2004        // (grouping has already collapsed the per-row bindings by then), so
2005        // it keeps sorting the post-projection output alone via
2006        // `apply_order_by`, further down.
2007        let mut order_by_pre_applied = false;
2008        let mut result = match tail {
2009            // A missing tail only ever occurs with a MERGE clause and
2010            // nothing after it — a pure write, same empty result shape
2011            // standalone CREATE already returns (not one blank row per
2012            // `current_rows`, which a synthetic `Tail::Return(vec![])`
2013            // would produce instead).
2014            None => QueryResult {
2015                columns: vec![],
2016                rows: vec![],
2017                stats: QueryStats::default(),
2018            },
2019            Some(Tail::Return(items, distinct)) => {
2020                if let Some(ob) = order_by {
2021                    // DISTINCT (like aggregation) can drop rows, breaking
2022                    // the 1:1 correspondence `apply_order_by_with_scope`
2023                    // needs between `current_rows` and the projected
2024                    // output -- ORDER BY after DISTINCT can only sort the
2025                    // post-projection, post-dedup result, same as the
2026                    // aggregating case just below.
2027                    if !has_aggregate(items) && !distinct {
2028                        let projected =
2029                            self.materialize_return(txn, items, &current_rows, *distinct, guard)?;
2030                        order_by_pre_applied = true;
2031                        self.apply_order_by_with_scope(
2032                            txn,
2033                            &current_rows,
2034                            projected,
2035                            ob,
2036                            skip,
2037                            limit,
2038                        )?
2039                    } else if !distinct {
2040                        order_by_pre_applied = true;
2041                        self.materialize_aggregating_return_with_order(
2042                            txn,
2043                            items,
2044                            &current_rows,
2045                            ob,
2046                            (skip, limit),
2047                            guard,
2048                        )?
2049                    } else {
2050                        self.materialize_return(txn, items, &current_rows, *distinct, guard)?
2051                    }
2052                } else {
2053                    self.materialize_return(txn, items, &current_rows, *distinct, guard)?
2054                }
2055            }
2056            Some(Tail::ReturnStar(distinct)) => {
2057                let items = return_star_items(carried_vars.iter().cloned())?;
2058                let projected =
2059                    self.materialize_return(txn, &items, &current_rows, *distinct, guard)?;
2060                if let Some(ob) = order_by {
2061                    if !distinct {
2062                        order_by_pre_applied = true;
2063                        self.apply_order_by_with_scope(
2064                            txn,
2065                            &current_rows,
2066                            projected,
2067                            ob,
2068                            skip,
2069                            limit,
2070                        )?
2071                    } else {
2072                        projected
2073                    }
2074                } else {
2075                    projected
2076                }
2077            }
2078            Some(Tail::Delete(vars, ret)) => {
2079                self.materialize_delete(txn, vars, &current_rows, false, ret, guard)?
2080            }
2081            Some(Tail::DetachDelete(vars, ret)) => {
2082                self.materialize_delete(txn, vars, &current_rows, true, ret, guard)?
2083            }
2084            Some(Tail::Set(items, ret)) => {
2085                self.materialize_set(txn, items, &current_rows, ret, guard)?
2086            }
2087            Some(Tail::Remove(items, ret)) => {
2088                self.materialize_remove(txn, items, &current_rows, ret, guard)?
2089            }
2090            Some(Tail::Create(patterns, ret)) => {
2091                let updated_rows = self.materialize_create(
2092                    require_write_txn(txn),
2093                    patterns,
2094                    &current_rows,
2095                    guard,
2096                )?;
2097                match ret {
2098                    Some(rt) => {
2099                        self.materialize_return(txn, &rt.items, &updated_rows, rt.distinct, guard)?
2100                    }
2101                    None => QueryResult {
2102                        columns: vec![],
2103                        rows: vec![],
2104                        stats: QueryStats::default(),
2105                    },
2106                }
2107            }
2108        };
2109        if let Some(order_by) = order_by {
2110            if !order_by_pre_applied {
2111                let tail_items: Option<&[ReturnItem]> = match tail {
2112                    Some(Tail::Return(items, _)) => Some(items),
2113                    _ => None,
2114                };
2115                result.rows = apply_order_by(
2116                    result.rows,
2117                    &result.columns,
2118                    order_by,
2119                    tail_items,
2120                    skip,
2121                    limit,
2122                )?;
2123            }
2124        } else if distinct_return {
2125            // The pre-truncate above was skipped for exactly this case --
2126            // apply SKIP/LIMIT now, after materialize_return's dedup,
2127            // instead.
2128            let skip_n = skip.unwrap_or(0).max(0) as usize;
2129            if skip_n > 0 {
2130                result.rows.drain(0..skip_n.min(result.rows.len()));
2131            }
2132            if let Some(count) = limit {
2133                result.rows.truncate(count.max(0) as usize);
2134            }
2135        }
2136        guard.check_result_rows(result.rows.len())?;
2137        Ok(result)
2138    }
2139
2140    /// Applies a clause's optional trailing `WITH` (shared by both
2141    /// `QueryClause::Match` and `QueryClause::Unwind`, which can each end
2142    /// in one — see `QueryClause`'s docs), or, with no `WITH`, grows
2143    /// `carried_vars` by `new_vars` so the next clause shares this one's
2144    /// binding scope — same "no WITH means stay in scope" rule `OPTIONAL
2145    /// MATCH` already gets, now uniform across clause kinds.
2146    fn apply_with_or_carry(
2147        &self,
2148        txn: Txn,
2149        with: &Option<WithClause>,
2150        rows: Vec<BindingRow>,
2151        new_vars: HashSet<String>,
2152        carried_vars: &mut HashSet<String>,
2153        guard: &ExecutionGuard<'_>,
2154    ) -> Result<Vec<BindingRow>, QueryError> {
2155        let Some(with) = with else {
2156            carried_vars.extend(new_vars);
2157            return Ok(rows);
2158        };
2159        // `WITH *` -- expand to every name already carried into this
2160        // clause *plus* whatever this same clause's own pattern just
2161        // bound (`new_vars`, e.g. MERGE's own target -- `carried_vars`
2162        // alone wouldn't have that yet, since it's only ever updated at
2163        // this function's very end). `with_owned` only exists to give
2164        // the rest of this function a `&WithClause` with `items` already
2165        // containing the expanded names, without touching any of its
2166        // other fields (`order_by`/`skip`/`limit`/`distinct`/
2167        // `where_clause` all stay exactly as parsed).
2168        let with_owned;
2169        let with: &WithClause = if with.star {
2170            // A `HashSet` union, not a plain chain -- `new_vars` can
2171            // legitimately overlap with `carried_vars` (e.g. `MATCH (a)
2172            // MERGE (a)-[:R]->(b)` reuses the already-bound `a`), and a
2173            // raw chain would double it up into two identical columns.
2174            let star_items = with_star_items(carried_vars.union(&new_vars).cloned());
2175            let mut owned = with.clone();
2176            let mut items = star_items;
2177            items.extend(owned.items);
2178            owned.items = items;
2179            with_owned = owned;
2180            &with_owned
2181        } else {
2182            with
2183        };
2184        let with_skip = self.resolve_skip_limit(txn, with.skip.as_ref(), "SKIP", guard)?;
2185        let with_limit = self.resolve_skip_limit(txn, with.limit.as_ref(), "LIMIT", guard)?;
2186        let rows = if let Some(with_order_by) = with
2187            .order_by
2188            .as_ref()
2189            .filter(|_| has_aggregate(&with.items))
2190        {
2191            // `materialize_aggregating_with_with_order` folds its own
2192            // extra composed ORDER BY keys through the same grouping pass
2193            // as `with.items` -- also covers `with.distinct` correctly
2194            // without any extra handling here, since grouping already
2195            // makes every output row unique by its own grouping-key
2196            // columns (see that function's `RETURN`-side twin's own docs
2197            // on why that makes `DISTINCT` a no-op downstream of
2198            // aggregation).
2199            self.materialize_aggregating_with_with_order(
2200                txn,
2201                &with.items,
2202                &rows,
2203                with_order_by,
2204                (with_skip, with_limit),
2205                guard,
2206            )?
2207        } else {
2208            // Only cloned when actually needed below (ORDER BY on a
2209            // non-aggregating, non-`DISTINCT` WITH) -- avoids the copy on
2210            // every other WITH shape.
2211            let pre_with_rows = (with.order_by.is_some() && !with.distinct).then(|| rows.clone());
2212            let mut rows = self.materialize_with(txn, with, &rows, guard)?;
2213            if let Some(with_order_by) = &with.order_by {
2214                // Only a non-aggregating, non-`DISTINCT` WITH keeps a 1:1
2215                // row correspondence with its pre-WITH input -- see
2216                // `apply_order_by_bindings`'s own docs on why that's
2217                // exactly when ORDER BY can also see the pre-WITH scope.
2218                rows = self.apply_order_by_bindings(
2219                    txn,
2220                    rows,
2221                    pre_with_rows.as_deref(),
2222                    &with.items,
2223                    with_order_by,
2224                    (with_skip, with_limit),
2225                )?;
2226            } else {
2227                let skip_n = with_skip.unwrap_or(0).max(0) as usize;
2228                if skip_n > 0 {
2229                    rows.drain(0..skip_n.min(rows.len()));
2230                }
2231                if let Some(with_limit) = with_limit {
2232                    rows.truncate(with_limit.max(0) as usize);
2233                }
2234            }
2235            rows
2236        };
2237        *carried_vars = with
2238            .items
2239            .iter()
2240            .enumerate()
2241            .map(with_item_output_name)
2242            .collect();
2243        Ok(rows)
2244    }
2245
2246    /// `UNWIND`'s fan-out. Not a graph traversal — like `WITH`, handled
2247    /// directly here rather than through a `LogicalPlan`/`eval_plan` (see
2248    /// `UnwindClause`'s docs). Cross-joins each input row against every
2249    /// element of that row's resolved list, then applies the clause's own
2250    /// `WHERE`.
2251    fn eval_unwind(
2252        &self,
2253        txn: Txn,
2254        clause: &UnwindClause,
2255        rows: &[BindingRow],
2256        guard: &ExecutionGuard<'_>,
2257    ) -> Result<Vec<BindingRow>, QueryError> {
2258        let mut out = Vec::new();
2259        for row in rows {
2260            let source_value = self.eval_return_expr(txn, &clause.source.0, row, guard)?;
2261            let elements: Vec<Binding> = match source_value {
2262                Value::List(items) => items.iter().map(value_to_binding_restore).collect(),
2263                // `UNWIND null AS x` behaves like unwinding an empty list
2264                // (zero rows) in real Cypher, not an error.
2265                Value::Null => Vec::new(),
2266                other => {
2267                    return Err(QueryError::Type(format!(
2268                        "UNWIND needs a list, got {other:?}"
2269                    )))
2270                }
2271            };
2272            for element in elements {
2273                let mut new_row = row.clone();
2274                new_row.insert(clause.var.clone(), element);
2275                out.push(new_row);
2276            }
2277        }
2278        if let Some(where_clause) = &clause.where_clause {
2279            let mut filtered = Vec::with_capacity(out.len());
2280            for row in out {
2281                if self.eval_with_expr(txn, where_clause, &row, guard)? == Some(true) {
2282                    filtered.push(row);
2283                }
2284            }
2285            out = filtered;
2286        }
2287        Ok(out)
2288    }
2289
2290    /// `shortestPath((a)-[:TYPE*..N]-(b))` — a real parent-pointer BFS
2291    /// between two already-bound endpoints, not a `LogicalPlan`/
2292    /// `VarExpand` traversal (which only tracks final position plus a
2293    /// visited set, not the hop-by-hop chain a path needs to reconstruct).
2294    /// BFS visits in non-decreasing depth order, so the first time `b` is
2295    /// reached is *a* shortest path — stop there and reconstruct via
2296    /// parent pointers, rather than enumerating every path up to some
2297    /// bound the way `VarExpand` does.
2298    ///
2299    /// Both endpoints must already be bound by a preceding clause (e.g.
2300    /// `MATCH (a:Person{name:'Alice'}), (b:Person{name:'Bob'}) MATCH p =
2301    /// shortestPath((a)-[:KNOWS*]-(b)) RETURN p` — parser-enforced, see
2302    /// `parser::validate_shortest_path_pattern`) — v1 doesn't attempt to
2303    /// resolve a fresh/scanned endpoint here the way ordinary MATCH does,
2304    /// since "shortest path to *any* node matching these constraints" is a
2305    /// different, more ambiguous question than "shortest path between
2306    /// these two specific nodes."
2307    ///
2308    /// Every input row always survives (unlike an ordinary pattern match,
2309    /// which can produce zero rows for a non-match) — an unreachable pair
2310    /// binds the path variable to `Null`, same as `OPTIONAL MATCH`'s
2311    /// null-padding, rather than dropping the row. `part.optional` is
2312    /// therefore a no-op here, not separately handled. Exceeding the
2313    /// safety depth cap on an unbounded (`*..`) search also resolves to
2314    /// `Null`, not an error — unlike `VarExpand`'s cap (which errors,
2315    /// because truncating there would silently produce an *incomplete
2316    /// set* of paths, a wrong-answer risk), `shortestPath()` is only ever
2317    /// answering "is there a path within the searched horizon," which is
2318    /// a well-defined answer either way.
2319    fn eval_shortest_path(
2320        &self,
2321        txn: Txn,
2322        part: &QueryPart,
2323        rows: &[BindingRow],
2324        guard: &ExecutionGuard<'_>,
2325    ) -> Result<Vec<BindingRow>, QueryError> {
2326        let Some(path_var) = &part.path_var else {
2327            // Nothing names the result, so there's nothing to bind and no
2328            // filtering effect (see this function's docs) — pure no-op.
2329            return Ok(rows.to_vec());
2330        };
2331        let start_var = part.pattern.start.var.as_deref().expect(
2332            "shortestPath()'s start node always has a var — validated at parse time by \
2333             validate_shortest_path_pattern",
2334        );
2335        let (rel, end_node) = &part.pattern.hops[0];
2336        let end_var = end_node.var.as_deref().expect(
2337            "shortestPath()'s end node always has a var — validated at parse time by \
2338             validate_shortest_path_pattern",
2339        );
2340        let (min_hops, max_hops) = rel.hop_range.expect(
2341            "shortestPath()'s relationship is always variable-length — validated at parse time by \
2342             validate_shortest_path_pattern",
2343        );
2344        let direction = match rel.direction {
2345            RelDirection::Right => ExpandDirection::Out,
2346            RelDirection::Left => ExpandDirection::In,
2347            RelDirection::Either => ExpandDirection::Either,
2348        };
2349        let rel_labels = &rel.rel_types;
2350
2351        let mut out = Vec::with_capacity(rows.len());
2352        for row in rows {
2353            let start_id = require_bound_node(row, start_var)?;
2354            let end_id = require_bound_node(row, end_var)?;
2355            let path = self.shortest_path_between(
2356                txn,
2357                start_id,
2358                end_id,
2359                ShortestPathSpec {
2360                    direction,
2361                    rel_labels,
2362                    min_hops,
2363                    max_hops,
2364                },
2365            )?;
2366            let mut new_row = row.clone();
2367            let binding = match path {
2368                Some(elems) => Binding::Path(elems),
2369                None => Binding::Value(PropertyValue::Null),
2370            };
2371            new_row.insert(path_var.clone(), binding);
2372            out.push(new_row);
2373        }
2374        if let Some(where_clause) = &part.where_clause {
2375            let mut filtered = Vec::with_capacity(out.len());
2376            for row in out {
2377                if self.eval_expr(txn, where_clause, &row, guard)? == Some(true) {
2378                    filtered.push(row);
2379                }
2380            }
2381            out = filtered;
2382        }
2383        Ok(out)
2384    }
2385
2386    /// The BFS itself. `min_hops` is only ever 0 or 1 (`validate_shortest_
2387    /// path_pattern` rejects anything higher) — deliberately: a plain
2388    /// visited-set BFS can't correctly answer "shortest path of at least N
2389    /// hops" for N > 1 (a node first reached at a too-early depth would
2390    /// need to stay revisitable for a later, longer route to it, which a
2391    /// visited-set structurally can't represent) without a different
2392    /// (node, depth)-keyed algorithm. Rejecting the case outright at parse
2393    /// time is safer than silently answering it wrong.
2394    fn shortest_path_between(
2395        &self,
2396        txn: Txn,
2397        start: NodeId,
2398        end: NodeId,
2399        spec: ShortestPathSpec<'_>,
2400    ) -> Result<Option<Vec<PathBinding>>, QueryError> {
2401        if start == end && spec.min_hops == 0 {
2402            return Ok(Some(vec![PathBinding::Node(start)]));
2403        }
2404        let cap = spec.max_hops.unwrap_or(VAR_EXPAND_DEPTH_CAP);
2405        let mut parent: HashMap<NodeId, (NodeId, EdgeId)> = HashMap::new();
2406        let mut visited: HashSet<NodeId> = HashSet::new();
2407        visited.insert(start);
2408        let mut frontier = vec![start];
2409        let mut depth = 0u32;
2410        while depth < cap && !frontier.is_empty() {
2411            depth += 1;
2412            let mut next_frontier = Vec::new();
2413            for node in frontier {
2414                for entry in neighbors_for_direction(txn, node, spec.direction, spec.rel_labels)? {
2415                    if entry.other == end {
2416                        parent.insert(entry.other, (node, entry.edge_id));
2417                        return Ok(Some(reconstruct_path(&parent, start, end)));
2418                    }
2419                    if visited.insert(entry.other) {
2420                        parent.insert(entry.other, (node, entry.edge_id));
2421                        next_frontier.push(entry.other);
2422                    }
2423                }
2424            }
2425            frontier = next_frontier;
2426        }
2427        Ok(None)
2428    }
2429
2430    /// Projects `rows` through a `WITH` clause. Unlike `materialize_return`
2431    /// (which resolves everything down to display `Value`s), a bare
2432    /// variable reference (`WITH message`) must keep its graph identity
2433    /// (`Binding::Node`/`Edge`) so the next `QueryPart` can keep
2434    /// traversing from it — only computed expressions collapse to a
2435    /// scalar `Binding::Value`.
2436    fn materialize_with(
2437        &self,
2438        txn: Txn,
2439        with: &WithClause,
2440        rows: &[BindingRow],
2441        guard: &ExecutionGuard<'_>,
2442    ) -> Result<Vec<BindingRow>, QueryError> {
2443        let is_aggregating = has_aggregate(&with.items);
2444        let mut out = if !is_aggregating {
2445            let mut out = Vec::with_capacity(rows.len());
2446            for row in rows {
2447                let mut new_row = BindingRow::new();
2448                for (i, item) in with.items.iter().enumerate() {
2449                    let name = with_item_output_name((i, item));
2450                    let binding = self.item_binding(txn, &item.expr, row, guard)?;
2451                    new_row.insert(name, binding);
2452                }
2453                out.push(new_row);
2454            }
2455            out
2456        } else {
2457            validate_return_items(&with.items)?;
2458            let grouped = self.resolve_grouped_rows(txn, &with.items, rows, guard)?;
2459            grouped
2460                .into_iter()
2461                .map(|bindings| {
2462                    with.items
2463                        .iter()
2464                        .enumerate()
2465                        .zip(bindings)
2466                        .map(|((i, item), b)| (with_item_output_name((i, item)), b))
2467                        .collect()
2468                })
2469                .collect()
2470        };
2471        if let Some(where_clause) = &with.where_clause {
2472            let mut filtered = Vec::with_capacity(out.len());
2473            if is_aggregating {
2474                // Aggregation collapses many input rows into one group --
2475                // there's no single pre-WITH row left to fall back to, so
2476                // (matching real Cypher) WHERE only sees the grouped/
2477                // aggregated names, same as `RETURN`'s own aggregate WHERE.
2478                for row in out {
2479                    if self.eval_with_expr(txn, where_clause, &row, guard)? == Some(true) {
2480                        filtered.push(row);
2481                    }
2482                }
2483            } else {
2484                // Real Cypher lets a `WITH x AS y WHERE ...` immediately
2485                // following see *both* the pre-WITH binding (`x`) and the
2486                // new alias (`y`) -- confirmed via the TCK's own
2487                // `WithWhere7` scenarios. New aliases shadow same-named
2488                // old bindings on conflict. Still true with `DISTINCT` --
2489                // unlike aggregation, `DISTINCT` alone doesn't collapse
2490                // several pre-WITH rows into one *ambiguous* group; it's
2491                // a dedup applied to the *surviving*, still individually-
2492                // real rows, which is why the dedup itself happens below,
2493                // after this filter, not before it (TCK's WithWhere1
2494                // `[2]`: `WITH DISTINCT a.name2 AS name WHERE a.name2 =
2495                // 'B'` needs `a` from the row that produced each
2496                // candidate `name`, not just `name` itself).
2497                for (row, new_row) in rows.iter().zip(out) {
2498                    let mut merged = row.clone();
2499                    merged.extend(new_row.iter().map(|(k, v)| (k.clone(), v.clone())));
2500                    if self.eval_with_expr(txn, where_clause, &merged, guard)? == Some(true) {
2501                        filtered.push(new_row);
2502                    }
2503                }
2504            }
2505            out = filtered;
2506        }
2507        if with.distinct {
2508            out = dedup_binding_rows(&with.items, out)?;
2509        }
2510        Ok(out)
2511    }
2512
2513    /// `materialize_aggregating_return_with_order`'s `WITH`-side twin --
2514    /// same "fold extra composed ORDER BY keys through the same grouping
2515    /// pass as `with_items` themselves" approach (TCK's WithOrderBy4
2516    /// `[16]`-`[18]`), just producing `Vec<BindingRow>` (preserving graph
2517    /// identity for whatever clause comes after this `WITH`) instead of a
2518    /// final `QueryResult` -- the extra keys' own values are only ever
2519    /// used for sorting here, never carried into the output rows.
2520    fn materialize_aggregating_with_with_order(
2521        &self,
2522        txn: Txn,
2523        with_items: &[ReturnItem],
2524        rows: &[BindingRow],
2525        order_by: &[(ReturnExpr, SortDir)],
2526        skip_limit: (Option<i64>, Option<i64>),
2527        guard: &ExecutionGuard<'_>,
2528    ) -> Result<Vec<BindingRow>, QueryError> {
2529        let (skip, limit) = skip_limit;
2530        enum OrderKeySource {
2531            RealColumn(usize),
2532            Extra(usize),
2533        }
2534        let mut extra_exprs: Vec<ReturnExpr> = Vec::new();
2535        let order_by_source: Vec<OrderKeySource> = order_by
2536            .iter()
2537            .map(|(expr, _)| {
2538                match with_items
2539                    .iter()
2540                    .enumerate()
2541                    .position(|(i, it)| item_matches_leaf(expr, i, it))
2542                {
2543                    Some(i) => OrderKeySource::RealColumn(i),
2544                    None => {
2545                        let idx = extra_exprs.len();
2546                        extra_exprs.push(expr.clone());
2547                        OrderKeySource::Extra(idx)
2548                    }
2549                }
2550            })
2551            .collect();
2552        let extended_items: Vec<ReturnItem> = with_items
2553            .iter()
2554            .cloned()
2555            .chain(
2556                extra_exprs
2557                    .into_iter()
2558                    .map(|expr| ReturnItem { expr, alias: None }),
2559            )
2560            .collect();
2561        validate_return_items(&extended_items)?;
2562        let grouped = self.resolve_grouped_rows(txn, &extended_items, rows, guard)?;
2563        let real_len = with_items.len();
2564        let mut keyed: Vec<(Vec<Value>, BindingRow)> = Vec::with_capacity(grouped.len());
2565        for bindings in grouped {
2566            let (real, extra) = bindings.split_at(real_len);
2567            let real_values: Vec<Value> = real
2568                .iter()
2569                .map(|b| self.binding_to_value(txn, b))
2570                .collect::<Result<Vec<_>, _>>()?;
2571            let extra_values: Vec<Value> = extra
2572                .iter()
2573                .map(|b| self.binding_to_value(txn, b))
2574                .collect::<Result<Vec<_>, _>>()?;
2575            let keys: Vec<Value> = order_by_source
2576                .iter()
2577                .map(|src| match src {
2578                    OrderKeySource::RealColumn(i) => real_values[*i].clone(),
2579                    OrderKeySource::Extra(k) => extra_values[*k].clone(),
2580                })
2581                .collect();
2582            let real_row: BindingRow = with_items
2583                .iter()
2584                .enumerate()
2585                .zip(real)
2586                .map(|((i, item), binding)| (with_item_output_name((i, item)), binding.clone()))
2587                .collect();
2588            keyed.push((keys, real_row));
2589        }
2590        Ok(top_k_by(keyed, order_by, skip, limit)
2591            .into_iter()
2592            .map(|(_, row)| row)
2593            .collect())
2594    }
2595
2596    /// The `Binding` one WITH/RETURN item evaluates to for one input row. A
2597    /// bare `Var` keeps its graph identity (`Binding::Node`/`Edge`) so a
2598    /// later `QueryPart` can keep traversing from it; anything else
2599    /// (computed expressions) collapses to `Binding::Value`. Shared by the
2600    /// non-aggregating `materialize_with` path and grouping-key evaluation.
2601    fn item_binding(
2602        &self,
2603        txn: Txn,
2604        expr: &ReturnExpr,
2605        row: &BindingRow,
2606        guard: &ExecutionGuard<'_>,
2607    ) -> Result<Binding, QueryError> {
2608        match expr {
2609            ReturnExpr::Var(v) => row
2610                .get(v)
2611                .cloned()
2612                .ok_or_else(|| QueryError::UnboundVariable(v.clone())),
2613            other => {
2614                let value = self.eval_return_expr(txn, other, row, guard)?;
2615                // `value_to_property_value` collapses Node/Edge/List/Path
2616                // to Null -- fine for a bare Var (handled above, never
2617                // reaches here) but wrong for any *wrapped* non-Var
2618                // expression that still evaluates to one of those (a list
2619                // literal/index/slice, or a CASE branch returning a bound
2620                // node/edge): those need the matching real Binding kind,
2621                // not a silently-nulled scalar. `Path` still falls back to
2622                // Null here -- a real, separate gap (needs a `Value::Path`
2623                // -> `Binding::Path` conversion this doesn't have yet),
2624                // not something any currently-reachable expression form
2625                // produces though.
2626                Ok(match value {
2627                    Value::Node(n) => Binding::Node(n.id),
2628                    Value::Edge(e) => Binding::Edge(e.id),
2629                    Value::List(items) => Binding::List(items),
2630                    Value::Map(m) => Binding::Map(m),
2631                    other => Binding::Value(value_to_property_value(&other)),
2632                })
2633            }
2634        }
2635    }
2636
2637    /// Same sort as `apply_order_by`, but over `BindingRow`s (a `WITH`
2638    /// clause's own ORDER BY, which must run before that row set becomes
2639    /// the seed for the next `QueryPart` — sorting/limiting a WITH changes
2640    /// *which* rows continue, not just their presentation order).
2641    fn apply_order_by_bindings(
2642        &self,
2643        txn: Txn,
2644        rows: Vec<BindingRow>,
2645        // `Some`, same length as `rows`, only for a non-aggregating,
2646        // non-`DISTINCT` WITH (1:1 row correspondence with the pre-WITH
2647        // input) -- lets ORDER BY see both the pre-WITH scope and the
2648        // new aliases, matching `where_clause`'s own merge (real Cypher:
2649        // `WITH a.count AS count ORDER BY a.count`, `a` isn't projected
2650        // but is still a valid sort key, TCK's With4 [6]). `None` for an
2651        // aggregating/`DISTINCT` WITH -- many pre-WITH rows collapse
2652        // into one output row there, so no single pre-WITH scope exists
2653        // to merge in.
2654        pre_with_rows: Option<&[BindingRow]>,
2655        with_items: &[ReturnItem],
2656        order_by: &[(ReturnExpr, SortDir)],
2657        skip_limit: (Option<i64>, Option<i64>),
2658    ) -> Result<Vec<BindingRow>, QueryError> {
2659        let (skip, limit) = skip_limit;
2660        // Same reasoning as `apply_order_by`'s `order_by_col` shortcut: an
2661        // ORDER BY item that repeats a WITH item's expression verbatim
2662        // (`WITH sum(x) AS s ORDER BY sum(x)`, TCK's WithOrderBy4 [11])
2663        // refers to that already-computed item, not a fresh expression --
2664        // look it up by its output name directly (works whether or not
2665        // that item has an alias) rather than re-evaluating the
2666        // expression, which would need pre-aggregation bindings that no
2667        // longer exist at this post-`materialize_with` point (an
2668        // aggregate call reaching `eval_projected_expr` always errors, by
2669        // design).
2670        let order_by_output: Vec<Option<String>> = order_by
2671            .iter()
2672            .map(|(expr, _)| {
2673                with_items
2674                    .iter()
2675                    .enumerate()
2676                    .find(|(_, item)| item.expr == *expr)
2677                    .map(with_item_output_name)
2678            })
2679            .collect();
2680        let mut keyed: Vec<(Vec<Value>, BindingRow)> = Vec::with_capacity(rows.len());
2681        for (i, row) in rows.into_iter().enumerate() {
2682            let mut value_map = self.binding_row_to_value_map(txn, &row)?;
2683            if let Some(pre) = pre_with_rows {
2684                // Pre-WITH names fill in gaps only -- a new alias with the
2685                // same name already occupies that key in `value_map` and
2686                // must keep winning (matches `materialize_with`'s own
2687                // "new aliases shadow same-named old bindings" rule).
2688                for (k, v) in self.binding_row_to_value_map(txn, &pre[i])? {
2689                    value_map.entry(k).or_insert(v);
2690                }
2691            }
2692            let keys = order_by
2693                .iter()
2694                .zip(&order_by_output)
2695                .map(|((expr, _), output_name)| match output_name {
2696                    Some(name) => Ok(value_map.get(name).cloned().unwrap_or(Value::Null)),
2697                    None => eval_projected_expr(expr, &value_map),
2698                })
2699                .collect::<Result<Vec<_>, _>>()?;
2700            keyed.push((keys, row));
2701        }
2702        Ok(top_k_by(keyed, order_by, skip, limit)
2703            .into_iter()
2704            .map(|(_, row)| row)
2705            .collect())
2706    }
2707
2708    /// Sorts an already-`materialize_return`d result for a non-aggregating
2709    /// `RETURN`, evaluating each ORDER BY expression against *both* the
2710    /// pre-projection `BindingRow` it came from and its own projected
2711    /// output columns overlaid on top — real Cypher allows ORDER BY to
2712    /// reference either a RETURN alias or a still-in-scope variable that
2713    /// wasn't returned at all, so neither view alone is enough (see the
2714    /// call site in `execute_match`). `binding_rows` and `result.rows` are
2715    /// the same length and pairwise correspond — `materialize_return`'s
2716    /// non-aggregating path preserves row order 1:1 with its input.
2717    fn apply_order_by_with_scope(
2718        &self,
2719        txn: Txn,
2720        binding_rows: &[BindingRow],
2721        result: QueryResult,
2722        order_by: &[(ReturnExpr, SortDir)],
2723        skip: Option<i64>,
2724        limit: Option<i64>,
2725    ) -> Result<QueryResult, QueryError> {
2726        let QueryResult { columns, rows, .. } = result;
2727        let mut keyed: Vec<(Vec<Value>, Vec<Value>)> = Vec::with_capacity(rows.len());
2728        for (binding_row, row) in binding_rows.iter().zip(rows) {
2729            let mut value_map = self.binding_row_to_value_map(txn, binding_row)?;
2730            for (col, val) in columns.iter().zip(&row) {
2731                value_map.insert(col.clone(), val.clone());
2732            }
2733            let keys = order_by
2734                .iter()
2735                .map(|(expr, _)| eval_projected_expr(expr, &value_map))
2736                .collect::<Result<Vec<_>, _>>()?;
2737            keyed.push((keys, row));
2738        }
2739        let rows = top_k_by(keyed, order_by, skip, limit)
2740            .into_iter()
2741            .map(|(_, row)| row)
2742            .collect();
2743        Ok(QueryResult {
2744            columns,
2745            rows,
2746            stats: QueryStats::default(),
2747        })
2748    }
2749
2750    fn binding_row_to_value_map(
2751        &self,
2752        txn: Txn,
2753        row: &BindingRow,
2754    ) -> Result<HashMap<String, Value>, QueryError> {
2755        let mut map = HashMap::with_capacity(row.len());
2756        for (k, binding) in row {
2757            map.insert(k.clone(), self.binding_to_value(txn, binding)?);
2758        }
2759        Ok(map)
2760    }
2761
2762    /// Resolves a `Binding` to its display `Value` — a `Node`/`Edge`
2763    /// binding fetches the full current record, a scalar `Value` binding
2764    /// passes through (collapsing a stored `PropertyValue::Null` to
2765    /// `Value::Null`, same as everywhere else null is represented).
2766    fn binding_to_value(&self, txn: Txn, b: &Binding) -> Result<Value, QueryError> {
2767        Ok(match b {
2768            Binding::Node(id) => {
2769                Value::Node((*deleted_entity_access(self.get_node_cached(txn, *id)?)?).clone())
2770            }
2771            Binding::Edge(id) => Value::Edge(deleted_entity_access(GraphStore::get_edge_in_txn(
2772                txn, *id,
2773            )?)?),
2774            Binding::Value(PropertyValue::Null) => Value::Null,
2775            Binding::Value(pv) => property_value_to_value(pv.clone()),
2776            Binding::List(items) => Value::List(items.clone()),
2777            Binding::Map(m) => Value::Map(m.clone()),
2778            Binding::Path(elems) => Value::Path(self.resolve_path_elems(txn, elems)?),
2779        })
2780    }
2781
2782    /// `startNode(r)`/`endNode(r)` — unlike every other builtin function
2783    /// (`labels()`, `type()`, ...), which reads straight off the already-
2784    /// materialized `Value::Node`/`Edge` it's given, this needs a *second*
2785    /// `GraphStore` lookup: `Edge.src`/`.dst` are bare `NodeId`s, not full
2786    /// records. `call_builtin` (the free function every other builtin
2787    /// dispatches through) has no `Txn` to do that lookup with, so these
2788    /// two are special-cased here instead, before ever reaching it.
2789    fn start_or_end_node(
2790        &self,
2791        txn: Txn,
2792        which: &str,
2793        arg: Option<&Value>,
2794    ) -> Result<Value, QueryError> {
2795        match arg {
2796            None | Some(Value::Null) => Ok(Value::Null),
2797            Some(Value::Edge(e)) => {
2798                let id = if which == "startnode" { e.src } else { e.dst };
2799                let node = deleted_entity_access(self.get_node_cached(txn, id)?)?;
2800                Ok(Value::Node((*node).clone()))
2801            }
2802            Some(other) => Err(QueryError::Type(format!(
2803                "{which}() expects a relationship, got {other:?}"
2804            ))),
2805        }
2806    }
2807
2808    /// `type(r)` -- unlike every other property/label access, real Cypher
2809    /// still allows this after `DELETE r` deleted the relationship
2810    /// earlier in the same statement (a relationship's type never
2811    /// changes, so there's nothing mutable a live record could be hiding
2812    /// -- unlike `labels()`/property access, which stay real
2813    /// `DeletedEntityAccess` errors, TCK's Return2 `[14]`-`[17]`). Tries
2814    /// the ordinary evaluation first; only on failure, and only for a
2815    /// bare `Var` bound to an edge, falls back to `guard`'s cached type
2816    /// from the moment it was deleted (`ExecutionGuard::
2817    /// deleted_edge_types`'s own docs). Any other failure (unbound
2818    /// variable, a genuinely wrong argument type, ...) propagates
2819    /// unchanged.
2820    fn eval_type_call(
2821        &self,
2822        txn: Txn,
2823        arg_expr: Option<&ReturnExpr>,
2824        row: &BindingRow,
2825        guard: &ExecutionGuard<'_>,
2826    ) -> Result<Value, QueryError> {
2827        let Some(arg_expr) = arg_expr else {
2828            return type_builtin(None);
2829        };
2830        match self.eval_return_expr(txn, arg_expr, row, guard) {
2831            Ok(v) => type_builtin(Some(&v)),
2832            Err(err) => {
2833                if let ReturnExpr::Var(v) = arg_expr {
2834                    if let Some(Binding::Edge(id)) = row.get(v) {
2835                        if let Some(label) = guard.deleted_edge_type(*id) {
2836                            return Ok(Value::Property(PropertyValue::String(label)));
2837                        }
2838                    }
2839                }
2840                Err(err)
2841            }
2842        }
2843    }
2844
2845    /// `binding_to_value`'s per-element helper for `Binding::Path` — fetches
2846    /// each element's full current record, same "keep just the id in the
2847    /// row, resolve to a full record only when materializing for display"
2848    /// split `Binding::Node`/`Edge` already use above.
2849    fn resolve_path_elems(
2850        &self,
2851        txn: Txn,
2852        elems: &[PathBinding],
2853    ) -> Result<Vec<PathElem>, QueryError> {
2854        elems
2855            .iter()
2856            .map(|e| {
2857                Ok(match e {
2858                    PathBinding::Node(id) => PathElem::Node(
2859                        (*deleted_entity_access(self.get_node_cached(txn, *id)?)?).clone(),
2860                    ),
2861                    PathBinding::Edge(id) => PathElem::Edge(deleted_entity_access(
2862                        GraphStore::get_edge_in_txn(txn, *id)?,
2863                    )?),
2864                })
2865            })
2866            .collect()
2867    }
2868
2869    /// Folds `rows` into groups keyed by every non-aggregate item's per-row
2870    /// `Binding` (via `item_binding`), then finishes each aggregating
2871    /// item's accumulator(s) per group. Returns one `Vec<Binding>` per
2872    /// output group, column-aligned with `items`. Shared by
2873    /// `materialize_with` and `materialize_return` — both already take the
2874    /// same `rows: &[BindingRow]` input type, so the grouping core stays
2875    /// in `Binding`-space (preserving graph identity for bare-var grouping
2876    /// keys) and each caller does its own thin final conversion.
2877    ///
2878    /// An item "aggregates" (`contains_aggregate`) in one of two shapes:
2879    /// purely (`count(a)`, `count(*)`, the only shape this used to
2880    /// support) or composed with other expressions (`count(a) + 3`, `a,
2881    /// count(a)` isn't this -- `a` is its own separate, non-aggregating
2882    /// item). Either way, `Group.accs[i]` holds one `AggAcc` per
2883    /// aggregate-bearing subexpression found in that item's tree
2884    /// (`collect_agg_nodes`'s order — empty for a non-aggregating item,
2885    /// exactly one for the purely-aggregating shape), and finishing a
2886    /// composed item evaluates its whole expression tree via
2887    /// `rewrite_composed_item` rather than just unwrapping a single
2888    /// accumulator. `validate_return_items` (which callers must run
2889    /// first) already guarantees every non-aggregate leaf inside a
2890    /// composed item's tree matches some *other* item's own top-level
2891    /// expression verbatim, so this function trusts that invariant rather
2892    /// than re-checking it.
2893    ///
2894    /// Grouping-key lookup is a hash-map lookup (`group_index`, keyed by
2895    /// `binding_hash_key`'s output — `Binding`/`PropertyValue` don't
2896    /// derive `Eq`/`Hash` themselves, `PropertyValue::Float` can't, so
2897    /// `HashKey` stands in for them; see its docs) into `groups`, which
2898    /// stays a plain `Vec` for insertion-order-stable output when there's
2899    /// no ORDER BY. O(1) average per row, not the O(rows × groups) linear
2900    /// scan this used to be — see BENCHMARKS.md for the measured
2901    /// before/after.
2902    fn resolve_grouped_rows(
2903        &self,
2904        txn: Txn,
2905        items: &[ReturnItem],
2906        rows: &[BindingRow],
2907        guard: &ExecutionGuard<'_>,
2908    ) -> Result<Vec<Vec<Binding>>, QueryError> {
2909        struct Group {
2910            // Aligned to `items`: `Some` at a non-aggregating item's
2911            // index, `None` at an aggregating one's (whether purely
2912            // aggregating or composed) -- exactly one of
2913            // `key_bindings[i]`/`!accs[i].is_empty()` holds per `i`.
2914            key_bindings: Vec<Option<Binding>>,
2915            accs: Vec<Vec<AggAcc>>,
2916            row_count: i64,
2917        }
2918        fn fresh_accs(items: &[ReturnItem]) -> Vec<Vec<AggAcc>> {
2919            items
2920                .iter()
2921                .map(|item| {
2922                    let mut nodes = Vec::new();
2923                    collect_agg_nodes(&item.expr, &mut nodes);
2924                    nodes
2925                        .into_iter()
2926                        .map(|node| match node {
2927                            ReturnExpr::CountStar => AggAcc::identity("count", false),
2928                            ReturnExpr::Call { name, distinct, .. } => {
2929                                AggAcc::identity(name, *distinct)
2930                            }
2931                            _ => unreachable!(
2932                                "collect_agg_nodes only ever collects CountStar/aggregate Call nodes"
2933                            ),
2934                        })
2935                        .collect()
2936                })
2937                .collect()
2938        }
2939        // Computed once, not per row -- `item_agg_nodes[i][k]` is exactly
2940        // the node `group.accs[i][k]` accumulates for, every row.
2941        let item_agg_nodes: Vec<Vec<&ReturnExpr>> = items
2942            .iter()
2943            .map(|item| {
2944                let mut nodes = Vec::new();
2945                collect_agg_nodes(&item.expr, &mut nodes);
2946                nodes
2947            })
2948            .collect();
2949
2950        // Groups live in `groups` (insertion order, for stable output when
2951        // there's no ORDER BY) with `group_index` as a hash-based lookup
2952        // into it, keyed by a hashable stand-in for `key_bindings` (see
2953        // `HashKey` — `Binding`/`PropertyValue` don't derive `Eq`/`Hash`
2954        // themselves, `PropertyValue::Float` can't). O(1) average lookup
2955        // per row instead of the O(groups) linear scan this replaced —
2956        // see BENCHMARKS.md for the measured before/after.
2957        let mut groups: Vec<Group> = Vec::new();
2958        let mut group_index: HashMap<Vec<Option<HashKey>>, usize> = HashMap::new();
2959        for row in rows {
2960            let mut key_bindings = Vec::with_capacity(items.len());
2961            for item in items {
2962                key_bindings.push(if contains_aggregate(&item.expr) {
2963                    None
2964                } else {
2965                    Some(self.item_binding(txn, &item.expr, row, guard)?)
2966                });
2967            }
2968            let hash_key: Vec<Option<HashKey>> = key_bindings
2969                .iter()
2970                .map(|b| b.as_ref().map(binding_hash_key).transpose())
2971                .collect::<Result<Vec<_>, _>>()?;
2972            let group_idx = *group_index.entry(hash_key).or_insert_with(|| {
2973                groups.push(Group {
2974                    key_bindings: key_bindings.clone(),
2975                    accs: fresh_accs(items),
2976                    row_count: 0,
2977                });
2978                groups.len() - 1
2979            });
2980            let group = &mut groups[group_idx];
2981            group.row_count += 1;
2982            for (i, nodes) in item_agg_nodes.iter().enumerate() {
2983                for (k, node) in nodes.iter().enumerate() {
2984                    match node {
2985                        // `count(*)` counts rows, not values -- folded
2986                        // unconditionally (no null-skip: there's no
2987                        // per-row expression to be null) via a dummy
2988                        // always-non-null argument, reusing `AggAcc::
2989                        // Count`'s existing fold logic instead of a
2990                        // separate no-accumulator path (see `fresh_accs`).
2991                        ReturnExpr::CountStar => {
2992                            group.accs[i][k].fold(&Value::Literal(Literal::Bool(true)))?;
2993                        }
2994                        ReturnExpr::Call { name, args, .. } => {
2995                            // Standard Cypher null-skipping: a null
2996                            // argument (e.g. an unmatched OPTIONAL MATCH
2997                            // variable) contributes to neither the
2998                            // accumulator nor its DISTINCT dedup set.
2999                            let value = self.eval_return_expr(txn, &args[0], row, guard)?;
3000                            if is_percentile_name(name) {
3001                                // percentileCont/percentileDisc's second
3002                                // argument (the percentile) is evaluated
3003                                // per row too -- in practice always a
3004                                // constant across the group, but nothing
3005                                // structurally requires that, so it's just
3006                                // evaluated fresh every row like any other
3007                                // expression rather than memoized once.
3008                                let percentile =
3009                                    self.eval_return_expr(txn, &args[1], row, guard)?;
3010                                if !matches!(value, Value::Null) {
3011                                    group.accs[i][k].fold_percentile(&value, &percentile)?;
3012                                }
3013                            } else if !matches!(value, Value::Null) {
3014                                group.accs[i][k].fold(&value)?;
3015                            }
3016                        }
3017                        _ => unreachable!(
3018                            "collect_agg_nodes only ever collects CountStar/aggregate Call nodes"
3019                        ),
3020                    }
3021                }
3022            }
3023        }
3024
3025        // Global aggregate over an empty result set (no grouping-key items
3026        // at all, and no rows to seed a group from) still produces exactly
3027        // one output row — `count`/`count(*)` -> 0, `sum` -> 0,
3028        // `avg`/`min`/`max` -> Null, `collect` -> [] — via the same
3029        // fresh-accumulator `finish()` path a normal empty-contribution
3030        // group already uses below, not a separate code path.
3031        let no_key_items = items.iter().all(|item| contains_aggregate(&item.expr));
3032        if groups.is_empty() && no_key_items {
3033            groups.push(Group {
3034                key_bindings: vec![None; items.len()],
3035                accs: fresh_accs(items),
3036                row_count: 0,
3037            });
3038        }
3039
3040        let mut out = Vec::with_capacity(groups.len());
3041        for mut group in groups {
3042            let ctx = GroupFinishCtx {
3043                items,
3044                key_bindings: &group.key_bindings,
3045            };
3046            let mut row_out = Vec::with_capacity(items.len());
3047            for (i, item) in items.iter().enumerate() {
3048                let binding = match &group.key_bindings[i] {
3049                    Some(b) => b.clone(),
3050                    None => {
3051                        let mut accs = std::mem::take(&mut group.accs[i]).into_iter();
3052                        let mut subst = HashMap::new();
3053                        let rewritten = self
3054                            .rewrite_composed_item(txn, &item.expr, &ctx, &mut accs, &mut subst)?;
3055                        value_to_binding(eval_projected_expr(&rewritten, &subst)?)
3056                    }
3057                };
3058                row_out.push(binding);
3059            }
3060            out.push(row_out);
3061        }
3062        Ok(out)
3063    }
3064
3065    /// Finishing half of a composed aggregate item (`count(a) + 3`):
3066    /// rewrites `expr`'s tree into an equivalent one `eval_projected_expr`
3067    /// can evaluate without any further graph access, replacing every
3068    /// aggregate-bearing subexpression with a synthetic `Var` referencing
3069    /// its now-finished accumulator's value in `subst` (consumed from
3070    /// `accs` in `collect_agg_nodes`'s order, the same order `fresh_accs`/
3071    /// the per-row fold loop in `resolve_grouped_rows` built them in), and
3072    /// every non-aggregate `Var`/`Prop` leaf with a synthetic `Var`
3073    /// referencing whichever *other* item's own grouping-key `Binding` it
3074    /// structurally matches (`validate_return_items` already guarantees
3075    /// exactly one such match exists — never reached otherwise). Each
3076    /// substituted value gets its own fresh, guaranteed-unique slot name
3077    /// (`subst.len()` at insertion time), so nothing here can collide with
3078    /// a real Cypher identifier the user wrote.
3079    fn rewrite_composed_item(
3080        &self,
3081        txn: Txn,
3082        expr: &ReturnExpr,
3083        ctx: &GroupFinishCtx<'_>,
3084        accs: &mut std::vec::IntoIter<AggAcc>,
3085        subst: &mut HashMap<String, Value>,
3086    ) -> Result<ReturnExpr, QueryError> {
3087        if matches!(expr, ReturnExpr::CountStar)
3088            || matches!(expr, ReturnExpr::Call { name, .. } if is_aggregate_name(name))
3089        {
3090            let value = accs
3091                .next()
3092                .expect("accs is aligned with this same expr's collect_agg_nodes traversal order")
3093                .finish();
3094            let slot = format!("__slot{}", subst.len());
3095            subst.insert(slot.clone(), value);
3096            return Ok(ReturnExpr::Var(slot));
3097        }
3098        if matches!(expr, ReturnExpr::Var(_) | ReturnExpr::Prop(_)) {
3099            let j = ctx
3100                .items
3101                .iter()
3102                .enumerate()
3103                .position(|(i, it)| item_matches_leaf(expr, i, it) && !contains_aggregate(&it.expr))
3104                .expect(
3105                    "validate_return_items already checked this leaf matches a grouping-key item",
3106                );
3107            let binding = ctx.key_bindings[j]
3108                .clone()
3109                .expect("a non-aggregating item always has a key binding");
3110            let value = self.binding_to_value(txn, &binding)?;
3111            let slot = format!("__slot{}", subst.len());
3112            subst.insert(slot.clone(), value);
3113            return Ok(ReturnExpr::Var(slot));
3114        }
3115        Ok(match expr {
3116            ReturnExpr::Lit(lit) => ReturnExpr::Lit(lit.clone()),
3117            ReturnExpr::Call {
3118                name,
3119                args,
3120                distinct,
3121            } => ReturnExpr::Call {
3122                name: name.clone(),
3123                distinct: *distinct,
3124                args: args
3125                    .iter()
3126                    .map(|a| self.rewrite_composed_item(txn, a, ctx, accs, subst))
3127                    .collect::<Result<_, _>>()?,
3128            },
3129            ReturnExpr::Case { test, whens, else_ } => ReturnExpr::Case {
3130                test: test
3131                    .as_deref()
3132                    .map(|t| self.rewrite_composed_item(txn, t, ctx, accs, subst))
3133                    .transpose()?
3134                    .map(Box::new),
3135                whens: whens
3136                    .iter()
3137                    .map(|(w, t)| {
3138                        Ok::<_, QueryError>((
3139                            self.rewrite_composed_item(txn, w, ctx, accs, subst)?,
3140                            self.rewrite_composed_item(txn, t, ctx, accs, subst)?,
3141                        ))
3142                    })
3143                    .collect::<Result<_, _>>()?,
3144                else_: else_
3145                    .as_deref()
3146                    .map(|e| self.rewrite_composed_item(txn, e, ctx, accs, subst))
3147                    .transpose()?
3148                    .map(Box::new),
3149            },
3150            ReturnExpr::Arith(l, op, r) => ReturnExpr::Arith(
3151                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
3152                *op,
3153                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
3154            ),
3155            ReturnExpr::Neg(e) => ReturnExpr::Neg(Box::new(
3156                self.rewrite_composed_item(txn, e, ctx, accs, subst)?,
3157            )),
3158            ReturnExpr::ListLit(list_items) => ReturnExpr::ListLit(
3159                list_items
3160                    .iter()
3161                    .map(|i| self.rewrite_composed_item(txn, i, ctx, accs, subst))
3162                    .collect::<Result<_, _>>()?,
3163            ),
3164            ReturnExpr::Index(base, index) => ReturnExpr::Index(
3165                Box::new(self.rewrite_composed_item(txn, base, ctx, accs, subst)?),
3166                Box::new(self.rewrite_composed_item(txn, index, ctx, accs, subst)?),
3167            ),
3168            ReturnExpr::PropOf(base, prop) => ReturnExpr::PropOf(
3169                Box::new(self.rewrite_composed_item(txn, base, ctx, accs, subst)?),
3170                prop.clone(),
3171            ),
3172            ReturnExpr::Slice(base, start, end) => ReturnExpr::Slice(
3173                Box::new(self.rewrite_composed_item(txn, base, ctx, accs, subst)?),
3174                start
3175                    .as_deref()
3176                    .map(|s| self.rewrite_composed_item(txn, s, ctx, accs, subst))
3177                    .transpose()?
3178                    .map(Box::new),
3179                end.as_deref()
3180                    .map(|e| self.rewrite_composed_item(txn, e, ctx, accs, subst))
3181                    .transpose()?
3182                    .map(Box::new),
3183            ),
3184            // `where_clause`/`project` are deliberately left untouched
3185            // (cloned verbatim), not recursed into -- they run once per
3186            // *element* of `source`'s own already-rewritten result, in a
3187            // scope `eval_projected_expr`'s own `ListComp`/`Quantifier`
3188            // handling builds itself (the outer `subst` map plus a fresh
3189            // binding for `var`, per element). Rewriting a `Var`/`Prop`
3190            // leaf in here the same way `source` gets rewritten would
3191            // wrongly try to resolve the comprehension's own *local* loop
3192            // variable (`x`/`ok`) as if it had to be some other item's
3193            // grouping key -- there's no such item, since it's not an
3194            // outer reference at all (found via TCK's List11 [3]: `ALL(ok
3195            // IN collect(...) WHERE ok)` panicked trying to resolve `ok`
3196            // this way). `validate_composed_expr`'s own `ListComp` arm
3197            // already guarantees `project` has no aggregate to substitute
3198            // in the first place; `where_clause` is the same documented
3199            // scope gap `contains_aggregate` has everywhere else.
3200            ReturnExpr::ListComp {
3201                var,
3202                source,
3203                where_clause,
3204                project,
3205            } => ReturnExpr::ListComp {
3206                var: var.clone(),
3207                source: Box::new(self.rewrite_composed_item(txn, source, ctx, accs, subst)?),
3208                where_clause: where_clause.clone(),
3209                project: project.clone(),
3210            },
3211            ReturnExpr::Quantifier {
3212                kind,
3213                var,
3214                source,
3215                where_clause,
3216            } => ReturnExpr::Quantifier {
3217                kind: *kind,
3218                var: var.clone(),
3219                source: Box::new(self.rewrite_composed_item(txn, source, ctx, accs, subst)?),
3220                where_clause: where_clause.clone(),
3221            },
3222            ReturnExpr::MapLit(entries) => ReturnExpr::MapLit(
3223                entries
3224                    .iter()
3225                    .map(|(k, v)| {
3226                        Ok::<_, QueryError>((
3227                            k.clone(),
3228                            self.rewrite_composed_item(txn, v, ctx, accs, subst)?,
3229                        ))
3230                    })
3231                    .collect::<Result<_, _>>()?,
3232            ),
3233            ReturnExpr::And(l, r) => ReturnExpr::And(
3234                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
3235                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
3236            ),
3237            ReturnExpr::Or(l, r) => ReturnExpr::Or(
3238                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
3239                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
3240            ),
3241            ReturnExpr::Xor(l, r) => ReturnExpr::Xor(
3242                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
3243                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
3244            ),
3245            ReturnExpr::Not(e) => ReturnExpr::Not(Box::new(
3246                self.rewrite_composed_item(txn, e, ctx, accs, subst)?,
3247            )),
3248            ReturnExpr::Compare(l, op, r) => ReturnExpr::Compare(
3249                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
3250                *op,
3251                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
3252            ),
3253            ReturnExpr::IsNull(e) => ReturnExpr::IsNull(Box::new(
3254                self.rewrite_composed_item(txn, e, ctx, accs, subst)?,
3255            )),
3256            ReturnExpr::In(needle, haystack) => ReturnExpr::In(
3257                Box::new(self.rewrite_composed_item(txn, needle, ctx, accs, subst)?),
3258                Box::new(self.rewrite_composed_item(txn, haystack, ctx, accs, subst)?),
3259            ),
3260            ReturnExpr::HasLabel(v, l) => ReturnExpr::HasLabel(v.clone(), l.clone()),
3261            ReturnExpr::PatternPredicate(p) => ReturnExpr::PatternPredicate(p.clone()),
3262            ReturnExpr::PatternComprehension { .. } => expr.clone(),
3263            ReturnExpr::ExistsPattern { .. } => expr.clone(),
3264            ReturnExpr::ExistsSubquery(_) => expr.clone(),
3265            ReturnExpr::Var(_) | ReturnExpr::Prop(_) | ReturnExpr::CountStar => {
3266                unreachable!("handled above, before this match")
3267            }
3268        })
3269    }
3270
3271    /// WITH's HAVING-equivalent — evaluated against the already-projected/
3272    /// grouped row, same as ORDER BY. Never pushed into the planner (see
3273    /// `WithExpr`'s docs).
3274    /// `Option<bool>` — `None` is Cypher's "unknown" (see `compare()`'s
3275    /// docs), propagated through `AND`/`OR`/`NOT` via `and3`/`or3`/`map`
3276    /// instead of collapsing to `false` partway through. Every call site
3277    /// filters a row by checking `== Some(true)` — unknown behaves like
3278    /// `false` for filtering purposes, but *only* at that final step, not
3279    /// internally, since `AND`/`OR`'s truth tables need to tell "false"
3280    /// and "unknown" apart to combine correctly.
3281    fn eval_with_expr(
3282        &self,
3283        txn: Txn,
3284        expr: &WithExpr,
3285        row: &BindingRow,
3286        guard: &ExecutionGuard<'_>,
3287    ) -> Result<Option<bool>, QueryError> {
3288        Ok(match expr {
3289            WithExpr::And(l, r) => and3(
3290                self.eval_with_expr(txn, l, row, guard)?,
3291                self.eval_with_expr(txn, r, row, guard)?,
3292            ),
3293            WithExpr::Or(l, r) => or3(
3294                self.eval_with_expr(txn, l, row, guard)?,
3295                self.eval_with_expr(txn, r, row, guard)?,
3296            ),
3297            WithExpr::Not(e) => self.eval_with_expr(txn, e, row, guard)?.map(|b| !b),
3298            WithExpr::Compare(lhs, op, rhs) => {
3299                let lv = self.eval_return_expr(txn, lhs, row, guard)?;
3300                let rv = self.eval_return_expr(txn, rhs, row, guard)?;
3301                compare_values(&lv, *op, &rv)
3302            }
3303            // Always definite -- same reasoning as `Expr::IsNull`.
3304            WithExpr::IsNull(e) => Some(matches!(
3305                self.eval_return_expr(txn, e, row, guard)?,
3306                Value::Null
3307            )),
3308            // Unlike an ordinary MATCH's own `WHERE` (`Expr`), which folds
3309            // a bare pattern predicate into `Expr::Pattern` at parse time
3310            // (`return_expr_to_expr`), `WithExpr` has no such folding --
3311            // `WITH ... WHERE a.id = 0 AND (a)-->(b)` embeds it straight
3312            // as a `ReturnExpr::PatternPredicate` inside `Bare`/`And`/`Or`.
3313            // Special-cased here (rather than in `eval_return_expr`, which
3314            // errors on it -- a pattern predicate is only ever meaningful
3315            // as a predicate, never as a real projected value) so `WITH
3316            // ... WHERE` gets the same existential-search semantics MATCH's
3317            // own `WHERE` already has (TCK's WithWhere4 `[2]`).
3318            WithExpr::Bare(ReturnExpr::PatternPredicate(pattern)) => {
3319                Some(self.eval_pattern_predicate_exists(txn, pattern, row, guard)?)
3320            }
3321            WithExpr::Bare(e) => self.eval_return_expr_bool3(txn, e, row, guard)?,
3322        })
3323    }
3324
3325    /// `WHERE (n)-[:REL]->()` etc (TCK's Pattern1) -- existential: true
3326    /// iff at least one real match of `pattern` exists, with every
3327    /// already-bound named endpoint (`n`, and `m` in `(n)-->(m)` when `m`
3328    /// is also bound by an earlier MATCH) held fixed to this row's own
3329    /// binding rather than searched freely. `semantic::
3330    /// validate_pattern_predicate` already rejected any named endpoint
3331    /// that ISN'T already bound (real Cypher's `UndefinedVariable`), so
3332    /// every named var here is safe to seed. Reuses the exact same
3333    /// `build_match_plan` "already-bound var -> Seed, not a fresh scan"
3334    /// mechanism `eval_merge`'s own "try as an ordinary MATCH first" half
3335    /// already relies on -- for a one-hop pattern this is a real
3336    /// connected-subgraph search (Expand + Filter), not an isolated
3337    /// per-node check. `Some(1)`-limited: existence is all that's needed,
3338    /// so there's no reason to enumerate every match. Shared by `Expr::
3339    /// Pattern` (an ordinary MATCH's own WHERE) and `WithExpr::Bare`'s
3340    /// `PatternPredicate` case (a WITH's own WHERE) -- same semantics
3341    /// either way, just reached from two different expression shapes.
3342    fn eval_pattern_predicate_exists(
3343        &self,
3344        txn: Txn,
3345        pattern: &Pattern,
3346        row: &BindingRow,
3347        guard: &ExecutionGuard<'_>,
3348    ) -> Result<bool, QueryError> {
3349        let carried_vars: HashSet<String> = row.keys().cloned().collect();
3350        let plan = apply_index_seeks(build_match_plan(pattern, &None, &carried_vars)?, txn)?;
3351        let found =
3352            self.eval_plan_with_limit(txn, &plan, std::slice::from_ref(row), guard, Some(1))?;
3353        Ok(!found.is_empty())
3354    }
3355
3356    /// `exists { MATCH ... RETURN ... }`'s "full" form (TCK's
3357    /// ExistentialSubquery2/3) -- runs `stmt` (always a `Statement::Match`,
3358    /// `semantic::validate_statement` rejects anything else reaching here
3359    /// and rejects every mutating clause inside it, so this only ever sees
3360    /// a real read-only pipeline) correlated against `row` via
3361    /// `execute_match_seeded`, then checks whether it produced at least
3362    /// one output row -- the inner RETURN's own projected *values* are
3363    /// never inspected, only whether the row exists at all, same as
3364    /// `eval_pattern_predicate_exists`/`Expr::Exists` above.
3365    fn eval_exists_subquery(
3366        &self,
3367        txn: Txn,
3368        stmt: &Statement,
3369        row: &BindingRow,
3370        guard: &ExecutionGuard<'_>,
3371    ) -> Result<bool, QueryError> {
3372        let Statement::Match {
3373            clauses,
3374            tail,
3375            order_by,
3376            skip,
3377            limit,
3378        } = stmt
3379        else {
3380            unreachable!(
3381                "semantic::validate_statement only allows Statement::Match inside exists {{}}"
3382            )
3383        };
3384        let skip = self.resolve_skip_limit(txn, skip.as_deref(), "SKIP", guard)?;
3385        let limit = self.resolve_skip_limit(txn, limit.as_deref(), "LIMIT", guard)?;
3386        let result = self.execute_match_seeded(
3387            txn,
3388            clauses,
3389            tail,
3390            ResultModifiers {
3391                order_by,
3392                skip,
3393                limit,
3394            },
3395            Some(row),
3396            guard,
3397        )?;
3398        Ok(!result.rows.is_empty())
3399    }
3400
3401    /// Evaluates an `OPTIONAL MATCH` part with left-outer-join semantics:
3402    /// every outer row survives, whether or not the optional pattern
3403    /// matched anything for it. Must wrap the *whole* subplan rather than
3404    /// null-padding inside `Expand`/`VarExpand` themselves — baking it in
3405    /// there would turn every default (non-optional) `Expand` into a
3406    /// left-outer-join too (breaking existing inner-join semantics), and
3407    /// would mis-handle multi-hop optional patterns: IS7's optional
3408    /// pattern is 2 hops, and per-hop null-padding would emit one
3409    /// null-padded row per *hop-1* match even when hop 2 also matched,
3410    /// instead of collapsing to exactly one row per outer row that had
3411    /// zero end-to-end matches.
3412    ///
3413    /// Implementation: tag each outer row with its index, evaluate the
3414    /// subplan once over the whole tagged batch (a single seed, not one
3415    /// call per row), group results back by that index, then for any
3416    /// outer index with zero results, emit the outer row unchanged plus
3417    /// `Null` for every variable the optional pattern would have newly
3418    /// introduced.
3419    fn eval_optional_part(
3420        &self,
3421        txn: Txn,
3422        plan: &LogicalPlan,
3423        outer_rows: &[BindingRow],
3424        new_vars: &HashSet<String>,
3425        guard: &ExecutionGuard<'_>,
3426    ) -> Result<Vec<BindingRow>, QueryError> {
3427        let tagged: Vec<BindingRow> = outer_rows
3428            .iter()
3429            .enumerate()
3430            .map(|(i, row)| {
3431                let mut r = row.clone();
3432                r.insert(
3433                    OPTIONAL_SEED_IDX_KEY.to_string(),
3434                    Binding::Value(PropertyValue::Int(i as i64)),
3435                );
3436                r
3437            })
3438            .collect();
3439        guard.check_intermediate_rows(tagged.len())?;
3440        let results = self.eval_plan(txn, plan, &tagged, guard)?;
3441        let mut by_idx: HashMap<i64, Vec<BindingRow>> = HashMap::new();
3442        for mut row in results {
3443            let idx = match row.remove(OPTIONAL_SEED_IDX_KEY) {
3444                Some(Binding::Value(PropertyValue::Int(i))) => i,
3445                other => unreachable!(
3446                    "__seed_idx tagged internally as Binding::Value(Int), got {other:?}"
3447                ),
3448            };
3449            by_idx.entry(idx).or_default().push(row);
3450        }
3451        let mut out = Vec::with_capacity(outer_rows.len());
3452        for (i, outer_row) in outer_rows.iter().enumerate() {
3453            match by_idx.remove(&(i as i64)) {
3454                Some(matches) => out.extend(matches),
3455                None => {
3456                    let mut padded = outer_row.clone();
3457                    for var in new_vars {
3458                        padded.insert(var.clone(), Binding::Value(PropertyValue::Null));
3459                    }
3460                    out.push(padded);
3461                }
3462            }
3463            guard.check_intermediate_rows(out.len())?;
3464        }
3465        Ok(out)
3466    }
3467
3468    fn eval_plan(
3469        &self,
3470        txn: Txn,
3471        plan: &LogicalPlan,
3472        seed: &[BindingRow],
3473        guard: &ExecutionGuard<'_>,
3474    ) -> Result<Vec<BindingRow>, QueryError> {
3475        self.eval_plan_with_limit(txn, plan, seed, guard, None)
3476    }
3477
3478    fn eval_plan_with_limit(
3479        &self,
3480        txn: Txn,
3481        plan: &LogicalPlan,
3482        seed: &[BindingRow],
3483        guard: &ExecutionGuard<'_>,
3484        limit: Option<usize>,
3485    ) -> Result<Vec<BindingRow>, QueryError> {
3486        let stream = self.stream_plan(txn, plan, seed, guard, limit);
3487        match limit {
3488            Some(limit) => stream.take(limit).collect(),
3489            None => stream.collect(),
3490        }
3491    }
3492
3493    /// Build a pull-based row pipeline. Each iterator owns only its current
3494    /// row (plus one relationship fan-out at an Expand), so scan/filter/
3495    /// expand chains no longer allocate a Vec at every logical-plan node.
3496    /// Blocking clause boundaries still collect this stream explicitly.
3497    fn stream_plan<'s>(
3498        &'s self,
3499        txn: Txn<'s>,
3500        plan: &'s LogicalPlan,
3501        seed: &'s [BindingRow],
3502        guard: &'s ExecutionGuard<'_>,
3503        scan_limit: Option<usize>,
3504    ) -> RowStream<'s> {
3505        match plan {
3506            LogicalPlan::Seed { var } => {
3507                debug_assert!(
3508                    seed.first().is_none_or(|row| row.contains_key(var)),
3509                    "Seed{{var: {var:?}}} planned for a var not present in the carried-forward rows"
3510                );
3511                Self::count_stream(Box::new(seed.iter().cloned().map(Ok)), guard)
3512            }
3513            LogicalPlan::AllNodesScan { var } => {
3514                self.stream_scan(txn, var, None, seed, guard, scan_limit)
3515            }
3516            LogicalPlan::NodeByLabelScan { var, label } => {
3517                self.stream_scan(txn, var, Some(label), seed, guard, scan_limit)
3518            }
3519            LogicalPlan::IndexRangeSeek {
3520                var,
3521                label,
3522                prop,
3523                lo,
3524                hi,
3525            } => self.stream_index_range_seek(txn, var, label, prop, lo, hi, seed, guard),
3526            LogicalPlan::EdgeTypeScan {
3527                src_var,
3528                rel_var,
3529                dst_var,
3530                rel_types,
3531                src_label,
3532                dst_label,
3533                rel_predicate,
3534            } => self.stream_edge_type_scan(
3535                txn,
3536                EdgeTypeScanSpec {
3537                    src_var,
3538                    rel_var,
3539                    dst_var,
3540                    rel_types,
3541                    src_label: src_label.as_deref(),
3542                    dst_label: dst_label.as_deref(),
3543                    rel_predicate: rel_predicate.as_ref(),
3544                },
3545                seed,
3546                guard,
3547            ),
3548            LogicalPlan::IndexSeek {
3549                var,
3550                label,
3551                prop,
3552                value,
3553            } => self.stream_index_seek(
3554                txn,
3555                IndexSeekSpec {
3556                    var,
3557                    label,
3558                    prop,
3559                    value,
3560                },
3561                seed,
3562                guard,
3563                scan_limit,
3564            ),
3565            LogicalPlan::Expand {
3566                input,
3567                from_var,
3568                to_var,
3569                rel_var,
3570                rel_labels,
3571                direction,
3572            } => {
3573                let input = self.stream_plan(txn, input, seed, guard, None);
3574                let stream = input.flat_map(move |res| -> RowStream<'s> {
3575                    let row = match res {
3576                        Ok(row) => row,
3577                        Err(error) => return Box::new(std::iter::once(Err(error))),
3578                    };
3579                    let from_id = match row.get(from_var) {
3580                        Some(Binding::Node(id)) => *id,
3581                        // A null binding has no neighbors and contributes
3582                        // no rows. Missing or structurally invalid bindings
3583                        // remain errors.
3584                        Some(Binding::Value(PropertyValue::Null)) => {
3585                            return Box::new(std::iter::empty())
3586                        }
3587                        _ => {
3588                            return Box::new(std::iter::once(Err(QueryError::UnboundVariable(
3589                                from_var.clone(),
3590                            ))))
3591                        }
3592                    };
3593                    match neighbors_for_direction(txn, from_id, *direction, rel_labels) {
3594                        Ok(entries) => Box::new(entries.into_iter().map(move |entry| {
3595                            guard.relationship_expansion()?;
3596                            let mut new_row = row.clone();
3597                            new_row.insert(to_var.clone(), Binding::Node(entry.other));
3598                            if let Some(rel_var) = rel_var {
3599                                new_row.insert(rel_var.clone(), Binding::Edge(entry.edge_id));
3600                            }
3601                            Ok(new_row)
3602                        })),
3603                        Err(error) => Box::new(std::iter::once(Err(error))),
3604                    }
3605                });
3606                Self::count_stream(Box::new(stream), guard)
3607            }
3608            LogicalPlan::VarExpand {
3609                input,
3610                from_var,
3611                to_var,
3612                rel_labels,
3613                direction,
3614                min_hops,
3615                max_hops,
3616                exclude_edge_vars,
3617                exclude_edge_sets,
3618                exclude_edge_var,
3619                path_segment_var,
3620                rel_list_var,
3621                rel_props,
3622            } => {
3623                let input = self.stream_plan(txn, input, seed, guard, None);
3624                let stream = input.flat_map(move |res| {
3625                    let rows = res.and_then(|row| {
3626                        self.expand_variable_row(
3627                            txn,
3628                            row,
3629                            VarExpandSpec {
3630                                from_var,
3631                                to_var,
3632                                rel_labels,
3633                                direction: *direction,
3634                                min_hops: *min_hops,
3635                                max_hops: *max_hops,
3636                                exclude_edge_vars,
3637                                exclude_edge_sets,
3638                                exclude_edge_var,
3639                                path_segment_var: path_segment_var.as_deref(),
3640                                rel_list_var: rel_list_var.as_deref(),
3641                                rel_props,
3642                            },
3643                            guard,
3644                        )
3645                    });
3646                    match rows {
3647                        Ok(rows) => Box::new(rows.into_iter().map(Ok)) as RowStream<'s>,
3648                        Err(error) => Box::new(std::iter::once(Err(error))),
3649                    }
3650                });
3651                Self::count_stream(Box::new(stream), guard)
3652            }
3653            LogicalPlan::MatchRelList {
3654                input,
3655                from_var,
3656                to_var,
3657                rel_list_var,
3658                rel_labels,
3659                direction,
3660                min_hops,
3661                max_hops,
3662            } => {
3663                let input = self.stream_plan(txn, input, seed, guard, None);
3664                let stream = input.filter_map(move |res| {
3665                    let row = match res {
3666                        Ok(row) => row,
3667                        Err(error) => return Some(Err(error)),
3668                    };
3669                    self.match_bound_rel_list_row(
3670                        row,
3671                        MatchRelListSpec {
3672                            from_var,
3673                            to_var,
3674                            rel_list_var,
3675                            rel_labels,
3676                            direction: *direction,
3677                            min_hops: *min_hops,
3678                            max_hops: *max_hops,
3679                        },
3680                    )
3681                    .transpose()
3682                });
3683                Self::count_stream(Box::new(stream), guard)
3684            }
3685            LogicalPlan::Filter { input, predicate } => {
3686                let input = self.stream_plan(txn, input, seed, guard, None);
3687                let stream = input.filter_map(move |res| {
3688                    let row = match res {
3689                        Ok(row) => row,
3690                        Err(error) => return Some(Err(error)),
3691                    };
3692                    if let Err(error) = guard.checkpoint() {
3693                        return Some(Err(error));
3694                    }
3695                    match self.eval_expr(txn, predicate, &row, guard) {
3696                        Ok(Some(true)) => Some(Ok(row)),
3697                        Ok(_) => None,
3698                        Err(error) => Some(Err(error)),
3699                    }
3700                });
3701                Self::count_stream(Box::new(stream), guard)
3702            }
3703        }
3704    }
3705
3706    /// Wraps every `stream_plan` operator's output: counts produced rows
3707    /// against the guard's intermediate-row limit, and FUSES the stream
3708    /// after the first `Err` — `next()` returns `None` from then on, so
3709    /// the erroring operator (and everything beneath it) is never polled
3710    /// again. The operator closures in `stream_plan` rely on this instead
3711    /// of each tracking its own post-error `done` flag: after they emit an
3712    /// `Err`, this wrapper guarantees they're not resumed.
3713    fn count_stream<'s>(mut stream: RowStream<'s>, guard: &'s ExecutionGuard<'_>) -> RowStream<'s> {
3714        let mut produced = 0usize;
3715        let mut done = false;
3716        Box::new(std::iter::from_fn(move || {
3717            if done {
3718                return None;
3719            }
3720            let item = stream.next()?;
3721            if item.is_ok() {
3722                produced = match produced.checked_add(1) {
3723                    Some(produced) => produced,
3724                    None => {
3725                        done = true;
3726                        return Some(Err(QueryError::ResourceLimit(
3727                            "stream row counter overflow".into(),
3728                        )));
3729                    }
3730                };
3731                if let Err(error) = guard.check_intermediate_rows(produced) {
3732                    done = true;
3733                    return Some(Err(error));
3734                }
3735            } else {
3736                done = true;
3737            }
3738            Some(item)
3739        }))
3740    }
3741
3742    /// Fast path for aggregating expansion chains -- one or two `Expand`
3743    /// hops feeding a `WITH` that groups by the final node and computes
3744    /// `count(*)` and/or `collect(<mid-node>.prop)`:
3745    ///
3746    /// ```text
3747    /// MATCH (s ...)-[:X]-(b)            WITH b, count(*) ...           (1 hop)
3748    /// MATCH (s ...)-[:X]-(a)-[:Y]-(b)   WITH b, count(*) ...           (2 hops)
3749    /// MATCH (s ...)-[:X]-(a)-[:Y]-(b)   WITH b, collect(a.p), count(*) (2 hops)
3750    /// ```
3751    ///
3752    /// Counts/collects in a tight loop over `neighbors_in_txn` instead of
3753    /// materializing a `BindingRow` per intermediate path. Motivation is
3754    /// measured, not assumed: the same algorithm hand-rolled runs in ~1ms
3755    /// where the generic pipeline takes ~100ms on the recommendations
3756    /// dataset (`marsdb/examples/csr_falsifier.rs`) -- the row machinery,
3757    /// not storage, is ~99% of that query's time; the first (2-hop count)
3758    /// entry measured ~25x end-to-end on that suite.
3759    ///
3760    /// Deliberately conservative: returns `Ok(None)` (generic path) for
3761    /// ANY shape it doesn't fully recognize. What it accepts:
3762    /// - plan = `[Filter*] Expand([Filter*] Expand(leaf))` or
3763    ///   `[Filter*] Expand(leaf)`, every expansion single-typed (or
3764    ///   untyped) and directed (no `Either`), leaf free of any
3765    ///   expansion/`Seed` (evaluated via the generic stream);
3766    /// - filters drawn only from the shapes `build_match_plan`
3767    ///   synthesizes here: `HasLabel` on the hop nodes, and the
3768    ///   edge-isomorphism `Not(VarEq(r2, r1))` between the two hops
3769    ///   (honored in-loop by skipping `e2.edge_id == e1.edge_id`);
3770    /// - `WITH` = `Var(final-node)` plus any mix of `count(*)` and
3771    ///   `collect(<mid-node>.prop)` (2-hop only, non-DISTINCT), no
3772    ///   `*`/`WHERE`, ORDER BY only on the count column;
3773    /// - no carried bindings entering the clause.
3774    ///
3775    /// `collect()` skips null/absent values (real Cypher's rule), reads
3776    /// the property through the per-prop directory path, and memoizes it
3777    /// per mid-node. Group and in-group encounter order both follow
3778    /// traversal order, matching the generic grouping pass's
3779    /// first-encounter semantics for ORDER BY ties and collect contents.
3780    ///
3781    /// `HasLabel` checks use per-label node-id sets loaded once via
3782    /// `NODE_LABEL_INDEX` -- O(label size) setup instead of a per-candidate
3783    /// record read in the hot loop.
3784    fn try_fast_expand_expand_count(
3785        &self,
3786        txn: Txn,
3787        plan: &LogicalPlan,
3788        with: &Option<WithClause>,
3789        current_rows: &[BindingRow],
3790        // When this MATCH is the statement's final clause and the tail is
3791        // a plain (non-aggregating, non-DISTINCT) RETURN whose ORDER
3792        // BY/SKIP/LIMIT ride on the count column, the hint lets the loop
3793        // sort groups and keep only skip+limit of them BEFORE building
3794        // any rows -- the generic tail then re-sorts and slices that tiny
3795        // prefix exactly (same key, same tie order), so semantics are
3796        // unchanged while the 6k-groups-for-a-LIMIT-5 case stops
3797        // materializing 6k rows. Measured motivation: inception's
3798        // remaining ~40ms was almost entirely this tail.
3799        tail_hint: Option<(&ReturnExpr, SortDir, usize)>,
3800        guard: &ExecutionGuard<'_>,
3801    ) -> Result<Option<FastCountResult>, QueryError> {
3802        // -- clause-context checks --------------------------------------
3803        if current_rows.len() != 1 || !current_rows[0].is_empty() {
3804            return Ok(None);
3805        }
3806        let Some(with) = with else { return Ok(None) };
3807        if with.star || with.distinct || with.where_clause.is_some() || with.items.len() < 2 {
3808            return Ok(None);
3809        }
3810
3811        // -- plan shape: 1 or 2 Expand stages over a non-expanding leaf --
3812        fn peel<'p>(mut plan: &'p LogicalPlan, preds: &mut Vec<&'p Expr>) -> &'p LogicalPlan {
3813            while let LogicalPlan::Filter { input, predicate } = plan {
3814                push_conjunct_refs(predicate, preds);
3815                plan = input;
3816            }
3817            plan
3818        }
3819        fn push_conjunct_refs<'p>(expr: &'p Expr, out: &mut Vec<&'p Expr>) {
3820            if let Expr::And(l, r) = expr {
3821                push_conjunct_refs(l, out);
3822                push_conjunct_refs(r, out);
3823            } else {
3824                out.push(expr);
3825            }
3826        }
3827        struct Stage<'p> {
3828            from: &'p str,
3829            to: &'p str,
3830            rel_var: Option<&'p str>,
3831            label: Option<&'p str>,
3832            dir: Direction,
3833            preds: Vec<&'p Expr>,
3834        }
3835        // Collected outermost-first, reversed to innermost-first below.
3836        let mut stages: Vec<Stage<'_>> = Vec::new();
3837        let mut cursor = plan;
3838        let leaf = loop {
3839            let mut preds = Vec::new();
3840            match peel(cursor, &mut preds) {
3841                LogicalPlan::Expand {
3842                    input,
3843                    from_var,
3844                    to_var,
3845                    rel_var,
3846                    rel_labels,
3847                    direction,
3848                } if stages.len() < 2 => {
3849                    let (Some(dir), Some(label)) =
3850                        (fast_direction(*direction), fast_label(rel_labels))
3851                    else {
3852                        return Ok(None);
3853                    };
3854                    stages.push(Stage {
3855                        from: from_var,
3856                        to: to_var,
3857                        rel_var: rel_var.as_deref(),
3858                        label,
3859                        dir,
3860                        preds,
3861                    });
3862                    cursor = input;
3863                }
3864                _ => {
3865                    if stages.is_empty() || plan_contains_expansion(cursor) {
3866                        return Ok(None);
3867                    }
3868                    // The leaf keeps its own filter chain (`cursor`, not
3869                    // the peeled node): a start-node predicate the planner
3870                    // pushed down (`WHERE m.title = ...` without an index)
3871                    // is just part of leaf evaluation, which runs through
3872                    // the generic stream anyway.
3873                    break cursor;
3874                }
3875            }
3876        };
3877        stages.reverse(); // innermost (hop 1) first
3878        if stages.len() == 2 && stages[1].from != stages[0].to {
3879            return Ok(None);
3880        }
3881        let final_to = stages.last().expect("at least one stage").to;
3882        let origin = stages[0].from;
3883        let mid_var = (stages.len() == 2).then(|| stages[0].to);
3884
3885        // -- WITH-shape: Var(final) + {count(*) | collect(mid.prop)}* ----
3886        enum OutCol<'p> {
3887            Group,
3888            Count,
3889            Collect(&'p str), // mid-node property name
3890        }
3891        let mut cols: Vec<OutCol<'_>> = Vec::with_capacity(with.items.len());
3892        // The grouping key: either the chain's far end (collaborative
3893        // filtering) or its origin (matrix_review_counts groups by the
3894        // seed and counts its expansions).
3895        let mut group_seen = false;
3896        let mut group_by_origin = false;
3897        let mut count_seen = false;
3898        for item in &with.items {
3899            match &item.expr {
3900                ReturnExpr::Var(v) if v == final_to && !group_seen => {
3901                    group_seen = true;
3902                    cols.push(OutCol::Group);
3903                }
3904                ReturnExpr::Var(v) if v == origin && !group_seen => {
3905                    group_seen = true;
3906                    group_by_origin = true;
3907                    cols.push(OutCol::Group);
3908                }
3909                ReturnExpr::CountStar if !count_seen => {
3910                    count_seen = true;
3911                    cols.push(OutCol::Count);
3912                }
3913                ReturnExpr::Call {
3914                    name,
3915                    args,
3916                    distinct: false,
3917                } if name.eq_ignore_ascii_case("collect") => {
3918                    let [ReturnExpr::Prop(pa)] = args.as_slice() else {
3919                        return Ok(None);
3920                    };
3921                    let Some(mid) = mid_var else { return Ok(None) };
3922                    if pa.var != mid {
3923                        return Ok(None);
3924                    }
3925                    cols.push(OutCol::Collect(&pa.prop));
3926                }
3927                _ => return Ok(None),
3928            }
3929        }
3930        if !group_seen {
3931            return Ok(None);
3932        }
3933        let names: Vec<String> = with
3934            .items
3935            .iter()
3936            .enumerate()
3937            .map(with_item_output_name)
3938            .collect();
3939        let count_name = cols
3940            .iter()
3941            .position(|c| matches!(c, OutCol::Count))
3942            .map(|i| names[i].as_str());
3943        // ORDER BY: only "by the count column" (any direction) or absent.
3944        let mut pre_keep: Option<usize> = None;
3945        let count_sort: Option<SortDir> = match &with.order_by {
3946            None => {
3947                // No WITH-level ordering: the tail hint (final clause,
3948                // plain RETURN ordered by the count column) can take over.
3949                match tail_hint {
3950                    Some((key, dir, keep)) if with.skip.is_none() && with.limit.is_none() => {
3951                        let matches_count = match key {
3952                            ReturnExpr::Var(v) => count_name == Some(v.as_str()),
3953                            ReturnExpr::CountStar => count_seen,
3954                            _ => false,
3955                        };
3956                        if matches_count {
3957                            pre_keep = Some(keep);
3958                            Some(dir)
3959                        } else {
3960                            None
3961                        }
3962                    }
3963                    _ => None,
3964                }
3965            }
3966            Some(keys) => {
3967                let [(key, dir)] = keys.as_slice() else {
3968                    return Ok(None);
3969                };
3970                let matches_count = match key {
3971                    ReturnExpr::Var(v) => count_name == Some(v.as_str()),
3972                    ReturnExpr::CountStar => count_seen,
3973                    _ => false,
3974                };
3975                if !matches_count {
3976                    return Ok(None);
3977                }
3978                Some(*dir)
3979            }
3980        };
3981
3982        // -- predicate classification per stage --------------------------
3983        let mut stage_label_filters: Vec<Vec<&str>> = vec![Vec::new(); stages.len()];
3984        let mut isomorphism = false;
3985        for (i, stage) in stages.iter().enumerate() {
3986            for pred in &stage.preds {
3987                match pred {
3988                    Expr::HasLabel(v, l) if v == stage.to => stage_label_filters[i].push(l),
3989                    Expr::Not(inner) if i == 1 => {
3990                        match (&**inner, stages[0].rel_var, stage.rel_var) {
3991                            (Expr::VarEq(x, y), Some(r1), Some(r2))
3992                                if (x == r1 && y == r2) || (x == r2 && y == r1) =>
3993                            {
3994                                isomorphism = true;
3995                            }
3996                            _ => return Ok(None),
3997                        }
3998                    }
3999                    _ => return Ok(None),
4000                }
4001            }
4002        }
4003
4004        // -- resolve everything the loop needs ---------------------------
4005        let skip = self.resolve_skip_limit(txn, with.skip.as_ref(), "SKIP", guard)?;
4006        let limit = self.resolve_skip_limit(txn, with.limit.as_ref(), "LIMIT", guard)?;
4007        let label_set = |label: &str| -> Result<std::collections::HashSet<u64>, QueryError> {
4008            Ok(
4009                GraphStore::all_node_ids_limited_in_txn(txn, Some(label), usize::MAX)?
4010                    .into_iter()
4011                    .map(|n| n.0)
4012                    .collect(),
4013            )
4014        };
4015        let stage_sets: Vec<Vec<std::collections::HashSet<u64>>> = stage_label_filters
4016            .iter()
4017            .map(|labels| labels.iter().map(|l| label_set(l)).collect())
4018            .collect::<Result<_, _>>()?;
4019        // Collected properties: resolve names to interned ids once.
4020        let collect_prop_ids: Vec<Option<u32>> = cols
4021            .iter()
4022            .map(|c| match c {
4023                OutCol::Collect(prop) => self.prop_id_for(txn, prop),
4024                _ => Ok(None),
4025            })
4026            .collect::<Result<_, _>>()?;
4027
4028        // Seed nodes. For a filtered scan/seek leaf, enumerate candidate
4029        // ids directly and evaluate the leaf's predicates against ONE
4030        // reused row buffer -- the generic stream builds a fresh
4031        // `HashMap` row per candidate, which for an unindexed predicate
4032        // over a big label (matrix_review_counts: `title CONTAINS` over
4033        // 9k movies) was the query's remaining cost. Any leaf shape this
4034        // doesn't cover falls back to the generic stream.
4035        let mut seeds = Vec::new();
4036        let mut leaf_preds = Vec::new();
4037        let leaf_base = peel(leaf, &mut leaf_preds);
4038        let leaf_candidates: Option<Vec<NodeId>> = match leaf_base {
4039            LogicalPlan::AllNodesScan { var } if var == stages[0].from => Some(
4040                GraphStore::all_node_ids_limited_in_txn(txn, None, usize::MAX)?,
4041            ),
4042            LogicalPlan::NodeByLabelScan { var, label } if var == stages[0].from => Some(
4043                GraphStore::all_node_ids_limited_in_txn(txn, Some(label), usize::MAX)?,
4044            ),
4045            LogicalPlan::IndexSeek {
4046                var,
4047                label,
4048                prop,
4049                value: crate::ir::IndexSeekValue::Fixed(value),
4050            } if var == stages[0].from => {
4051                Some(GraphStore::lookup_by_index_in_txn(txn, label, prop, value)?)
4052            }
4053            _ => None,
4054        };
4055        match leaf_candidates {
4056            Some(candidates) => {
4057                // All-simple-predicate leaves (`var.prop <op> literal`,
4058                // matrix's `title CONTAINS ...`) evaluate through one
4059                // pre-opened NODES handle and the shared `compare` --
4060                // no per-candidate table open, no probe row, no
4061                // `eval_expr` dispatch. Anything else keeps the probe-row
4062                // route below.
4063                let simple: Option<Vec<(&PropAccess, CompareOp, &Literal)>> = leaf_preds
4064                    .iter()
4065                    .map(|pred| match pred {
4066                        Expr::Compare(pa, op, lit) if pa.var == stages[0].from => {
4067                            Some((pa, *op, lit))
4068                        }
4069                        _ => None,
4070                    })
4071                    .collect();
4072                if let Some(simple) = simple {
4073                    let pred_ids: Vec<Option<u32>> = simple
4074                        .iter()
4075                        .map(|(pa, _, _)| self.prop_id_for(txn, &pa.prop))
4076                        .collect::<Result<_, _>>()?;
4077                    let mut read_prop = GraphStore::node_prop_reader(txn)?;
4078                    'cand: for id in candidates {
4079                        guard.checkpoint()?;
4080                        for ((_, op, lit), prop_id) in simple.iter().zip(&pred_ids) {
4081                            let value = match prop_id {
4082                                Some(pid) => read_prop(id, *pid)?.flatten(),
4083                                None => None,
4084                            };
4085                            if compare(&value, *op, lit) != Some(true) {
4086                                continue 'cand;
4087                            }
4088                        }
4089                        seeds.push(id);
4090                    }
4091                } else {
4092                    let mut probe = BindingRow::new();
4093                    for id in candidates {
4094                        guard.checkpoint()?;
4095                        probe.insert(stages[0].from.to_string(), Binding::Node(id));
4096                        let mut pass = true;
4097                        for pred in &leaf_preds {
4098                            if self.eval_expr(txn, pred, &probe, guard)? != Some(true) {
4099                                pass = false;
4100                                break;
4101                            }
4102                        }
4103                        if pass {
4104                            seeds.push(id);
4105                        }
4106                    }
4107                }
4108            }
4109            None => {
4110                for row in self.eval_plan(txn, leaf, current_rows, guard)? {
4111                    match row.get(stages[0].from) {
4112                        Some(Binding::Node(id)) => seeds.push(*id),
4113                        _ => return Ok(None),
4114                    }
4115                }
4116            }
4117        }
4118
4119        // -- the tight loop ----------------------------------------------
4120        struct Group {
4121            count: i64,
4122            collects: Vec<Vec<Value>>,
4123        }
4124        let n_collects = cols
4125            .iter()
4126            .filter(|c| matches!(c, OutCol::Collect(_)))
4127            .count();
4128        let mut order: Vec<u64> = Vec::new();
4129        let mut groups: HashMap<u64, Group> = HashMap::new();
4130        // Per-mid-node property memo: the same mid node recurs across
4131        // seeds/edges and its collected property is stable within the
4132        // snapshot.
4133        let mut mid_prop_memo: HashMap<(u64, u32), Option<Value>> = HashMap::new();
4134        let mut mid_values: Vec<Option<Value>> = vec![None; n_collects];
4135        let one_hop = stages.len() == 1;
4136        for &s in &seeds {
4137            guard.checkpoint()?;
4138            for e1 in GraphStore::neighbors_in_txn(txn, s, stages[0].dir, stages[0].label)? {
4139                guard.relationship_expansion()?;
4140                if !stage_sets[0].iter().all(|set| set.contains(&e1.other.0)) {
4141                    continue;
4142                }
4143                if one_hop {
4144                    let key = if group_by_origin { s.0 } else { e1.other.0 };
4145                    let group = groups.entry(key).or_insert_with(|| {
4146                        order.push(key);
4147                        Group {
4148                            count: 0,
4149                            collects: vec![Vec::new(); n_collects],
4150                        }
4151                    });
4152                    group.count += 1;
4153                    continue;
4154                }
4155                // Resolve this mid node's collected properties once.
4156                let mut ci = 0usize;
4157                for (col, prop_id) in cols.iter().zip(&collect_prop_ids) {
4158                    if let OutCol::Collect(_) = col {
4159                        mid_values[ci] = match prop_id {
4160                            Some(pid) => mid_prop_memo
4161                                .entry((e1.other.0, *pid))
4162                                .or_insert_with(|| {
4163                                    GraphStore::get_node_prop_in_txn(txn, e1.other, *pid)
4164                                        .ok()
4165                                        .flatten()
4166                                        .flatten()
4167                                        .map(property_value_to_value)
4168                                })
4169                                .clone(),
4170                            None => None, // never-interned property: absent everywhere
4171                        };
4172                        ci += 1;
4173                    }
4174                }
4175                guard.checkpoint()?;
4176                for e2 in
4177                    GraphStore::neighbors_in_txn(txn, e1.other, stages[1].dir, stages[1].label)?
4178                {
4179                    guard.relationship_expansion()?;
4180                    if isomorphism && e2.edge_id == e1.edge_id {
4181                        continue;
4182                    }
4183                    if !stage_sets[1].iter().all(|set| set.contains(&e2.other.0)) {
4184                        continue;
4185                    }
4186                    let key = if group_by_origin { s.0 } else { e2.other.0 };
4187                    let group = groups.entry(key).or_insert_with(|| {
4188                        order.push(key);
4189                        Group {
4190                            count: 0,
4191                            collects: vec![Vec::new(); n_collects],
4192                        }
4193                    });
4194                    group.count += 1;
4195                    for (ci, value) in mid_values.iter().enumerate() {
4196                        // collect() skips nulls, real Cypher's rule.
4197                        if let Some(v) = value {
4198                            group.collects[ci].push(v.clone());
4199                        }
4200                    }
4201                }
4202            }
4203        }
4204
4205        // -- project, order, skip/limit ----------------------------------
4206        let mut grouped: Vec<(u64, Group)> = order
4207            .into_iter()
4208            .map(|id| {
4209                let group = groups.remove(&id).expect("group recorded in order");
4210                (id, group)
4211            })
4212            .collect();
4213        match count_sort {
4214            Some(SortDir::Asc) => grouped.sort_by_key(|(_, g)| g.count),
4215            Some(SortDir::Desc) => grouped.sort_by_key(|(_, g)| std::cmp::Reverse(g.count)),
4216            None => {}
4217        }
4218        if let Some(keep) = pre_keep {
4219            grouped.truncate(keep);
4220        }
4221        let skip_n = skip.unwrap_or(0).max(0) as usize;
4222        if skip_n > 0 {
4223            grouped.drain(0..skip_n.min(grouped.len()));
4224        }
4225        if let Some(limit) = limit {
4226            grouped.truncate(limit.max(0) as usize);
4227        }
4228        let rows: Vec<BindingRow> = grouped
4229            .into_iter()
4230            .map(|(id, group)| {
4231                let mut row = BindingRow::new();
4232                let mut collects = group.collects.into_iter();
4233                for (col, name) in cols.iter().zip(&names) {
4234                    let binding = match col {
4235                        OutCol::Group => Binding::Node(NodeId(id)),
4236                        OutCol::Count => Binding::Value(PropertyValue::Int(group.count)),
4237                        OutCol::Collect(_) => {
4238                            Binding::List(collects.next().expect("one list per collect column"))
4239                        }
4240                    };
4241                    row.insert(name.clone(), binding);
4242                }
4243                row
4244            })
4245            .collect();
4246        if std::env::var("MARSDB_FAST_DEBUG").is_ok() {
4247            eprintln!(
4248                "[fast-path FIRED] stages={} groups={}",
4249                stages.len(),
4250                rows.len()
4251            );
4252        }
4253        Ok(Some((rows, names.into_iter().collect())))
4254    }
4255
4256    fn stream_scan<'s>(
4257        &'s self,
4258        txn: Txn<'s>,
4259        var: &'s str,
4260        label: Option<&'s str>,
4261        seed: &'s [BindingRow],
4262        guard: &'s ExecutionGuard<'_>,
4263        row_limit: Option<usize>,
4264    ) -> RowStream<'s> {
4265        let mut initialized = false;
4266        let mut node_ids = Vec::new();
4267        let mut seed_index = 0usize;
4268        let mut node_index = 0usize;
4269        let mut done = false;
4270        let stream = std::iter::from_fn(move || {
4271            if done || seed.is_empty() {
4272                return None;
4273            }
4274            if !initialized {
4275                initialized = true;
4276                let budget_node_limit = guard.options.max_intermediate_rows.map(|max_rows| {
4277                    max_rows
4278                        .checked_div(seed.len())
4279                        .unwrap_or(0)
4280                        .saturating_add(1)
4281                });
4282                let storage_limit = match (row_limit, budget_node_limit) {
4283                    (Some(a), Some(b)) => Some(a.min(b)),
4284                    (Some(a), None) => Some(a),
4285                    (None, Some(b)) => Some(b),
4286                    (None, None) => None,
4287                };
4288                let storage_limit = storage_limit.unwrap_or(usize::MAX);
4289                match GraphStore::all_node_ids_limited_in_txn(txn, label, storage_limit) {
4290                    Ok(ids) => node_ids = ids,
4291                    Err(error) => {
4292                        done = true;
4293                        return Some(Err(error.into()));
4294                    }
4295                }
4296            }
4297            if node_ids.is_empty() || seed_index >= seed.len() {
4298                return None;
4299            }
4300            if let Err(error) = guard.checkpoint() {
4301                done = true;
4302                return Some(Err(error));
4303            }
4304            let mut row = seed[seed_index].clone();
4305            row.insert(var.to_string(), Binding::Node(node_ids[node_index]));
4306            node_index += 1;
4307            if node_index == node_ids.len() {
4308                node_index = 0;
4309                seed_index += 1;
4310            }
4311            Some(Ok(row))
4312        });
4313        Self::count_stream(Box::new(stream), guard)
4314    }
4315
4316    /// `LogicalPlan::IndexSeek`'s streaming operator -- same cross-join-
4317    /// against-`seed` shape as `stream_scan`, but the id list comes from
4318    /// one exact-match `PROPERTY_INDEX` lookup instead of a label scan.
4319    /// `row_limit` bounds the lookup itself the same way `stream_scan`'s
4320    /// does -- a non-unique index can still match far more nodes than a
4321    /// `LIMIT` needs, so the same "ask storage for at most the budget,
4322    /// not everything" reasoning applies, just against `PROPERTY_INDEX`
4323    /// instead of `NODE_LABEL_INDEX`.
4324    ///
4325    /// `spec.value` is either fixed for the whole seek (a literal, or a
4326    /// `$param` already resolved to one -- looked up once, reused across
4327    /// every seed row, same as before this `enum` existed) or row-
4328    /// dependent (`IndexSeekValue::RowExpr`, e.g. `row.field` from an
4329    /// enclosing `UNWIND`) -- re-evaluated and re-looked-up for each seed
4330    /// row, since a different row can mean a different lookup value. This
4331    /// is the fix for what was previously *always* a `NodeByLabelScan` +
4332    /// `Filter` for that shape (`planner::apply_index_seeks` only
4333    /// recognized a literal-valued equality, never a per-row one) -- an
4334    /// O(label size) scan repeated per incoming row, the exact pattern a
4335    /// bulk import's relationship-creation pass hits hardest.
4336    /// `IndexRangeSeek` evaluation: one bounded index scan, reused
4337    /// across every seed row (same cross-join shape as
4338    /// `stream_index_seek`'s `Fixed` arm). The residual `Filter` the
4339    /// planner keeps above this node applies the exact predicate; this
4340    /// stream only narrows candidates.
4341    #[allow(clippy::too_many_arguments)]
4342    /// `EdgeTypeScan` evaluation: a demand-driven sequential sweep of
4343    /// the whole `EDGES` table (chunked `EdgeScanCursor`, raw record
4344    /// bytes in hand), binding the full single-hop pattern per matching
4345    /// edge. Rejection order is cheapest-first: type id, then the
4346    /// pushed-down relationship predicate straight off the record bytes
4347    /// (no storage get), then endpoint label checks through the
4348    /// statement node cache. Matches accumulate so later seed rows
4349    /// replay them (same cross-join contract as every other leaf).
4350    fn stream_edge_type_scan<'s>(
4351        &'s self,
4352        txn: Txn<'s>,
4353        spec: EdgeTypeScanSpec<'s>,
4354        seed: &'s [BindingRow],
4355        guard: &'s ExecutionGuard<'_>,
4356    ) -> RowStream<'s> {
4357        const CHUNK: usize = 512;
4358        /// One-time per-scan resolutions, done lazily on first pull.
4359        struct ScanPrep {
4360            /// `None` = untyped hop (any edge).
4361            type_ids: Option<Vec<u32>>,
4362            prop_ids: HashMap<String, Option<u32>>,
4363        }
4364        let mut prepared: Option<ScanPrep> = None;
4365        let mut cursor = GraphStore::edge_scan_cursor();
4366        let mut matched: Vec<(u64, u64, u64)> = Vec::new(); // (edge, src, dst)
4367        let mut exhausted = false;
4368        let mut seed_index = 0usize;
4369        let mut match_index = 0usize;
4370        let mut done = false;
4371        let stream = std::iter::from_fn(move || {
4372            if done || seed.is_empty() {
4373                return None;
4374            }
4375            loop {
4376                if prepared.is_none() {
4377                    let type_ids = match resolve_type_ids(txn, spec.rel_types) {
4378                        Ok(ids) => ids,
4379                        Err(error) => {
4380                            done = true;
4381                            return Some(Err(error));
4382                        }
4383                    };
4384                    let mut prop_ids = HashMap::new();
4385                    if let Some(pred) = spec.rel_predicate {
4386                        if let Err(error) = self.collect_scan_prop_ids(txn, pred, &mut prop_ids) {
4387                            done = true;
4388                            return Some(Err(error));
4389                        }
4390                    }
4391                    prepared = Some(ScanPrep { type_ids, prop_ids });
4392                }
4393                let ScanPrep { type_ids, prop_ids } = prepared.as_ref().expect("set above");
4394                // An impossible type list (a name never interned) can
4395                // never match anything.
4396                if type_ids.as_ref().is_some_and(|ids| ids.is_empty()) {
4397                    return None;
4398                }
4399
4400                if !exhausted && match_index >= matched.len() && seed_index == 0 {
4401                    let chunk = match cursor.next_chunk(txn, CHUNK) {
4402                        Ok(c) => c,
4403                        Err(error) => {
4404                            done = true;
4405                            return Some(Err(error.into()));
4406                        }
4407                    };
4408                    if chunk.len() < CHUNK {
4409                        exhausted = true;
4410                    }
4411                    for (id, bytes) in chunk {
4412                        if let Err(error) = guard.checkpoint() {
4413                            done = true;
4414                            return Some(Err(error));
4415                        }
4416                        let (label_id, src, dst) = match GraphStore::edge_record_header(&bytes) {
4417                            Ok(h) => h,
4418                            Err(error) => {
4419                                done = true;
4420                                return Some(Err(error.into()));
4421                            }
4422                        };
4423                        if type_ids
4424                            .as_ref()
4425                            .is_some_and(|ids| !ids.contains(&label_id))
4426                        {
4427                            continue;
4428                        }
4429                        if let Some(pred) = spec.rel_predicate {
4430                            match eval_scan_predicate(&bytes, pred, prop_ids) {
4431                                Ok(true) => {}
4432                                Ok(false) => continue,
4433                                Err(error) => {
4434                                    done = true;
4435                                    return Some(Err(error));
4436                                }
4437                            }
4438                        }
4439                        match self.scan_endpoints_pass(
4440                            txn,
4441                            src,
4442                            dst,
4443                            spec.src_label,
4444                            spec.dst_label,
4445                        ) {
4446                            Ok(true) => matched.push((id, src, dst)),
4447                            Ok(false) => {}
4448                            Err(error) => {
4449                                done = true;
4450                                return Some(Err(error));
4451                            }
4452                        }
4453                    }
4454                    continue;
4455                }
4456                if match_index >= matched.len() {
4457                    if exhausted {
4458                        seed_index += 1;
4459                        match_index = 0;
4460                        if seed_index >= seed.len() || matched.is_empty() {
4461                            return None;
4462                        }
4463                    } else {
4464                        continue;
4465                    }
4466                }
4467                if let Err(error) = guard.checkpoint() {
4468                    done = true;
4469                    return Some(Err(error));
4470                }
4471                let (edge, src, dst) = matched[match_index];
4472                match_index += 1;
4473                let mut row = seed[seed_index].clone();
4474                row.insert(spec.src_var.to_string(), Binding::Node(NodeId(src)));
4475                row.insert(spec.rel_var.to_string(), Binding::Edge(EdgeId(edge)));
4476                row.insert(spec.dst_var.to_string(), Binding::Node(NodeId(dst)));
4477                return Some(Ok(row));
4478            }
4479        });
4480        Self::count_stream(Box::new(stream), guard)
4481    }
4482
4483    /// Both endpoints exist (swept-edge invariant; a miss means
4484    /// corruption and is treated as non-match rather than a panic) and
4485    /// carry the required labels. Node-cache-backed: one decode per
4486    /// distinct node per statement.
4487    fn scan_endpoints_pass(
4488        &self,
4489        txn: Txn,
4490        src: u64,
4491        dst: u64,
4492        src_label: Option<&str>,
4493        dst_label: Option<&str>,
4494    ) -> Result<bool, QueryError> {
4495        for (id, wanted) in [(src, src_label), (dst, dst_label)] {
4496            let Some(label) = wanted else { continue };
4497            let Some(node) = self.get_node_cached(txn, NodeId(id))? else {
4498                return Ok(false);
4499            };
4500            if !node.labels.iter().any(|l| l == label) {
4501                return Ok(false);
4502            }
4503        }
4504        Ok(true)
4505    }
4506
4507    /// Interned ids for every prop the scan predicate references --
4508    /// resolved once per scan through the statement memo. A name never
4509    /// interned maps to `None` (absent on every record by construction).
4510    fn collect_scan_prop_ids(
4511        &self,
4512        txn: Txn,
4513        pred: &Expr,
4514        out: &mut HashMap<String, Option<u32>>,
4515    ) -> Result<(), QueryError> {
4516        match pred {
4517            Expr::And(l, r) => {
4518                self.collect_scan_prop_ids(txn, l, out)?;
4519                self.collect_scan_prop_ids(txn, r, out)?;
4520            }
4521            Expr::Not(inner) => self.collect_scan_prop_ids(txn, inner, out)?,
4522            Expr::Compare(pa, _, _) | Expr::IsNull(pa) => {
4523                if !out.contains_key(&pa.prop) {
4524                    let id = self.prop_id_for(txn, &pa.prop)?;
4525                    out.insert(pa.prop.clone(), id);
4526                }
4527            }
4528            other => {
4529                return Err(QueryError::Semantic(format!(
4530                    "internal: non-scan-evaluable predicate reached EdgeTypeScan: {other:?}"
4531                )))
4532            }
4533        }
4534        Ok(())
4535    }
4536
4537    /// `IndexRangeSeek` evaluation: a demand-driven bounded index scan
4538    /// (chunked refills through `IndexRangeCursor`, O(log n) re-seek per
4539    /// refill), cross-joined with every seed row -- same join shape as
4540    /// `stream_index_seek`'s `Fixed` arm, but the ids are pulled as
4541    /// consumed instead of collected up front, so a `LIMIT`ed consumer
4542    /// that stops early never pays for the rest of the range. The
4543    /// residual `Filter` the planner keeps above this node applies the
4544    /// exact predicate; this stream only narrows candidates.
4545    ///
4546    /// Multi-seed note: the chunk buffer grows to the full match set
4547    /// only when several seed rows each need the whole range (the
4548    /// cross-join semantics require it); the single-seed case -- every
4549    /// top-level `MATCH (n:L) WHERE n.p > x` -- stays incremental.
4550    #[allow(clippy::too_many_arguments)]
4551    fn stream_index_range_seek<'s>(
4552        &'s self,
4553        txn: Txn<'s>,
4554        var: &'s str,
4555        label: &'s str,
4556        prop: &'s str,
4557        lo: &'s Option<(PropertyValue, bool)>,
4558        hi: &'s Option<(PropertyValue, bool)>,
4559        seed: &'s [BindingRow],
4560        guard: &'s ExecutionGuard<'_>,
4561    ) -> RowStream<'s> {
4562        const CHUNK: usize = 512;
4563        let mut cursor: Option<Option<marsdb_graph::IndexRangeCursor>> = None;
4564        let mut ids: Vec<NodeId> = Vec::new();
4565        let mut exhausted = false;
4566        let mut seed_index = 0usize;
4567        let mut node_index = 0usize;
4568        let mut done = false;
4569        let stream = std::iter::from_fn(move || {
4570            if done || seed.is_empty() {
4571                return None;
4572            }
4573            loop {
4574                // Refill when the consumer has caught up with what's
4575                // fetched (only the first seed row drives fetching; later
4576                // seed rows replay the accumulated ids).
4577                if !exhausted && node_index >= ids.len() && seed_index == 0 {
4578                    let cur = match &mut cursor {
4579                        Some(c) => c,
4580                        None => {
4581                            let created = GraphStore::index_range_cursor_in_txn(
4582                                txn,
4583                                label,
4584                                prop,
4585                                lo.as_ref().map(|(v, incl)| (v, *incl)),
4586                                hi.as_ref().map(|(v, incl)| (v, *incl)),
4587                            );
4588                            match created {
4589                                Ok(c) => cursor.insert(c),
4590                                Err(error) => {
4591                                    done = true;
4592                                    return Some(Err(error.into()));
4593                                }
4594                            }
4595                        }
4596                    };
4597                    match cur {
4598                        None => exhausted = true,
4599                        Some(c) => match c.next_chunk(txn, CHUNK) {
4600                            Ok(chunk) => {
4601                                if chunk.len() < CHUNK {
4602                                    exhausted = true;
4603                                }
4604                                ids.extend(chunk);
4605                            }
4606                            Err(error) => {
4607                                done = true;
4608                                return Some(Err(error.into()));
4609                            }
4610                        },
4611                    }
4612                }
4613                if node_index >= ids.len() {
4614                    if exhausted {
4615                        seed_index += 1;
4616                        node_index = 0;
4617                        if seed_index >= seed.len() || ids.is_empty() {
4618                            return None;
4619                        }
4620                    } else {
4621                        continue;
4622                    }
4623                }
4624                if let Err(error) = guard.checkpoint() {
4625                    done = true;
4626                    return Some(Err(error));
4627                }
4628                let mut row = seed[seed_index].clone();
4629                row.insert(var.to_string(), Binding::Node(ids[node_index]));
4630                node_index += 1;
4631                return Some(Ok(row));
4632            }
4633        });
4634        Self::count_stream(Box::new(stream), guard)
4635    }
4636
4637    fn stream_index_seek<'s>(
4638        &'s self,
4639        txn: Txn<'s>,
4640        spec: IndexSeekSpec<'s>,
4641        seed: &'s [BindingRow],
4642        guard: &'s ExecutionGuard<'_>,
4643        row_limit: Option<usize>,
4644    ) -> RowStream<'s> {
4645        let budget_node_limit = guard.options.max_intermediate_rows.map(|max_rows| {
4646            max_rows
4647                .checked_div(seed.len().max(1))
4648                .unwrap_or(0)
4649                .saturating_add(1)
4650        });
4651        let storage_limit = match (row_limit, budget_node_limit) {
4652            (Some(a), Some(b)) => Some(a.min(b)),
4653            (Some(a), None) => Some(a),
4654            (None, Some(b)) => Some(b),
4655            (None, None) => None,
4656        };
4657        let lookup = move |value: &PropertyValue| -> Result<Vec<NodeId>, QueryError> {
4658            match storage_limit {
4659                Some(limit) => GraphStore::lookup_by_index_limited_in_txn(
4660                    txn, spec.label, spec.prop, value, limit,
4661                )
4662                .map_err(Into::into),
4663                None => GraphStore::lookup_by_index_in_txn(txn, spec.label, spec.prop, value)
4664                    .map_err(Into::into),
4665            }
4666        };
4667        match spec.value {
4668            // One lookup, reused across every seed row -- identical shape
4669            // to `stream_scan`'s own cross join, and to this function
4670            // before `IndexSeekValue` existed.
4671            IndexSeekValue::Fixed(value) => {
4672                let mut node_ids: Option<Vec<NodeId>> = None;
4673                let mut seed_index = 0usize;
4674                let mut node_index = 0usize;
4675                let mut done = false;
4676                let stream = std::iter::from_fn(move || {
4677                    if done || seed.is_empty() {
4678                        return None;
4679                    }
4680                    let ids = match &node_ids {
4681                        Some(ids) => ids,
4682                        None => match lookup(value) {
4683                            Ok(ids) => node_ids.insert(ids),
4684                            Err(error) => {
4685                                done = true;
4686                                return Some(Err(error));
4687                            }
4688                        },
4689                    };
4690                    if ids.is_empty() || seed_index >= seed.len() {
4691                        return None;
4692                    }
4693                    if let Err(error) = guard.checkpoint() {
4694                        done = true;
4695                        return Some(Err(error));
4696                    }
4697                    let mut row = seed[seed_index].clone();
4698                    row.insert(spec.var.to_string(), Binding::Node(ids[node_index]));
4699                    node_index += 1;
4700                    if node_index == ids.len() {
4701                        node_index = 0;
4702                        seed_index += 1;
4703                    }
4704                    Some(Ok(row))
4705                });
4706                Self::count_stream(Box::new(stream), guard)
4707            }
4708            // A fresh lookup per seed row -- `expr` (e.g. `row.field` from
4709            // an enclosing `UNWIND`) can evaluate to a different value for
4710            // each one, so last row's `node_ids` can't be reused for the
4711            // next.
4712            IndexSeekValue::RowExpr(expr) => {
4713                let mut node_ids: Vec<NodeId> = Vec::new();
4714                let mut seed_index = 0usize;
4715                let mut node_index = 0usize;
4716                let mut done = false;
4717                let stream = std::iter::from_fn(move || loop {
4718                    if done || seed_index >= seed.len() {
4719                        return None;
4720                    }
4721                    if node_index == 0 {
4722                        let evaluated =
4723                            match self.eval_return_expr(txn, expr, &seed[seed_index], guard) {
4724                                Ok(v) => v,
4725                                Err(error) => {
4726                                    done = true;
4727                                    return Some(Err(error));
4728                                }
4729                            };
4730                        let value = value_to_property_value(&evaluated);
4731                        // Real Cypher's three-valued logic: comparing
4732                        // against `null` is "unknown", not "find nodes
4733                        // whose stored value happens to be Null" -- this
4734                        // row contributes zero rows, same as the Filter
4735                        // fallback this replaces would reject it outright.
4736                        if matches!(value, PropertyValue::Null) {
4737                            seed_index += 1;
4738                            continue;
4739                        }
4740                        node_ids = match lookup(&value) {
4741                            Ok(ids) => ids,
4742                            Err(error) => {
4743                                done = true;
4744                                return Some(Err(error));
4745                            }
4746                        };
4747                        if node_ids.is_empty() {
4748                            seed_index += 1;
4749                            continue;
4750                        }
4751                    }
4752                    if let Err(error) = guard.checkpoint() {
4753                        done = true;
4754                        return Some(Err(error));
4755                    }
4756                    let mut row = seed[seed_index].clone();
4757                    row.insert(spec.var.to_string(), Binding::Node(node_ids[node_index]));
4758                    node_index += 1;
4759                    if node_index == node_ids.len() {
4760                        node_index = 0;
4761                        seed_index += 1;
4762                    }
4763                    return Some(Ok(row));
4764                });
4765                Self::count_stream(Box::new(stream), guard)
4766            }
4767        }
4768    }
4769
4770    fn expand_variable_row(
4771        &self,
4772        txn: Txn,
4773        row: BindingRow,
4774        spec: VarExpandSpec<'_>,
4775        guard: &ExecutionGuard<'_>,
4776    ) -> Result<Vec<BindingRow>, QueryError> {
4777        let start_id = match row.get(spec.from_var) {
4778            Some(Binding::Node(id)) => *id,
4779            Some(Binding::Value(PropertyValue::Null)) => return Ok(Vec::new()),
4780            _ => return Err(QueryError::UnboundVariable(spec.from_var.to_string())),
4781        };
4782        let mut out = Vec::new();
4783        if spec.min_hops == 0 {
4784            let mut new_row = row.clone();
4785            new_row.insert(spec.to_var.to_string(), Binding::Node(start_id));
4786            if let Some(path_segment_var) = spec.path_segment_var {
4787                new_row.insert(path_segment_var.to_string(), Binding::Path(Vec::new()));
4788            }
4789            if let Some(rel_list_var) = spec.rel_list_var {
4790                new_row.insert(rel_list_var.to_string(), Binding::List(Vec::new()));
4791            }
4792            new_row.insert(spec.exclude_edge_var.to_string(), Binding::Path(Vec::new()));
4793            out.push(new_row);
4794        }
4795        // `[:TYPE* {year: 1988}]` -- evaluated once here (constant across
4796        // the whole BFS, not per-candidate; the values can reference this
4797        // row's own already-bound variables, same as a fixed hop's inline
4798        // props already can) and checked against each candidate edge's
4799        // own stored properties during expansion below (TCK's Match4
4800        // `[5]`).
4801        let rel_props = spec
4802            .rel_props
4803            .iter()
4804            .map(|(key, expr)| {
4805                let value = self.eval_return_expr(txn, expr, &row, guard)?;
4806                Ok::<_, QueryError>((key.as_str(), value_to_property_value(&value)))
4807            })
4808            .collect::<Result<Vec<_>, _>>()?;
4809        let unbounded = spec.max_hops.is_none();
4810        let effective_max = spec.max_hops.unwrap_or(VAR_EXPAND_DEPTH_CAP);
4811        // Real Cypher's edge-isomorphism rule (no relationship repeated
4812        // within one MATCH pattern) applies across the *whole* pattern, not
4813        // just within this hop's own BFS -- seed the excluded set with
4814        // whatever edges earlier fixed hops of this same pattern already
4815        // bound, so this traversal can't walk back over one of them (see
4816        // `LogicalPlan::VarExpand`'s docs; found via TCK's Match5 `[27]`).
4817        // Complementary direction: an *earlier variable-length* hop's own
4818        // traversed-edge set (deposited under its own `exclude_edge_var`,
4819        // see `LogicalPlan::VarExpand`'s docs) -- union every such row's
4820        // `Binding::Path` edge ids in too (TCK's Match4 `[7]`).
4821        let seed_used_edges: HashSet<EdgeId> = spec
4822            .exclude_edge_vars
4823            .iter()
4824            .filter_map(|v| match row.get(v) {
4825                Some(Binding::Edge(id)) => Some(*id),
4826                _ => None,
4827            })
4828            .chain(spec.exclude_edge_sets.iter().flat_map(|v| {
4829                match row.get(v) {
4830                    Some(Binding::Path(segment)) => segment
4831                        .iter()
4832                        .filter_map(|p| match p {
4833                            PathBinding::Edge(id) => Some(*id),
4834                            PathBinding::Node(_) => None,
4835                        })
4836                        .collect::<Vec<_>>(),
4837                    _ => Vec::new(),
4838                }
4839            }))
4840            .collect();
4841        // The ordered `Edge, Node, Edge, Node, ...` sequence built up so
4842        // far, alongside the existing `used_edges` isomorphism set --
4843        // only actually consulted when `path_segment_var` is set (named-
4844        // path capture over this hop, see `LogicalPlan::VarExpand`'s own
4845        // docs), but always threaded through the BFS regardless (a plain
4846        // `Vec`, cheap to carry and clone even when unused).
4847        let mut frontier = vec![(start_id, seed_used_edges, Vec::<PathBinding>::new())];
4848        let mut depth = 0u32;
4849        while depth < effective_max && !frontier.is_empty() {
4850            depth += 1;
4851            let mut next_frontier = Vec::new();
4852            for (node, used_edges, segment) in frontier {
4853                for entry in neighbors_for_direction(txn, node, spec.direction, spec.rel_labels)? {
4854                    guard.relationship_expansion()?;
4855                    if used_edges.contains(&entry.edge_id) {
4856                        continue;
4857                    }
4858                    if !rel_props.is_empty() {
4859                        let edge = deleted_entity_access(GraphStore::get_edge_in_txn(
4860                            txn,
4861                            entry.edge_id,
4862                        )?)?;
4863                        let matches = rel_props
4864                            .iter()
4865                            .all(|(key, expected)| edge.props.get(*key) == Some(expected));
4866                        if !matches {
4867                            continue;
4868                        }
4869                    }
4870                    let mut next_used_edges = used_edges.clone();
4871                    next_used_edges.insert(entry.edge_id);
4872                    let mut next_segment = segment.clone();
4873                    next_segment.push(PathBinding::Edge(entry.edge_id));
4874                    next_segment.push(PathBinding::Node(entry.other));
4875                    next_frontier.push((entry.other, next_used_edges, next_segment.clone()));
4876                    guard.check_intermediate_rows(next_frontier.len())?;
4877                    if depth >= spec.min_hops {
4878                        let mut new_row = row.clone();
4879                        new_row.insert(spec.to_var.to_string(), Binding::Node(entry.other));
4880                        if let Some(path_segment_var) = spec.path_segment_var {
4881                            new_row.insert(
4882                                path_segment_var.to_string(),
4883                                Binding::Path(next_segment.clone()),
4884                            );
4885                        }
4886                        if let Some(rel_list_var) = spec.rel_list_var {
4887                            let edges = segment_edges_to_list(txn, &next_segment)?;
4888                            new_row.insert(rel_list_var.to_string(), edges);
4889                        }
4890                        new_row.insert(
4891                            spec.exclude_edge_var.to_string(),
4892                            Binding::Path(next_segment.clone()),
4893                        );
4894                        out.push(new_row);
4895                        guard.check_intermediate_rows(out.len())?;
4896                    }
4897                }
4898            }
4899            frontier = next_frontier;
4900            if depth == effective_max && unbounded && !frontier.is_empty() {
4901                return Err(QueryError::ResourceLimit(format!(
4902                    "variable-length traversal exceeded the safety depth cap ({VAR_EXPAND_DEPTH_CAP} \
4903                     hops) — likely a cyclic graph or unexpectedly large fanout; narrow the pattern or \
4904                     add an explicit upper bound (e.g. *0..10)"
4905                )));
4906            }
4907        }
4908        Ok(out)
4909    }
4910
4911    /// `LogicalPlan::MatchRelList`'s own docs -- deterministic, no search:
4912    /// `spec.rel_list_var`'s edges are already concrete, so there's
4913    /// exactly one possible walk to check, starting from `spec.from_var`'s
4914    /// already-bound node. Returns `Ok(None)` (row dropped, not an error)
4915    /// for every "doesn't match" case -- wrong hop count, a broken chain,
4916    /// an edge whose label isn't in `spec.rel_labels` -- same "no match
4917    /// survives" convention `Expand`/`VarExpand` already use for a filter
4918    /// that simply excludes a row.
4919    fn match_bound_rel_list_row(
4920        &self,
4921        row: BindingRow,
4922        spec: MatchRelListSpec<'_>,
4923    ) -> Result<Option<BindingRow>, QueryError> {
4924        let start_id = match row.get(spec.from_var) {
4925            Some(Binding::Node(id)) => *id,
4926            Some(Binding::Value(PropertyValue::Null)) => return Ok(None),
4927            _ => return Err(QueryError::UnboundVariable(spec.from_var.to_string())),
4928        };
4929        let edges: Vec<&Edge> = match row.get(spec.rel_list_var) {
4930            Some(Binding::List(items)) => items
4931                .iter()
4932                .map(|v| match v {
4933                    Value::Edge(e) => Ok(e),
4934                    other => Err(QueryError::Type(format!(
4935                        "'{}' must be a list of relationships, found {other:?} in it",
4936                        spec.rel_list_var
4937                    ))),
4938                })
4939                .collect::<Result<_, _>>()?,
4940            Some(Binding::Value(PropertyValue::Null)) => return Ok(None),
4941            _ => return Err(QueryError::UnboundVariable(spec.rel_list_var.to_string())),
4942        };
4943        let hops = edges.len() as u32;
4944        if hops < spec.min_hops || spec.max_hops.is_some_and(|max| hops > max) {
4945            return Ok(None);
4946        }
4947        if !spec.rel_labels.is_empty() && edges.iter().any(|e| !spec.rel_labels.contains(&e.label))
4948        {
4949            return Ok(None);
4950        }
4951        let mut current = start_id;
4952        for edge in &edges {
4953            let next = match spec.direction {
4954                ExpandDirection::Out if edge.src == current => edge.dst,
4955                ExpandDirection::In if edge.dst == current => edge.src,
4956                ExpandDirection::Either if edge.src == current => edge.dst,
4957                ExpandDirection::Either if edge.dst == current => edge.src,
4958                _ => return Ok(None),
4959            };
4960            current = next;
4961        }
4962        let mut new_row = row.clone();
4963        new_row.insert(spec.to_var.to_string(), Binding::Node(current));
4964        Ok(Some(new_row))
4965    }
4966
4967    /// `Option<bool>` — see `eval_with_expr`'s docs, same reasoning.
4968    /// `HasLabel`/`VarEq` never produce "unknown" (they operate on real
4969    /// bound node/edge identity, not a possibly-null property), so they
4970    /// always return `Some`.
4971    fn eval_expr(
4972        &self,
4973        txn: Txn,
4974        expr: &Expr,
4975        row: &BindingRow,
4976        guard: &ExecutionGuard<'_>,
4977    ) -> Result<Option<bool>, QueryError> {
4978        Ok(match expr {
4979            Expr::And(l, r) => and3(
4980                self.eval_expr(txn, l, row, guard)?,
4981                self.eval_expr(txn, r, row, guard)?,
4982            ),
4983            Expr::Or(l, r) => or3(
4984                self.eval_expr(txn, l, row, guard)?,
4985                self.eval_expr(txn, r, row, guard)?,
4986            ),
4987            Expr::Not(e) => self.eval_expr(txn, e, row, guard)?.map(|b| !b),
4988            Expr::Compare(pa, op, lit) => {
4989                let prop_value = self.lookup_prop(txn, pa, row)?;
4990                compare(&prop_value, *op, lit)
4991            }
4992            Expr::PropCompare(left, op, right) => {
4993                let a = self.lookup_prop(txn, left, row)?;
4994                let b = self.lookup_prop(txn, right, row)?;
4995                compare_property_pair_opt(&a, *op, &b)
4996            }
4997            // Always definite -- that's the whole point of IS NULL, so
4998            // this is the one `Expr` leaf that's always `Some`, same as
4999            // `HasLabel`/`VarEq` below.
5000            Expr::IsNull(pa) => Some(matches!(
5001                self.lookup_prop(txn, pa, row)?,
5002                None | Some(PropertyValue::Null)
5003            )),
5004            Expr::HasLabel(var, label) => {
5005                let binding = row
5006                    .get(var)
5007                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
5008                let Binding::Node(id) = binding else {
5009                    return Err(QueryError::UnboundVariable(var.clone()));
5010                };
5011                let node = self.get_node_cached(txn, *id)?;
5012                Some(node.is_some_and(|n| n.labels.iter().any(|l| l == label)))
5013            }
5014            Expr::VarEq(a, b) => {
5015                let ba = row
5016                    .get(a)
5017                    .ok_or_else(|| QueryError::UnboundVariable(a.clone()))?;
5018                let bb = row
5019                    .get(b)
5020                    .ok_or_else(|| QueryError::UnboundVariable(b.clone()))?;
5021                Some(match (ba, bb) {
5022                    (Binding::Node(x), Binding::Node(y)) => x == y,
5023                    (Binding::Edge(x), Binding::Edge(y)) => x == y,
5024                    // A null-padded `Binding::Value` (from an earlier
5025                    // OPTIONAL MATCH that didn't match) can't equal a
5026                    // real node/edge, and comparing across binding kinds
5027                    // (a node vs an edge) is never meaningful here — the
5028                    // planner only ever synthesizes VarEq between two
5029                    // occurrences of the same pattern variable, which are
5030                    // always the same kind when both are real.
5031                    _ => false,
5032                })
5033            }
5034            Expr::GeneralCompare(lhs, op, rhs) => {
5035                let lv = self.eval_return_expr(txn, lhs, row, guard)?;
5036                let rv = self.eval_return_expr(txn, rhs, row, guard)?;
5037                compare_values(&lv, *op, &rv)
5038            }
5039            Expr::GeneralIsNull(e) => Some(matches!(
5040                self.eval_return_expr(txn, e, row, guard)?,
5041                Value::Null
5042            )),
5043            Expr::GeneralBare(e) => self.eval_return_expr_bool3(txn, e, row, guard)?,
5044            // `WHERE (n)-[:REL]->()` etc (TCK's Pattern1) -- existential:
5045            // true iff at least one real match of `pattern` exists, with
5046            // every already-bound named endpoint (`n`, and `m` in `(n)-->
5047            // (m)` when `m` is also bound by an earlier MATCH) held fixed
5048            // to this row's own binding rather than searched freely.
5049            // `semantic::bind_pattern_predicate` already rejected any
5050            // named endpoint that ISN'T already bound (real Cypher's
5051            // UndefinedVariable), so every named var here is safe to seed.
5052            // Reuses the exact same `build_match_plan` "already-bound var
5053            // -> Seed, not a fresh scan" mechanism `eval_merge`'s own
5054            // "try as an ordinary MATCH first" half already relies on --
5055            // for a one-hop pattern this is a real connected-subgraph
5056            // search (Expand + Filter), not an isolated per-node check.
5057            // `Some(1)`-limited: existence is all that's needed, so
5058            // there's no reason to enumerate every match.
5059            Expr::Pattern(pattern) => {
5060                Some(self.eval_pattern_predicate_exists(txn, pattern, row, guard)?)
5061            }
5062            // `exists { (n)-->(m) WHERE ... }` (TCK's ExistentialSubquery1,
5063            // the "simple" form) -- same existential search as `Pattern`
5064            // above, just with its own inline `where?` threaded straight
5065            // into `build_match_plan`, same as an ordinary `MATCH ...
5066            // WHERE ...` (not evaluated as a separate post-filter step).
5067            Expr::Exists {
5068                pattern,
5069                where_clause,
5070            } => {
5071                let carried_vars: HashSet<String> = row.keys().cloned().collect();
5072                let wc: Option<Expr> = where_clause.as_deref().cloned();
5073                let plan = apply_index_seeks(build_match_plan(pattern, &wc, &carried_vars)?, txn)?;
5074                let found = self.eval_plan_with_limit(
5075                    txn,
5076                    &plan,
5077                    std::slice::from_ref(row),
5078                    guard,
5079                    Some(1),
5080                )?;
5081                Some(!found.is_empty())
5082            }
5083            // `exists { MATCH ... RETURN ... }` (TCK's
5084            // ExistentialSubquery2/3, the "full" form) -- runs the nested
5085            // statement correlated against `row` (`execute_match_seeded`)
5086            // and checks whether it produced at least one output row.
5087            Expr::ExistsSubquery(stmt) => Some(self.eval_exists_subquery(txn, stmt, row, guard)?),
5088            // See `Expr::EdgeNotInSet`'s own docs -- `edge_var` is always
5089            // a real `Binding::Edge` (a fixed hop's own filter var, the
5090            // only thing this gets generated for) and `edge_set_var` is
5091            // always the `Binding::Path` `expand_variable_row` deposits
5092            // for *every* variable-length hop, unconditionally (see
5093            // `LogicalPlan::VarExpand::exclude_edge_var`'s own docs) --
5094            // never anything else, so there's no null/wrong-kind case to
5095            // handle here the way `VarEq` above has to.
5096            Expr::EdgeNotInSet {
5097                edge_var,
5098                edge_set_var,
5099            } => {
5100                let Some(Binding::Edge(edge_id)) = row.get(edge_var) else {
5101                    return Err(QueryError::UnboundVariable(edge_var.clone()));
5102                };
5103                let Some(Binding::Path(segment)) = row.get(edge_set_var) else {
5104                    return Err(QueryError::UnboundVariable(edge_set_var.clone()));
5105                };
5106                Some(
5107                    !segment
5108                        .iter()
5109                        .any(|elem| matches!(elem, PathBinding::Edge(id) if id == edge_id)),
5110                )
5111            }
5112        })
5113    }
5114
5115    /// Prop name -> interned id, memoized per statement for read-only
5116    /// statements only -- see `prop_id_memo`'s docs for why write
5117    /// statements bypass the memo (mid-statement interning would make a
5118    /// cached `None` stale within the same statement).
5119    fn prop_id_for(&self, txn: Txn, name: &str) -> Result<Option<u32>, QueryError> {
5120        if let Some(cached) = self.prop_id_memo.borrow().get(name) {
5121            return Ok(*cached);
5122        }
5123        let id = GraphStore::lookup_prop_id_in_txn(txn, name)?;
5124        // A name -> Some(id) interning is immutable once made, so a hit
5125        // is safe to memoize in any statement. A `None` ("never
5126        // interned") can go stale *within a write statement* -- a later
5127        // `CREATE`/`SET` can intern that very name -- so `None` is only
5128        // memoized where nothing can intern: a read-only statement.
5129        if id.is_some() || self.read_only_stmt.get() {
5130            self.prop_id_memo.borrow_mut().insert(name.to_string(), id);
5131        }
5132        Ok(id)
5133    }
5134
5135    fn lookup_prop(
5136        &self,
5137        txn: Txn,
5138        pa: &PropAccess,
5139        row: &BindingRow,
5140    ) -> Result<Option<PropertyValue>, QueryError> {
5141        let binding = row
5142            .get(&pa.var)
5143            .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
5144        match binding {
5145            // A missing *property key* on an existing node/edge is a real,
5146            // legal "absent" (-> null downstream) -- but a missing
5147            // *node/edge record* means it was deleted earlier in this same
5148            // statement (`deleted_entity_access`'s docs), which is a real
5149            // error (`MATCH (n) DELETE n RETURN n.num` -- TCK's Return2
5150            // scenario [15]), not a silent null. These are two different
5151            // kinds of "missing" and must not be collapsed into one.
5152            //
5153            // Per-property read path (v2 step 1b): a node already
5154            // materialized in this statement's cache answers from the map;
5155            // otherwise this reads ONE directory entry from the stored
5156            // record -- no full decode, no name resolution, no cache
5157            // population (repeat per-prop reads are ~a point lookup each,
5158            // cheaper than materializing a whole record to answer one of
5159            // them). The nested Option from `get_node_prop_in_txn`
5160            // preserves the deleted-vs-absent split above.
5161            Binding::Node(id) => {
5162                // Safe for write statements too: every node-mutating
5163                // site evicts (`uncache_node`), so a surviving cache
5164                // entry is current by construction.
5165                if let Some(cached) = self.node_cache.borrow().get(id) {
5166                    return Ok(cached.props.get(&pa.prop).cloned());
5167                }
5168                match self.prop_id_for(txn, &pa.prop)? {
5169                    Some(prop_id) => Ok(deleted_entity_access(GraphStore::get_node_prop_in_txn(
5170                        txn, *id, prop_id,
5171                    )?)?),
5172                    // Name never interned anywhere: absent on every record
5173                    // by construction -- but a deleted node must still
5174                    // error, so existence is checked without any decode.
5175                    None => {
5176                        deleted_entity_access(
5177                            GraphStore::node_exists_in_txn(txn, *id)?.then_some(()),
5178                        )?;
5179                        Ok(None)
5180                    }
5181                }
5182            }
5183            Binding::Edge(id) => match self.prop_id_for(txn, &pa.prop)? {
5184                Some(prop_id) => Ok(deleted_entity_access(GraphStore::get_edge_prop_in_txn(
5185                    txn, *id, prop_id,
5186                )?)?),
5187                None => {
5188                    deleted_entity_access(GraphStore::edge_exists_in_txn(txn, *id)?.then_some(()))?;
5189                    Ok(None)
5190                }
5191            },
5192            // A WITH-projected scalar (or list/map) has no scalar `.prop`
5193            // to access via this path — e.g. `WITH message.id AS
5194            // messageId` then `messageId.foo` isn't meaningful. Treat as
5195            // absent rather than erroring, consistent with how a missing
5196            // property already behaves. `Binding::Map` specifically *does*
5197            // have real `.prop` access, just not through this method (its
5198            // values aren't always a scalar `PropertyValue`) — see
5199            // `lookup_prop_value`, which `ReturnExpr::Prop` actually calls.
5200            // A `Binding::Value` holding a `Date`/`Duration` also has real
5201            // `.prop` access (`d.year`, etc) — also handled there, not
5202            // here, for the same "not always a scalar `PropertyValue`"
5203            // reason (well, it always *is* one here, but `lookup_prop_value`
5204            // is where that access actually happens either way).
5205            Binding::Value(_) | Binding::List(_) | Binding::Map(_) => Ok(None),
5206            // Unlike the others, a path is a real type error, not just an
5207            // "absent" property -- real Cypher's `InvalidArgumentType`
5208            // (TCK's MatchWhere1 `[14]`: `MATCH r = (n)-[*]->() WHERE
5209            // r.name = 'apa'`). Property access never had a meaning for a
5210            // path to begin with (it's not a graph-object-shaped value).
5211            Binding::Path(_) => Err(QueryError::Type(format!(
5212                "'{}' is a path — property access requires a node, relationship, or map",
5213                pa.var
5214            ))),
5215        }
5216    }
5217
5218    /// `ReturnExpr::Prop`'s own lookup -- unlike `lookup_prop` (used by
5219    /// pattern-level `WHERE`, which only ever compares a real node/edge
5220    /// property against a `Literal`), a map's value can be any `Value`
5221    /// shape (nested list/map/node), not just a scalar `PropertyValue`,
5222    /// so this returns the wider type and handles `Binding::Map` itself
5223    /// rather than collapsing through `lookup_prop`. A `Binding::Value`
5224    /// holding a `Date`/`Duration` is handled here too, for the same
5225    /// reason -- `d.year`/`d.months`/etc are real component accessors
5226    /// (Temporal5's whole scenario shape, `WITH v.date AS d ... RETURN
5227    /// d.year`), not a stored property `lookup_prop` could ever find.
5228    ///
5229    /// Only a node, relationship, map, or temporal value has any `.prop`
5230    /// to access at all -- a plain scalar (`Bool`/`Int`/`Float`/`String`)
5231    /// or a `List` is a real type error here (real Cypher's own
5232    /// `InvalidArgumentType` is raised at *compile* time; this codebase's
5233    /// `Kind` system can't see through a WITH-projected value's real
5234    /// runtime shape to catch it any earlier -- see `infer_expr`'s own
5235    /// `Kind::Scalar` docs -- so it surfaces here instead), not a silent
5236    /// `null` (TCK's Graph6 [9] / Map1 [6]). `null` itself is exempt --
5237    /// real Cypher's null propagation rule, not a type error.
5238    fn lookup_prop_value(
5239        &self,
5240        txn: Txn,
5241        pa: &PropAccess,
5242        row: &BindingRow,
5243    ) -> Result<Value, QueryError> {
5244        match row.get(&pa.var) {
5245            Some(Binding::Map(m)) => Ok(m.get(&pa.prop).cloned().unwrap_or(Value::Null)),
5246            Some(Binding::Value(PropertyValue::Null)) => Ok(Value::Null),
5247            Some(Binding::Value(pv)) => match temporal_component(pv, &pa.prop) {
5248                Some(component) => Ok(Value::Property(component)),
5249                None if is_temporal_property_value(pv) => Ok(Value::Null),
5250                None => Err(QueryError::Type(format!(
5251                    "'{}' can't have properties accessed on it -- property access requires a \
5252                     node, relationship, map, or temporal value",
5253                    pa.var
5254                ))),
5255            },
5256            Some(Binding::List(_)) => Err(QueryError::Type(format!(
5257                "'{}' can't have properties accessed on it -- property access requires a node, \
5258                 relationship, map, or temporal value, not a list",
5259                pa.var
5260            ))),
5261            Some(_) => Ok(match self.lookup_prop(txn, pa, row)? {
5262                Some(PropertyValue::Null) | None => Value::Null,
5263                Some(pv) => property_value_to_value(pv),
5264            }),
5265            None => Err(QueryError::UnboundVariable(pa.var.clone())),
5266        }
5267    }
5268
5269    fn materialize_return(
5270        &self,
5271        txn: Txn,
5272        items: &[ReturnItem],
5273        rows: &[BindingRow],
5274        distinct: bool,
5275        guard: &ExecutionGuard<'_>,
5276    ) -> Result<QueryResult, QueryError> {
5277        let columns = items
5278            .iter()
5279            .enumerate()
5280            .map(|(i, item)| {
5281                item.alias
5282                    .clone()
5283                    .unwrap_or_else(|| default_column_name(&item.expr, i))
5284            })
5285            .collect();
5286        let mut out_rows = if !has_aggregate(items) {
5287            let mut out_rows = Vec::with_capacity(rows.len());
5288            for row in rows {
5289                let mut out_row = Vec::with_capacity(items.len());
5290                for item in items {
5291                    out_row.push(self.eval_return_expr(txn, &item.expr, row, guard)?);
5292                }
5293                out_rows.push(out_row);
5294            }
5295            out_rows
5296        } else {
5297            validate_return_items(items)?;
5298            let grouped = self.resolve_grouped_rows(txn, items, rows, guard)?;
5299            grouped
5300                .into_iter()
5301                .map(|bindings| {
5302                    bindings
5303                        .iter()
5304                        .map(|b| self.binding_to_value(txn, b))
5305                        .collect::<Result<Vec<_>, _>>()
5306                })
5307                .collect::<Result<Vec<_>, _>>()?
5308        };
5309        if distinct {
5310            out_rows = dedup_rows(out_rows)?;
5311        }
5312        Ok(QueryResult {
5313            columns,
5314            rows: out_rows,
5315            stats: QueryStats::default(),
5316        })
5317    }
5318
5319    /// An aggregating `RETURN`'s own `ORDER BY`, when at least one key
5320    /// doesn't verbatim/alias-match any item -- `RETURN me.age AS age,
5321    /// count(you.age) AS cnt ORDER BY age + count(you.age)` (TCK's
5322    /// ReturnOrderBy6). Folds those extra keys through the *same*
5323    /// grouping pass as `items` themselves, as synthetic unreturned extra
5324    /// items (reusing `resolve_grouped_rows`/`rewrite_composed_item`
5325    /// exactly as a composed RETURN item would, including an aggregate
5326    /// call that appears *only* in the ORDER BY key, nowhere in `items`
5327    /// -- real Cypher allows that too, it just needs to fold consistently
5328    /// with `items`' own implicit grouping, not literally reuse one of
5329    /// their accumulators), then uses their per-group values as
5330    /// additional sort keys before stripping them back off. Degrades to
5331    /// exactly the ordinary "sort by already-computed columns" behavior
5332    /// when every key does verbatim/alias-match (`extra_exprs` empty) --
5333    /// callers can route every aggregating-`RETURN`-with-`ORDER-BY` case
5334    /// through this one function rather than branching on whether extras
5335    /// are actually needed.
5336    ///
5337    /// `DISTINCT` isn't handled here -- deliberately: grouping already
5338    /// makes every output row unique by its own grouping-key columns (two
5339    /// groups can't have the same grouping key and still be different
5340    /// groups), so `RETURN DISTINCT` combined with aggregation is
5341    /// provably always a no-op downstream of this function regardless.
5342    fn materialize_aggregating_return_with_order(
5343        &self,
5344        txn: Txn,
5345        items: &[ReturnItem],
5346        rows: &[BindingRow],
5347        order_by: &[(ReturnExpr, SortDir)],
5348        skip_limit: (Option<i64>, Option<i64>),
5349        guard: &ExecutionGuard<'_>,
5350    ) -> Result<QueryResult, QueryError> {
5351        let (skip, limit) = skip_limit;
5352        enum OrderKeySource {
5353            RealColumn(usize),
5354            Extra(usize),
5355        }
5356        let mut extra_exprs: Vec<ReturnExpr> = Vec::new();
5357        let order_by_source: Vec<OrderKeySource> = order_by
5358            .iter()
5359            .map(|(expr, _)| {
5360                match items
5361                    .iter()
5362                    .enumerate()
5363                    .position(|(i, it)| item_matches_leaf(expr, i, it))
5364                {
5365                    Some(i) => OrderKeySource::RealColumn(i),
5366                    None => {
5367                        let idx = extra_exprs.len();
5368                        extra_exprs.push(expr.clone());
5369                        OrderKeySource::Extra(idx)
5370                    }
5371                }
5372            })
5373            .collect();
5374        let extended_items: Vec<ReturnItem> = items
5375            .iter()
5376            .cloned()
5377            .chain(
5378                extra_exprs
5379                    .into_iter()
5380                    .map(|expr| ReturnItem { expr, alias: None }),
5381            )
5382            .collect();
5383        validate_return_items(&extended_items)?;
5384        let grouped = self.resolve_grouped_rows(txn, &extended_items, rows, guard)?;
5385        let columns: Vec<String> = items
5386            .iter()
5387            .enumerate()
5388            .map(|(i, item)| {
5389                item.alias
5390                    .clone()
5391                    .unwrap_or_else(|| default_column_name(&item.expr, i))
5392            })
5393            .collect();
5394        let real_len = items.len();
5395        let mut keyed: Vec<(Vec<Value>, Vec<Value>)> = Vec::with_capacity(grouped.len());
5396        for bindings in grouped {
5397            let values: Vec<Value> = bindings
5398                .iter()
5399                .map(|b| self.binding_to_value(txn, b))
5400                .collect::<Result<Vec<_>, _>>()?;
5401            let (real, extra) = values.split_at(real_len);
5402            let keys: Vec<Value> = order_by_source
5403                .iter()
5404                .map(|src| match src {
5405                    OrderKeySource::RealColumn(i) => real[*i].clone(),
5406                    OrderKeySource::Extra(k) => extra[*k].clone(),
5407                })
5408                .collect();
5409            keyed.push((keys, real.to_vec()));
5410        }
5411        let rows = top_k_by(keyed, order_by, skip, limit)
5412            .into_iter()
5413            .map(|(_, row)| row)
5414            .collect();
5415        Ok(QueryResult {
5416            columns,
5417            rows,
5418            stats: QueryStats::default(),
5419        })
5420    }
5421
5422    /// `SKIP`/`LIMIT` accept any expression, not just a literal integer
5423    /// (`SKIP $n`, `SKIP toInteger(rand()*9)` -- TCK's `ReturnSkipLimit1
5424    /// [2]`/`[3]`) -- evaluated exactly once here, against an empty row,
5425    /// since no pattern variable can be in scope at a statement's own
5426    /// SKIP/LIMIT (an `UnboundVariable` error from `eval_return_expr`
5427    /// below is exactly the right outcome if one is referenced). Params
5428    /// are already resolved to concrete `Literal`s by this point (see
5429    /// `params::substitute_params`).
5430    fn resolve_skip_limit(
5431        &self,
5432        txn: Txn,
5433        expr: Option<&ReturnExpr>,
5434        clause: &str,
5435        guard: &ExecutionGuard<'_>,
5436    ) -> Result<Option<i64>, QueryError> {
5437        let Some(expr) = expr else {
5438            return Ok(None);
5439        };
5440        let value = self.eval_return_expr(txn, expr, &BindingRow::new(), guard)?;
5441        let n = match value {
5442            Value::Literal(Literal::Int(n)) | Value::Property(PropertyValue::Int(n)) => n,
5443            _ => {
5444                return Err(QueryError::Semantic(format!(
5445                    "{clause} must evaluate to an integer"
5446                )));
5447            }
5448        };
5449        if n < 0 {
5450            return Err(QueryError::Semantic(format!("{clause} can't be negative")));
5451        }
5452        Ok(Some(n))
5453    }
5454
5455    fn eval_return_expr(
5456        &self,
5457        txn: Txn,
5458        expr: &ReturnExpr,
5459        row: &BindingRow,
5460        guard: &ExecutionGuard<'_>,
5461    ) -> Result<Value, QueryError> {
5462        match expr {
5463            ReturnExpr::Var(var) => {
5464                let binding = row
5465                    .get(var)
5466                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
5467                self.binding_to_value(txn, binding)
5468            }
5469            ReturnExpr::Prop(pa) => self.lookup_prop_value(txn, pa, row),
5470            ReturnExpr::PropOf(base, prop) => {
5471                let v = self.eval_return_expr(txn, base, row, guard)?;
5472                property_of_value(&v, prop)
5473            }
5474            ReturnExpr::Lit(lit) => Ok(match lit {
5475                Literal::Null => Value::Null,
5476                other => Value::Literal(other.clone()),
5477            }),
5478            ReturnExpr::Call { name, args, .. } => {
5479                // Reaching here with an aggregate name means an aggregate
5480                // call slipped past `validate_return_items` (which only
5481                // allows one at a return item's top level) — grouping
5482                // itself never calls `eval_return_expr` on the aggregate
5483                // wrapper, only on each aggregate's own argument
5484                // subexpression (see `resolve_grouped_rows`), so this is
5485                // an internal-consistency error, not a normal user path.
5486                if is_aggregate_name(name) {
5487                    return Err(QueryError::Semantic(format!(
5488                        "aggregate function '{name}' can only be used as a return item's top-level expression"
5489                    )));
5490                }
5491                let lower = name.to_ascii_lowercase();
5492                if lower == "type" {
5493                    // Special-cased *before* the generic arg-evaluation
5494                    // below -- that would eagerly fail on a deleted
5495                    // relationship (`deleted_entity_access`), before
5496                    // `eval_type_call` ever gets a chance to fall back to
5497                    // its cached type. See `ExecutionGuard::
5498                    // deleted_edge_types`'s own docs.
5499                    return self.eval_type_call(txn, args.first(), row, guard);
5500                }
5501                let arg_values = args
5502                    .iter()
5503                    .map(|a| self.eval_return_expr(txn, a, row, guard))
5504                    .collect::<Result<Vec<_>, _>>()?;
5505                if lower == "startnode" || lower == "endnode" {
5506                    return self.start_or_end_node(txn, &lower, arg_values.first());
5507                }
5508                call_builtin(name, &arg_values, self.now_snapshot())
5509            }
5510            ReturnExpr::CountStar => Err(QueryError::Semantic(
5511                "count(*) can only be used as a return item's top-level expression".into(),
5512            )),
5513            ReturnExpr::Case { test, whens, else_ } => {
5514                let test_value = match test {
5515                    Some(t) => Some(self.eval_return_expr(txn, t, row, guard)?),
5516                    None => None,
5517                };
5518                for (when, then) in whens {
5519                    let when_value = self.eval_return_expr(txn, when, row, guard)?;
5520                    // Deliberately reuses the same Null == Null -> true
5521                    // convention as `compare()` below, not standard
5522                    // three-valued NULL logic — IS7's `CASE r WHEN null
5523                    // THEN false ELSE true END` depends on this exact
5524                    // semantics to detect an OPTIONAL MATCH non-match.
5525                    let matched = match &test_value {
5526                        Some(tv) => value_eq(tv, &when_value),
5527                        None => matches!(when_value, Value::Literal(Literal::Bool(true))),
5528                    };
5529                    if matched {
5530                        return self.eval_return_expr(txn, then, row, guard);
5531                    }
5532                }
5533                match else_ {
5534                    Some(e) => self.eval_return_expr(txn, e, row, guard),
5535                    None => Ok(Value::Null),
5536                }
5537            }
5538            ReturnExpr::Arith(l, op, r) => {
5539                let lv = self.eval_return_expr(txn, l, row, guard)?;
5540                let rv = self.eval_return_expr(txn, r, row, guard)?;
5541                apply_arith(*op, &lv, &rv)
5542            }
5543            ReturnExpr::Neg(e) => {
5544                let v = self.eval_return_expr(txn, e, row, guard)?;
5545                apply_neg(&v)
5546            }
5547            ReturnExpr::ListLit(items) => Ok(Value::List(
5548                items
5549                    .iter()
5550                    .map(|item| self.eval_return_expr(txn, item, row, guard))
5551                    .collect::<Result<Vec<_>, _>>()?,
5552            )),
5553            ReturnExpr::Index(base, index) => {
5554                let base_v = self.eval_return_expr(txn, base, row, guard)?;
5555                let index_v = self.eval_return_expr(txn, index, row, guard)?;
5556                apply_index(&base_v, &index_v)
5557            }
5558            ReturnExpr::Slice(base, start, end) => {
5559                let base_v = self.eval_return_expr(txn, base, row, guard)?;
5560                let start_v = start
5561                    .as_deref()
5562                    .map(|s| self.eval_return_expr(txn, s, row, guard))
5563                    .transpose()?;
5564                let end_v = end
5565                    .as_deref()
5566                    .map(|e| self.eval_return_expr(txn, e, row, guard))
5567                    .transpose()?;
5568                apply_slice(&base_v, start_v.as_ref(), end_v.as_ref())
5569            }
5570            ReturnExpr::ListComp {
5571                var,
5572                source,
5573                where_clause,
5574                project,
5575            } => {
5576                let source_v = self.eval_return_expr(txn, source, row, guard)?;
5577                let items = match source_v {
5578                    Value::List(items) => items,
5579                    Value::Null => return Ok(Value::Null),
5580                    other => {
5581                        return Err(QueryError::Type(format!(
5582                            "list comprehension source must be a list, got {other:?}"
5583                        )))
5584                    }
5585                };
5586                let mut result = Vec::with_capacity(items.len());
5587                for item in items {
5588                    // A fresh overlay per element -- `var` shadows any
5589                    // outer binding of the same name for the duration of
5590                    // this one element, same scoping UNWIND already uses.
5591                    let mut scoped_row = row.clone();
5592                    scoped_row.insert(var.clone(), value_to_binding_restore(&item));
5593                    let keep = match where_clause {
5594                        Some(w) => {
5595                            self.eval_return_expr_bool3(txn, w, &scoped_row, guard)? == Some(true)
5596                        }
5597                        None => true,
5598                    };
5599                    if !keep {
5600                        continue;
5601                    }
5602                    result.push(match project {
5603                        Some(p) => self.eval_return_expr(txn, p, &scoped_row, guard)?,
5604                        None => item,
5605                    });
5606                }
5607                Ok(Value::List(result))
5608            }
5609            ReturnExpr::Quantifier {
5610                kind,
5611                var,
5612                source,
5613                where_clause,
5614            } => {
5615                let source_v = self.eval_return_expr(txn, source, row, guard)?;
5616                let items = match source_v {
5617                    Value::List(items) => items,
5618                    Value::Null => return Ok(Value::Null),
5619                    other => {
5620                        return Err(QueryError::Type(format!(
5621                            "quantifier source must be a list, got {other:?}"
5622                        )))
5623                    }
5624                };
5625                let mut preds = Vec::with_capacity(items.len());
5626                for item in &items {
5627                    let mut scoped_row = row.clone();
5628                    scoped_row.insert(var.clone(), value_to_binding_restore(item));
5629                    preds.push(match where_clause {
5630                        Some(w) => self.eval_return_expr_bool3(txn, w, &scoped_row, guard)?,
5631                        None => item_truthy(item),
5632                    });
5633                }
5634                Ok(match eval_quantifier(*kind, &preds) {
5635                    Some(b) => Value::Literal(Literal::Bool(b)),
5636                    None => Value::Null,
5637                })
5638            }
5639            ReturnExpr::MapLit(entries) => {
5640                let mut map = BTreeMap::new();
5641                for (k, v) in entries {
5642                    map.insert(k.clone(), self.eval_return_expr(txn, v, row, guard)?);
5643                }
5644                Ok(Value::Map(map))
5645            }
5646            ReturnExpr::And(l, r) => Ok(bool3_to_value(and3(
5647                self.eval_return_expr_bool3(txn, l, row, guard)?,
5648                self.eval_return_expr_bool3(txn, r, row, guard)?,
5649            ))),
5650            ReturnExpr::Or(l, r) => Ok(bool3_to_value(or3(
5651                self.eval_return_expr_bool3(txn, l, row, guard)?,
5652                self.eval_return_expr_bool3(txn, r, row, guard)?,
5653            ))),
5654            ReturnExpr::Xor(l, r) => Ok(bool3_to_value(xor3(
5655                self.eval_return_expr_bool3(txn, l, row, guard)?,
5656                self.eval_return_expr_bool3(txn, r, row, guard)?,
5657            ))),
5658            ReturnExpr::Not(e) => Ok(bool3_to_value(
5659                self.eval_return_expr_bool3(txn, e, row, guard)?.map(|b| !b),
5660            )),
5661            ReturnExpr::Compare(l, op, r) => {
5662                let lv = self.eval_return_expr(txn, l, row, guard)?;
5663                let rv = self.eval_return_expr(txn, r, row, guard)?;
5664                Ok(bool3_to_value(compare_values(&lv, *op, &rv)))
5665            }
5666            ReturnExpr::IsNull(e) => {
5667                let v = self.eval_return_expr(txn, e, row, guard)?;
5668                Ok(Value::Literal(Literal::Bool(matches!(v, Value::Null))))
5669            }
5670            ReturnExpr::In(needle, haystack) => {
5671                let nv = self.eval_return_expr(txn, needle, row, guard)?;
5672                let hv = self.eval_return_expr(txn, haystack, row, guard)?;
5673                Ok(bool3_to_value(list_membership_ternary(&nv, &hv)?))
5674            }
5675            ReturnExpr::HasLabel(var, labels) => {
5676                let binding = row
5677                    .get(var)
5678                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
5679                match binding {
5680                    Binding::Node(id) => {
5681                        let node = deleted_entity_access(self.get_node_cached(txn, *id)?)?;
5682                        Ok(Value::Literal(Literal::Bool(
5683                            labels.iter().all(|l| node.labels.contains(l)),
5684                        )))
5685                    }
5686                    // `r:TYPE` -- a relationship has exactly one type, so
5687                    // this is just an equality check, not a set-membership
5688                    // one; a conjunctive `r:A:B` (only reachable from
5689                    // general expression position, never real Cypher's own
5690                    // pattern-level `WHERE` -- relationships can't carry
5691                    // more than one type) is trivially always false unless
5692                    // every listed name is the same one type (TCK's Graph5
5693                    // "Node and edge label expressions" [2]).
5694                    Binding::Edge(id) => {
5695                        let edge = deleted_entity_access(GraphStore::get_edge_in_txn(txn, *id)?)?;
5696                        Ok(Value::Literal(Literal::Bool(
5697                            labels.iter().all(|l| edge.label == *l),
5698                        )))
5699                    }
5700                    Binding::Value(PropertyValue::Null) => Ok(Value::Null),
5701                    other => Err(QueryError::Type(format!(
5702                        "'{var}' isn't a node or relationship — (n:Label) needs one, got {other:?}"
5703                    ))),
5704                }
5705            }
5706            ReturnExpr::PatternPredicate(_) => Err(QueryError::Semantic(
5707                "a pattern predicate (`(n)-->()` etc) can only be used inside WHERE".into(),
5708            )),
5709            ReturnExpr::PatternComprehension {
5710                path_var,
5711                pattern,
5712                where_clause,
5713                projection,
5714            } => self.eval_pattern_comprehension(
5715                txn,
5716                PatternComprehensionSpec {
5717                    path_var,
5718                    pattern,
5719                    where_clause,
5720                    projection,
5721                },
5722                row,
5723                guard,
5724            ),
5725            ReturnExpr::ExistsPattern { .. } | ReturnExpr::ExistsSubquery(_) => Err(
5726                QueryError::Semantic("an exists {} subquery can only be used inside WHERE".into()),
5727            ),
5728        }
5729    }
5730
5731    /// `[p = (n)-->() | p]` / `[(n)-[:T]->(b) | b.name]` -- enumerates
5732    /// every match of `pattern` against the graph (already-bound named
5733    /// endpoints in `row` held fixed, exactly like `Expr::Pattern`'s own
5734    /// existential search reuses `build_match_plan`'s "already-bound var
5735    /// -> Seed, not a fresh scan" mechanism) and projects `projection`
5736    /// against each match's own resulting row, collecting into a
5737    /// `Value::List`. No limit on `eval_plan_with_limit` here (unlike
5738    /// `Expr::Pattern`'s `Some(1)`) -- a comprehension needs every match,
5739    /// not just whether one exists.
5740    ///
5741    /// A named path (`path_var: Some`) reuses `execute_match`'s own
5742    /// `name_pattern_for_path`/`assemble_path` pair verbatim -- same
5743    /// "synthesize internal names for any unnamed hop, assemble the path
5744    /// from those, then strip the synthesized keys (and the reserved
5745    /// variable-length-hop segment key, if any) back out" approach a real
5746    /// `MATCH p = ...` clause already uses, including over a single
5747    /// variable-length hop (TCK's Pattern2 `[9]`) -- also reuses
5748    /// `validate_named_path_pattern`'s own restriction on anything wider
5749    /// (a variable-length hop mixed with another hop) for the same reason
5750    /// it already applies to `MATCH`.
5751    fn eval_pattern_comprehension(
5752        &self,
5753        txn: Txn,
5754        spec: PatternComprehensionSpec<'_>,
5755        row: &BindingRow,
5756        guard: &ExecutionGuard<'_>,
5757    ) -> Result<Value, QueryError> {
5758        let PatternComprehensionSpec {
5759            path_var,
5760            pattern,
5761            where_clause,
5762            projection,
5763        } = spec;
5764        if path_var.is_some() {
5765            validate_named_path_pattern(pattern)?;
5766        }
5767        let carried_vars: HashSet<String> = row.keys().cloned().collect();
5768        let (named_pattern, synthesized) = match path_var {
5769            Some(_) => name_pattern_for_path(pattern),
5770            None => (pattern.clone(), HashSet::new()),
5771        };
5772        let wc: Option<Expr> = where_clause.as_deref().cloned();
5773        let plan = apply_index_seeks(build_match_plan(&named_pattern, &wc, &carried_vars)?, txn)?;
5774        let rows = self.eval_plan_with_limit(txn, &plan, std::slice::from_ref(row), guard, None)?;
5775        let mut out = Vec::with_capacity(rows.len());
5776        for mut r in rows {
5777            if let Some(pv) = path_var {
5778                let path_binding = assemble_path(&named_pattern, &r);
5779                for key in &synthesized {
5780                    r.remove(key);
5781                }
5782                r.insert(pv.clone(), path_binding);
5783            }
5784            out.push(self.eval_return_expr(txn, projection, &r, guard)?);
5785        }
5786        Ok(Value::List(out))
5787    }
5788
5789    /// A `WHERE`-position `ReturnExpr` (list comprehension/quantifier
5790    /// filters) evaluated as three-valued logic instead of a plain
5791    /// `Value` -- delegates to `eval_return_expr` then folds the result
5792    /// down via `value_to_bool3`.
5793    fn eval_return_expr_bool3(
5794        &self,
5795        txn: Txn,
5796        expr: &ReturnExpr,
5797        row: &BindingRow,
5798        guard: &ExecutionGuard<'_>,
5799    ) -> Result<Option<bool>, QueryError> {
5800        value_to_bool3(&self.eval_return_expr(txn, expr, row, guard)?)
5801    }
5802
5803    /// Deletes every `targets` expression's value, across every row --
5804    /// shared by `materialize_delete` (`DELETE`/`DETACH DELETE` as a
5805    /// statement tail) and `execute_match`'s own `QueryClause::Delete`
5806    /// (`DELETE ... WITH ...` mid-pattern). Edges are deleted immediately
5807    /// (no ordering constraint), but nodes are only *collected* into
5808    /// `pending_nodes` and deleted in a second pass, after every target
5809    /// across every row has contributed its own edges -- not deleted
5810    /// inline the way `delete_binding`/`delete_value` used to. A single
5811    /// non-`DETACH` `DELETE` naming *several* targets that collectively
5812    /// cover all of a node's edges (e.g. `DELETE pathColls.key[0],
5813    /// pathColls.key[1]`, two paths sharing a node, each contributing one
5814    /// of its two incident edges) must succeed -- deleting inline would
5815    /// try to delete the first path's node while the second path's edge
5816    /// (not yet processed) was still attached, a real bug found via TCK's
5817    /// Delete5 `[7]` once `{key: collect(p)}`-shaped composed expressions
5818    /// could reach this code path at all (previously rejected outright at
5819    /// compile time, before general aggregate composition was supported).
5820    fn delete_targets(
5821        &self,
5822        txn: Txn,
5823        write_txn: &WriteTransaction,
5824        targets: &[ReturnExpr],
5825        rows: &[BindingRow],
5826        detach: bool,
5827        guard: &ExecutionGuard<'_>,
5828    ) -> Result<(), QueryError> {
5829        let mut deleted_edges = HashSet::new();
5830        let mut pending_nodes = HashSet::new();
5831        // All-bare-variable target lists (`DELETE r`, `DELETE r, a, b` --
5832        // by far the common case, and the only shape a predicate-driven
5833        // bulk delete produces) never evaluate anything between edge
5834        // deletions, so the edge ids can be collected across every row
5835        // first and deleted in one `delete_edges_in_txn` batch: one
5836        // `WriteCtx` and one label-name resolution per distinct type,
5837        // instead of a whole-edge fetch plus a fresh `WriteCtx` (and its
5838        // table opens) per edge. Observably identical to deleting
5839        // inline -- with no expression evaluation in the loop there is no
5840        // read that could distinguish "deleted already" from "deleted at
5841        // the end", and `guard`'s deleted-edge-type bookkeeping is only
5842        // consulted by later statements. Any computed target (`list[0]`,
5843        // `map.key`, ...) falls back to the per-edge path below, whose
5844        // immediate deletes are what let a later target's evaluation
5845        // correctly error via `deleted_entity_access` on touching an
5846        // already-deleted entity.
5847        if targets.iter().all(|t| matches!(t, ReturnExpr::Var(_))) {
5848            let mut edge_ids: Vec<EdgeId> = Vec::new();
5849            for row in rows {
5850                for target in targets {
5851                    let ReturnExpr::Var(name) = target else {
5852                        unreachable!("checked all-Var above");
5853                    };
5854                    let binding = row
5855                        .get(name)
5856                        .ok_or_else(|| QueryError::UnboundVariable(name.clone()))?;
5857                    collect_delete_binding(
5858                        binding,
5859                        &mut deleted_edges,
5860                        &mut edge_ids,
5861                        &mut pending_nodes,
5862                    )?;
5863                }
5864            }
5865            for (id, label) in GraphStore::delete_edges_in_txn(write_txn, &edge_ids)? {
5866                self.count(|s| s.relationships_deleted += 1);
5867                guard.record_deleted_edge_type(id, label);
5868            }
5869        } else {
5870            for row in rows {
5871                for target in targets {
5872                    // A bare variable (`DELETE r, a, b`, by far the common
5873                    // case) deletes by the raw id already sitting in the row's
5874                    // `Binding` -- no existence check, no property fetch.
5875                    // That's what lets `DELETE r, a, b` work when two rows of
5876                    // the same undirected match both reference the same `a`/
5877                    // `b`/`r` (real, from TCK's Delete4 `[1]`): the second
5878                    // row's own dedup lookup must succeed even though the
5879                    // first row already deleted them. Anything else (`list[0]`,
5880                    // `map.key`, a whole path variable's *elements* accessed
5881                    // computedly, ...) has no such raw shortcut and goes
5882                    // through real evaluation instead -- which correctly does
5883                    // still error via `deleted_entity_access` if it tries to
5884                    // read a property off something already gone, since that's
5885                    // a genuine access, not just a re-statement of identity.
5886                    if let ReturnExpr::Var(name) = target {
5887                        let binding = row
5888                            .get(name)
5889                            .ok_or_else(|| QueryError::UnboundVariable(name.clone()))?;
5890                        delete_binding(
5891                            self,
5892                            txn,
5893                            binding,
5894                            write_txn,
5895                            &mut deleted_edges,
5896                            &mut pending_nodes,
5897                            guard,
5898                        )?;
5899                    } else {
5900                        let value = self.eval_return_expr(txn, target, row, guard)?;
5901                        delete_value(
5902                            self,
5903                            &value,
5904                            write_txn,
5905                            &mut deleted_edges,
5906                            &mut pending_nodes,
5907                            guard,
5908                        )?;
5909                    }
5910                }
5911            }
5912        }
5913        for id in pending_nodes {
5914            self.uncache_node(id);
5915            if let Some(detached_edges) = GraphStore::delete_node_in_txn(write_txn, id, detach)? {
5916                self.count(|s| {
5917                    s.nodes_deleted += 1;
5918                    s.relationships_deleted += detached_edges;
5919                });
5920            }
5921        }
5922        Ok(())
5923    }
5924
5925    /// `ret`, when present, is evaluated *after* the physical delete runs,
5926    /// not before — real Cypher's own DELETE+RETURN TCK scenarios agree on
5927    /// this ordering: `MATCH (n) DELETE n RETURN n.num` must raise a
5928    /// `DeletedEntityAccess` error (TCK's Return2 scenarios [15]/[17]), not
5929    /// silently return the pre-delete value. `lookup_prop`/
5930    /// `binding_to_value` (via `deleted_entity_access`) already turn "the
5931    /// bound id's record is gone" into a proper `QueryError` rather than a
5932    /// silent null or a panic, which is exactly what makes deleting first
5933    /// safe here — every other real DELETE+RETURN shape (`count(*)`,
5934    /// `sum(num)` off a WITH-projected scalar, a literal, a null OPTIONAL
5935    /// MATCH binding) never touches the just-deleted entity's live record
5936    /// at all, so this ordering changes nothing for them.
5937    fn materialize_delete(
5938        &self,
5939        txn: Txn,
5940        targets: &[ReturnExpr],
5941        rows: &[BindingRow],
5942        detach: bool,
5943        ret: &Option<ReturnTail>,
5944        guard: &ExecutionGuard<'_>,
5945    ) -> Result<QueryResult, QueryError> {
5946        let write_txn = require_write_txn(txn);
5947        self.delete_targets(txn, write_txn, targets, rows, detach, guard)?;
5948        let result = match ret {
5949            Some(rt) => self.materialize_return(txn, &rt.items, rows, rt.distinct, guard)?,
5950            None => QueryResult {
5951                columns: vec![],
5952                rows: vec![],
5953                stats: QueryStats::default(),
5954            },
5955        };
5956        Ok(result)
5957    }
5958
5959    fn materialize_set(
5960        &self,
5961        txn: Txn,
5962        items: &[SetItem],
5963        rows: &[BindingRow],
5964        ret: &Option<ReturnTail>,
5965        guard: &ExecutionGuard<'_>,
5966    ) -> Result<QueryResult, QueryError> {
5967        let write_txn = require_write_txn(txn);
5968        for row in rows {
5969            for item in items {
5970                self.apply_set_item(txn, write_txn, row, item, guard)?;
5971            }
5972        }
5973        match ret {
5974            Some(rt) => self.materialize_return(txn, &rt.items, rows, rt.distinct, guard),
5975            None => Ok(QueryResult {
5976                columns: vec![],
5977                rows: vec![],
5978                stats: QueryStats::default(),
5979            }),
5980        }
5981    }
5982
5983    fn materialize_remove(
5984        &self,
5985        txn: Txn,
5986        items: &[RemoveItem],
5987        rows: &[BindingRow],
5988        ret: &Option<ReturnTail>,
5989        guard: &ExecutionGuard<'_>,
5990    ) -> Result<QueryResult, QueryError> {
5991        let write_txn = require_write_txn(txn);
5992        for row in rows {
5993            for item in items {
5994                apply_remove_item(self, write_txn, row, item)?;
5995            }
5996        }
5997        match ret {
5998            Some(rt) => self.materialize_return(txn, &rt.items, rows, rt.distinct, guard),
5999            None => Ok(QueryResult {
6000                columns: vec![],
6001                rows: vec![],
6002                stats: QueryStats::default(),
6003            }),
6004        }
6005    }
6006
6007    /// `<match_stmt> UNION [ALL] <match_stmt> ...` — every part shares the
6008    /// same `txn` (one snapshot for a read-only union, one write
6009    /// transaction otherwise — see `is_read_only`'s own `Union` handling)
6010    /// but no bindings: each part is `execute_match`'d completely
6011    /// independently, matching real Cypher's own scoping. Column names
6012    /// must match exactly across every part (real Cypher's
6013    /// `DifferentColumnsInUnion` — checked here, once each part's real
6014    /// `QueryResult.columns` is in hand, rather than statically, since
6015    /// nothing else in this codebase infers a `RETURN` list's column
6016    /// names without evaluating it). `all: false` (plain `UNION`) dedups
6017    /// the combined rows via the same `dedup_rows` `RETURN DISTINCT`
6018    /// already uses; `all: true` keeps every row.
6019    fn materialize_union(
6020        &self,
6021        txn: Txn,
6022        parts: &[Statement],
6023        all: bool,
6024        guard: &ExecutionGuard<'_>,
6025    ) -> Result<QueryResult, QueryError> {
6026        let mut combined: Option<QueryResult> = None;
6027        for part in parts {
6028            let Statement::Match {
6029                clauses,
6030                tail,
6031                order_by,
6032                skip,
6033                limit,
6034            } = part
6035            else {
6036                unreachable!(
6037                    "union_stmt parts are always Statement::Match -- see parser::parse_union_stmt"
6038                )
6039            };
6040            let skip = self.resolve_skip_limit(txn, skip.as_deref(), "SKIP", guard)?;
6041            let limit = self.resolve_skip_limit(txn, limit.as_deref(), "LIMIT", guard)?;
6042            let result = self.execute_match(
6043                txn,
6044                clauses,
6045                tail,
6046                ResultModifiers {
6047                    order_by,
6048                    skip,
6049                    limit,
6050                },
6051                guard,
6052            )?;
6053            combined = Some(match combined {
6054                None => result,
6055                Some(mut acc) => {
6056                    if acc.columns != result.columns {
6057                        return Err(QueryError::Semantic(format!(
6058                            "UNION requires every part to return the same columns -- got {:?} \
6059                             and {:?}",
6060                            acc.columns, result.columns
6061                        )));
6062                    }
6063                    acc.rows.extend(result.rows);
6064                    acc
6065                }
6066            });
6067            guard.check_intermediate_rows(combined.as_ref().map(|r| r.rows.len()).unwrap_or(0))?;
6068        }
6069        let mut result = combined.expect("union_stmt grammar guarantees at least 2 parts");
6070        if !all {
6071            result.rows = dedup_rows(result.rows)?;
6072        }
6073        Ok(result)
6074    }
6075
6076    fn apply_set_item(
6077        &self,
6078        txn: Txn,
6079        write_txn: &WriteTransaction,
6080        row: &BindingRow,
6081        item: &SetItem,
6082        guard: &ExecutionGuard<'_>,
6083    ) -> Result<(), QueryError> {
6084        match item {
6085            SetItem::Prop(pa, expr) => {
6086                let binding = row
6087                    .get(&pa.var)
6088                    .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
6089                // `SET` on a null binding is a documented no-op, same as
6090                // `DELETE`/`REMOVE` on one -- an `OPTIONAL MATCH` that found
6091                // nothing pads its variables with null (found via TCK's
6092                // Set1/Set3 "Ignore null when setting property/label"
6093                // scenarios).
6094                if matches!(binding, Binding::Value(PropertyValue::Null)) {
6095                    return Ok(());
6096                }
6097                let node_id = if let Binding::Node(id) = binding {
6098                    Some(*id)
6099                } else {
6100                    None
6101                };
6102                let edge_id = if let Binding::Edge(id) = binding {
6103                    Some(*id)
6104                } else {
6105                    None
6106                };
6107                if node_id.is_none() && edge_id.is_none() {
6108                    return Err(QueryError::UnboundVariable(format!(
6109                    "'{}' is a WITH-projected scalar, not a node/edge — SET needs a graph binding",
6110                    pa.var
6111                )));
6112                }
6113                let value = self.eval_return_expr(txn, expr, row, guard)?;
6114                // `SET n.prop = null` *removes* the property in real Cypher
6115                // (found via TCK's Set2 "Set a Property to Null" scenarios,
6116                // which this codebase previously couldn't parse at all --
6117                // `SET` had no trailing RETURN to observe the result with, so
6118                // this bug was never exercised until that gap closed).
6119                // Storing a literal `PropertyValue::Null` instead is
6120                // observably different: `n.prop` still shows up as a
6121                // (nulled-out) key when a caller enumerates a node's own
6122                // props (e.g. this RETURN's own node-to-string rendering),
6123                // where a real missing property wouldn't. The RHS being
6124                // `null` is now a *runtime* fact (it's any `ReturnExpr`, not
6125                // just the `Literal::Null` token), not something checkable
6126                // from the AST alone -- `SET n.prop = coalesce(x, null)`
6127                // must remove the property too if `x` turns out null.
6128                if let Some(id) = node_id {
6129                    self.uncache_node(id);
6130                }
6131                if matches!(value, Value::Null) {
6132                    if let Some(id) = node_id {
6133                        GraphStore::remove_node_prop_in_txn(write_txn, id, &pa.prop)?;
6134                        self.count(|s| s.properties_set += 1);
6135                    }
6136                    if let Some(id) = edge_id {
6137                        GraphStore::remove_edge_prop_in_txn(write_txn, id, &pa.prop)?;
6138                        self.count(|s| s.properties_set += 1);
6139                    }
6140                } else {
6141                    let pv = value_to_storable_property(&value).ok_or_else(|| {
6142                    QueryError::Type(format!(
6143                        "property '{}' can't be stored -- MarsDB's node/edge properties are limited \
6144                         to null/bool/int/float/string/date/duration; a list/map/node/edge/path value \
6145                         (got {value:?}) isn't storable",
6146                        pa.prop
6147                    ))
6148                })?;
6149                    if let Some(id) = node_id {
6150                        GraphStore::set_node_prop_in_txn(write_txn, id, &pa.prop, pv.clone())?;
6151                        self.count(|s| s.properties_set += 1);
6152                    }
6153                    if let Some(id) = edge_id {
6154                        GraphStore::set_edge_prop_in_txn(write_txn, id, &pa.prop, pv)?;
6155                        self.count(|s| s.properties_set += 1);
6156                    }
6157                }
6158            }
6159            SetItem::Labels(var, labels) => {
6160                let binding = row
6161                    .get(var)
6162                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
6163                match binding {
6164                    Binding::Node(id) => {
6165                        self.uncache_node(*id);
6166                        for label in labels {
6167                            GraphStore::add_node_label_in_txn(write_txn, *id, label)?;
6168                            self.count(|s| s.labels_added += 1);
6169                        }
6170                    }
6171                    // Same null-is-a-no-op rule as the property arm above.
6172                    Binding::Value(PropertyValue::Null) => {}
6173                    _ => {
6174                        return Err(QueryError::UnboundVariable(format!(
6175                            "'{var}' isn't a node — SET can only add labels to a node"
6176                        )))
6177                    }
6178                }
6179            }
6180            SetItem::MapAssign { var, value, merge } => {
6181                let binding = row
6182                    .get(var)
6183                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
6184                // Same null-is-a-no-op rule as the property arm above.
6185                if matches!(binding, Binding::Value(PropertyValue::Null)) {
6186                    return Ok(());
6187                }
6188                let node_id = if let Binding::Node(id) = binding {
6189                    Some(*id)
6190                } else {
6191                    None
6192                };
6193                let edge_id = if let Binding::Edge(id) = binding {
6194                    Some(*id)
6195                } else {
6196                    None
6197                };
6198                if node_id.is_none() && edge_id.is_none() {
6199                    return Err(QueryError::UnboundVariable(format!(
6200                        "'{var}' is a WITH-projected scalar, not a node/edge — SET needs a graph binding"
6201                    )));
6202                }
6203                if let Some(id) = node_id {
6204                    self.uncache_node(id);
6205                }
6206                let map_value = self.eval_return_expr(txn, value, row, guard)?;
6207                // A map literal is the common case, but real Cypher also
6208                // allows `SET r = a`/`SET r += a` where `a` is itself a
6209                // bound node/relationship -- copies its properties, same
6210                // as a map built from them would (TCK's Merge6 [6]/
6211                // Merge7 [4], "Copying properties from node").
6212                let entries = match map_value {
6213                    Value::Map(entries) => entries,
6214                    Value::Node(n) => n
6215                        .props
6216                        .into_iter()
6217                        .map(|(k, v)| (k, property_value_to_value(v)))
6218                        .collect(),
6219                    Value::Edge(e) => e
6220                        .props
6221                        .into_iter()
6222                        .map(|(k, v)| (k, property_value_to_value(v)))
6223                        .collect(),
6224                    other => {
6225                        return Err(QueryError::Type(format!(
6226                            "SET {var} = ...{} needs a map, node, or relationship, got {other:?}",
6227                            if *merge { " (+=)" } else { "" }
6228                        )))
6229                    }
6230                };
6231                // `SET n = {...}` (`merge: false`) replaces every existing
6232                // property -- delete whatever's already there first, not
6233                // just overwrite the map's own keys, or a key n already
6234                // had that the map doesn't mention would wrongly survive
6235                // (TCK's Set4 [2]/[3]/[4]).
6236                if !merge {
6237                    let existing_keys: Vec<String> = if let Some(id) = node_id {
6238                        deleted_entity_access(GraphStore::get_node_in_txn(txn, id)?)?
6239                            .props
6240                            .into_keys()
6241                            .collect()
6242                    } else {
6243                        deleted_entity_access(GraphStore::get_edge_in_txn(
6244                            txn,
6245                            edge_id.expect("node_id or edge_id is Some, checked above"),
6246                        )?)?
6247                        .props
6248                        .into_keys()
6249                        .collect()
6250                    };
6251                    for key in existing_keys {
6252                        if let Some(id) = node_id {
6253                            GraphStore::remove_node_prop_in_txn(write_txn, id, &key)?;
6254                            self.count(|s| s.properties_set += 1);
6255                        }
6256                        if let Some(id) = edge_id {
6257                            GraphStore::remove_edge_prop_in_txn(write_txn, id, &key)?;
6258                            self.count(|s| s.properties_set += 1);
6259                        }
6260                    }
6261                }
6262                // Either way, apply the map's own entries -- a `null`
6263                // value removes that one key (real Cypher's rule, same
6264                // "null means remove" convention `SetItem::Prop` already
6265                // has -- TCK's Set5 [4]), anything else sets it.
6266                for (key, entry_value) in entries {
6267                    if matches!(entry_value, Value::Null) {
6268                        if let Some(id) = node_id {
6269                            GraphStore::remove_node_prop_in_txn(write_txn, id, &key)?;
6270                            self.count(|s| s.properties_set += 1);
6271                        }
6272                        if let Some(id) = edge_id {
6273                            GraphStore::remove_edge_prop_in_txn(write_txn, id, &key)?;
6274                            self.count(|s| s.properties_set += 1);
6275                        }
6276                        continue;
6277                    }
6278                    let pv = value_to_storable_property(&entry_value).ok_or_else(|| {
6279                        QueryError::Type(format!(
6280                            "property '{key}' can't be stored -- MarsDB's node/edge properties are \
6281                             limited to null/bool/int/float/string/date/duration/list; a map/node/\
6282                             edge/path value (got {entry_value:?}) isn't storable"
6283                        ))
6284                    })?;
6285                    if let Some(id) = node_id {
6286                        GraphStore::set_node_prop_in_txn(write_txn, id, &key, pv.clone())?;
6287                        self.count(|s| s.properties_set += 1);
6288                    }
6289                    if let Some(id) = edge_id {
6290                        GraphStore::set_edge_prop_in_txn(write_txn, id, &key, pv)?;
6291                        self.count(|s| s.properties_set += 1);
6292                    }
6293                }
6294            }
6295        }
6296        Ok(())
6297    }
6298}
6299
6300/// `materialize_delete`'s bare-variable fast path -- deletes straight off
6301/// the row's raw `Binding` (just an id), no existence check and no
6302/// property fetch, so re-referencing an already-deleted-this-statement
6303/// entity by identity (a later row of the same multi-row `DELETE`) is a
6304/// silent dedup no-op, not an error. Mirrors `delete_value`'s shape
6305/// (including the path/null/type-error handling) but over `Binding`/
6306/// `PathBinding` (raw ids) instead of `Value`/`PathElem` (fully
6307/// materialized records).
6308/// Deletes edge `id`, first stashing its (immutable, so safe to cache)
6309/// type into `guard` -- see `ExecutionGuard::deleted_edge_types`'s own
6310/// docs for why. The lookup can't fail with a real error here: `id` was
6311/// just read out of a live `Binding::Edge`/`PathBinding::Edge` this same
6312/// transaction, so its record is still there to fetch (deletion hasn't
6313/// happened yet -- that's the very next line).
6314fn record_and_delete_edge(
6315    executor: &Executor<'_>,
6316    txn: Txn,
6317    write_txn: &WriteTransaction,
6318    id: EdgeId,
6319    guard: &ExecutionGuard<'_>,
6320) -> Result<(), QueryError> {
6321    if let Some(edge) = GraphStore::get_edge_in_txn(txn, id)? {
6322        guard.record_deleted_edge_type(id, edge.label);
6323    }
6324    if GraphStore::delete_edge_in_txn(write_txn, id)? {
6325        executor.count(|s| s.relationships_deleted += 1);
6326    }
6327    Ok(())
6328}
6329
6330/// `delete_binding`'s collect-only twin for the batched all-bare-variable
6331/// path in `delete_targets`: identical target-shape rules (nodes pended,
6332/// path edges before path nodes, null a no-op, scalar/list/map a type
6333/// error), but edge ids go into `edge_ids` (deduped through
6334/// `deleted_edges`, preserving first-encounter order) for one
6335/// `delete_edges_in_txn` call instead of being deleted one `WriteCtx`
6336/// apiece.
6337fn collect_delete_binding(
6338    binding: &Binding,
6339    deleted_edges: &mut HashSet<EdgeId>,
6340    edge_ids: &mut Vec<EdgeId>,
6341    pending_nodes: &mut HashSet<NodeId>,
6342) -> Result<(), QueryError> {
6343    match binding {
6344        Binding::Node(id) => {
6345            pending_nodes.insert(*id);
6346        }
6347        Binding::Edge(id) => {
6348            if deleted_edges.insert(*id) {
6349                edge_ids.push(*id);
6350            }
6351        }
6352        Binding::Path(elems) => {
6353            for elem in elems {
6354                if let PathBinding::Edge(id) = elem {
6355                    if deleted_edges.insert(*id) {
6356                        edge_ids.push(*id);
6357                    }
6358                }
6359            }
6360            for elem in elems {
6361                if let PathBinding::Node(id) = elem {
6362                    pending_nodes.insert(*id);
6363                }
6364            }
6365        }
6366        // A null binding is a real, legal DELETE target -- an `OPTIONAL
6367        // MATCH` that didn't match pads its variables with null, and
6368        // deleting that is a documented no-op, not an error.
6369        Binding::Value(PropertyValue::Null) => {}
6370        Binding::Value(_) | Binding::List(_) | Binding::Map(_) => {
6371            return Err(QueryError::Type(
6372                "DELETE needs a node, relationship, or path, not a scalar/list/map".into(),
6373            ))
6374        }
6375    }
6376    Ok(())
6377}
6378
6379fn delete_binding(
6380    executor: &Executor<'_>,
6381    txn: Txn,
6382    binding: &Binding,
6383    write_txn: &WriteTransaction,
6384    deleted_edges: &mut HashSet<EdgeId>,
6385    pending_nodes: &mut HashSet<NodeId>,
6386    guard: &ExecutionGuard<'_>,
6387) -> Result<(), QueryError> {
6388    match binding {
6389        Binding::Node(id) => {
6390            pending_nodes.insert(*id);
6391        }
6392        Binding::Edge(id) => {
6393            if deleted_edges.insert(*id) {
6394                record_and_delete_edge(executor, txn, write_txn, *id, guard)?;
6395            }
6396        }
6397        Binding::Path(elems) => {
6398            for elem in elems {
6399                if let PathBinding::Edge(id) = elem {
6400                    if deleted_edges.insert(*id) {
6401                        record_and_delete_edge(executor, txn, write_txn, *id, guard)?;
6402                    }
6403                }
6404            }
6405            for elem in elems {
6406                if let PathBinding::Node(id) = elem {
6407                    pending_nodes.insert(*id);
6408                }
6409            }
6410        }
6411        // A null binding is a real, legal DELETE target -- an `OPTIONAL
6412        // MATCH` that didn't match pads its variables with null, and
6413        // deleting that is a documented no-op, not an error.
6414        Binding::Value(PropertyValue::Null) => {}
6415        Binding::Value(_) | Binding::List(_) | Binding::Map(_) => {
6416            return Err(QueryError::Type(
6417                "DELETE needs a node, relationship, or path, not a scalar/list/map".into(),
6418            ))
6419        }
6420    }
6421    Ok(())
6422}
6423
6424/// Deletes whatever `value` evaluated to -- a node, a relationship, every
6425/// node/edge in a path, or nothing at all for `null` (a documented no-op:
6426/// an `OPTIONAL MATCH` that didn't match pads its variables with null, and
6427/// deleting that is specified as silent, not an error). Anything else (a
6428/// list, a map, a bare scalar, ...) is a real `QueryError::Type` --
6429/// `DELETE`'s target must resolve to a graph element, unlike `SET`'s RHS.
6430/// Edges are deleted immediately; nodes are only collected into
6431/// `pending_nodes` -- `delete_targets` (the only caller) deletes them in
6432/// its own second pass, after every target across every row has had a
6433/// chance to delete its own edges first (see its own docs for why).
6434fn delete_value(
6435    executor: &Executor<'_>,
6436    value: &Value,
6437    write_txn: &WriteTransaction,
6438    deleted_edges: &mut HashSet<EdgeId>,
6439    pending_nodes: &mut HashSet<NodeId>,
6440    guard: &ExecutionGuard<'_>,
6441) -> Result<(), QueryError> {
6442    match value {
6443        Value::Node(n) => {
6444            pending_nodes.insert(n.id);
6445        }
6446        Value::Edge(e) => {
6447            if deleted_edges.insert(e.id) {
6448                guard.record_deleted_edge_type(e.id, e.label.clone());
6449                if GraphStore::delete_edge_in_txn(write_txn, e.id)? {
6450                    executor.count(|s| s.relationships_deleted += 1);
6451                }
6452            }
6453        }
6454        Value::Path(elems) => {
6455            for elem in elems {
6456                if let PathElem::Edge(e) = elem {
6457                    if deleted_edges.insert(e.id) {
6458                        guard.record_deleted_edge_type(e.id, e.label.clone());
6459                        if GraphStore::delete_edge_in_txn(write_txn, e.id)? {
6460                            executor.count(|s| s.relationships_deleted += 1);
6461                        }
6462                    }
6463                }
6464            }
6465            for elem in elems {
6466                if let PathElem::Node(n) = elem {
6467                    pending_nodes.insert(n.id);
6468                }
6469            }
6470        }
6471        Value::Null => {}
6472        other => {
6473            return Err(QueryError::Type(format!(
6474                "DELETE needs a node, relationship, or path, got {other:?}"
6475            )))
6476        }
6477    }
6478    Ok(())
6479}
6480
6481fn apply_remove_item(
6482    executor: &Executor<'_>,
6483    write_txn: &WriteTransaction,
6484    row: &BindingRow,
6485    item: &RemoveItem,
6486) -> Result<(), QueryError> {
6487    match item {
6488        RemoveItem::Prop(pa) => {
6489            let binding = row
6490                .get(&pa.var)
6491                .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
6492            match binding {
6493                Binding::Node(id) => {
6494                    executor.uncache_node(*id);
6495                    GraphStore::remove_node_prop_in_txn(write_txn, *id, &pa.prop)?;
6496                    executor.count(|s| s.properties_set += 1);
6497                }
6498                Binding::Edge(id) => {
6499                    GraphStore::remove_edge_prop_in_txn(write_txn, *id, &pa.prop)?;
6500                    executor.count(|s| s.properties_set += 1);
6501                }
6502                // Same null-is-a-no-op rule DELETE already follows (found
6503                // via TCK's Remove1 "Ignore null when removing property"
6504                // scenarios).
6505                Binding::Value(PropertyValue::Null) => {}
6506                Binding::Value(_) | Binding::List(_) | Binding::Map(_) | Binding::Path(_) => {
6507                    return Err(QueryError::UnboundVariable(format!(
6508                        "'{}' is a WITH-projected scalar, not a node/edge — REMOVE needs a graph binding",
6509                        pa.var
6510                    )))
6511                }
6512            }
6513        }
6514        RemoveItem::Labels(var, labels) => {
6515            let binding = row
6516                .get(var)
6517                .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
6518            match binding {
6519                Binding::Node(id) => {
6520                    executor.uncache_node(*id);
6521                    for label in labels {
6522                        GraphStore::remove_node_label_in_txn(write_txn, *id, label)?;
6523                        executor.count(|s| s.labels_removed += 1);
6524                    }
6525                }
6526                // Same null-is-a-no-op rule as the property arm above
6527                // (found via TCK's Remove2 "Ignore null when removing a
6528                // node label" scenario).
6529                Binding::Value(PropertyValue::Null) => {}
6530                _ => {
6531                    return Err(QueryError::UnboundVariable(format!(
6532                        "'{var}' isn't a node — REMOVE can only remove labels from a node"
6533                    )))
6534                }
6535            }
6536        }
6537    }
6538    Ok(())
6539}
6540
6541/// Whether `tail`'s ultimate RETURN (if it has one at all -- either
6542/// `Tail::Return` itself, or a mutating tail's trailing `ReturnTail`) is a
6543/// `RETURN DISTINCT`. Used by `execute_match`'s LIMIT pre-truncate and
6544/// scan-limit-pushdown shortcuts, both of which must NOT fire for a
6545/// DISTINCT return -- dedup can drop rows, so capping the raw input at
6546/// `limit` before it runs could return fewer than `limit` distinct rows
6547/// even when more exist.
6548fn tail_is_distinct_return(tail: &Option<Tail>) -> bool {
6549    match tail {
6550        Some(Tail::Return(_, distinct)) | Some(Tail::ReturnStar(distinct)) => *distinct,
6551        Some(Tail::Delete(_, ret))
6552        | Some(Tail::DetachDelete(_, ret))
6553        | Some(Tail::Set(_, ret))
6554        | Some(Tail::Remove(_, ret))
6555        | Some(Tail::Create(_, ret)) => ret.as_ref().is_some_and(|rt| rt.distinct),
6556        None => false,
6557    }
6558}
6559
6560/// A statement never mutates anything iff it's a `MATCH ... RETURN` with no
6561/// `DELETE`/`DETACH DELETE`/`SET` tail *and* no `MERGE` clause anywhere in
6562/// it (`MERGE (n) RETURN n` has a `Tail::Return`, but still writes whenever
6563/// it has to create — checking `tail` alone here would be a real bug, not
6564/// just an incomplete check: it would send a MERGE-that-creates through a
6565/// `ReadTransaction`, which has no `.insert`). `Statement::Create` and
6566/// every other `Tail` variant always write. Confirmed by tracing every
6567/// function reachable from pattern/WHERE/WITH evaluation: none of them
6568/// ever call a table-mutating `*_in_txn` method for a `Tail::Return`
6569/// statement with no `MERGE` clause (a label-filtered scan looks up an
6570/// existing label id, it never allocates one — allocation only happens in
6571/// `create_node_in_txn`/`create_edge_in_txn`). `Executor::execute` uses
6572/// this to decide whether to open a `ReadTransaction` (no contention with
6573/// concurrent readers or a concurrent writer) or a `WriteTransaction`.
6574/// Returns whether executing `stmt` can mutate the graph. Public so callers
6575/// which execute generated or otherwise untrusted Cypher can enforce a
6576/// read-only policy using the same classification as the executor.
6577pub fn is_read_only(stmt: &Statement) -> bool {
6578    if let Statement::Union { parts, .. } = stmt {
6579        return parts.iter().all(is_read_only);
6580    }
6581    let Statement::Match {
6582        tail: Some(Tail::Return(_, _)) | Some(Tail::ReturnStar(_)),
6583        clauses,
6584        ..
6585    } = stmt
6586    else {
6587        return false;
6588    };
6589    !clauses.iter().any(|c| {
6590        matches!(
6591            c,
6592            QueryClause::Merge(_)
6593                | QueryClause::Set(_)
6594                | QueryClause::Delete { .. }
6595                | QueryClause::Remove(_)
6596                | QueryClause::Create(_)
6597                // A procedure is opaque to MarsDB -- it might write, so
6598                // any statement calling one is conservatively treated as
6599                // non-read-only too, same reasoning `Statement::
6600                // StandaloneCall` already gets for free (it isn't a
6601                // `Statement::Match` at all, so it never matches this
6602                // function's own read-only pattern above).
6603                | QueryClause::Call(_)
6604        )
6605    })
6606}
6607
6608/// Recovers the real `&WriteTransaction` from a `Txn` for `execute_match`
6609/// tail/clause arms (`DELETE`/`SET`, both the terminal-tail and
6610/// `QueryClause::Set`'s own mid-statement form) that need `.insert`/
6611/// `.remove`, not just `Txn`'s read-only `get`/`iter`. Panics if given
6612/// `Txn::Read` — which can't happen: any of these make `is_read_only`
6613/// return `false`, so `Executor::execute` always opens a
6614/// `WriteTransaction` (and thus `Txn::Write`) before reaching this path.
6615fn require_write_txn(txn: Txn<'_>) -> &WriteTransaction {
6616    let Txn::Write(write_txn) = txn else {
6617        unreachable!(
6618            "materialize_delete/materialize_set/QueryClause::Set only reached via the \
6619             write-dispatch path in Executor::execute — is_read_only(stmt) is false for any \
6620             statement with one of these, so execute always opens a WriteTransaction for them"
6621        )
6622    };
6623    write_txn
6624}
6625
6626fn default_column_name(expr: &ReturnExpr, idx: usize) -> String {
6627    match expr {
6628        ReturnExpr::Var(v) => v.clone(),
6629        ReturnExpr::Prop(pa) => format!("{}.{}", pa.var, pa.prop),
6630        ReturnExpr::Lit(_) => format!("col{idx}"),
6631        ReturnExpr::Call { name, .. } => format!("{name}(...)"),
6632        ReturnExpr::CountStar => "count(*)".to_string(),
6633        ReturnExpr::Case { .. } => format!("case{idx}"),
6634        ReturnExpr::Arith(..) | ReturnExpr::Neg(..) => format!("col{idx}"),
6635        ReturnExpr::ListLit(..)
6636        | ReturnExpr::Index(..)
6637        | ReturnExpr::PropOf(..)
6638        | ReturnExpr::Slice(..)
6639        | ReturnExpr::ListComp { .. }
6640        | ReturnExpr::Quantifier { .. }
6641        | ReturnExpr::MapLit(..)
6642        | ReturnExpr::And(..)
6643        | ReturnExpr::Or(..)
6644        | ReturnExpr::Xor(..)
6645        | ReturnExpr::Not(..)
6646        | ReturnExpr::Compare(..)
6647        | ReturnExpr::IsNull(..)
6648        | ReturnExpr::In(..)
6649        | ReturnExpr::HasLabel(..)
6650        | ReturnExpr::PatternPredicate(..)
6651        | ReturnExpr::PatternComprehension { .. }
6652        | ReturnExpr::ExistsPattern { .. }
6653        | ReturnExpr::ExistsSubquery(_) => format!("col{idx}"),
6654    }
6655}
6656
6657/// The name a `WITH`/`RETURN` item is known by afterward — its alias, or
6658/// a name derived from the expression (its bare var name, `col{i}`, etc).
6659/// `pub(crate)` so `explain.rs` can compute the same post-`WITH`
6660/// `carried_vars` set EXPLAIN needs without executing any rows.
6661pub(crate) fn with_item_output_name((i, item): (usize, &ReturnItem)) -> String {
6662    item.alias
6663        .clone()
6664        .unwrap_or_else(|| default_column_name(&item.expr, i))
6665}
6666
6667/// True iff `expr` contains an aggregate call anywhere inside it, at any
6668/// depth — used to reject an aggregate nested inside another aggregate's
6669/// argument, or inside a non-aggregate expression's `CASE`/`Call`
6670/// arguments (an aggregate must be a return item's *entire* top-level
6671/// expression — see `validate_return_items`).
6672/// Collects every aggregate-bearing subexpression in `expr` (a `CountStar`
6673/// or an aggregate-named `Call`), in a fixed pre-order -- the same
6674/// traversal `contains_aggregate` uses, just gathering references instead
6675/// of stopping at the first `true`. Doesn't recurse *into* a found node's
6676/// own arguments (an aggregate's argument is folded per-row as a whole,
6677/// not decomposed further -- see `resolve_grouped_rows`). The resulting
6678/// order is what makes a composed item's per-row folding
6679/// (`resolve_grouped_rows`) and its per-group finishing
6680/// (`Executor::rewrite_composed_item`) agree on which accumulator is
6681/// which, without needing to name or otherwise identify individual
6682/// aggregate calls within one item's expression tree.
6683fn collect_agg_nodes<'a>(expr: &'a ReturnExpr, out: &mut Vec<&'a ReturnExpr>) {
6684    match expr {
6685        ReturnExpr::CountStar => out.push(expr),
6686        ReturnExpr::Call { name, args, .. } => {
6687            if is_aggregate_name(name) {
6688                out.push(expr);
6689            } else {
6690                for arg in args {
6691                    collect_agg_nodes(arg, out);
6692                }
6693            }
6694        }
6695        ReturnExpr::Case { test, whens, else_ } => {
6696            if let Some(t) = test.as_deref() {
6697                collect_agg_nodes(t, out);
6698            }
6699            for (w, t) in whens {
6700                collect_agg_nodes(w, out);
6701                collect_agg_nodes(t, out);
6702            }
6703            if let Some(e) = else_.as_deref() {
6704                collect_agg_nodes(e, out);
6705            }
6706        }
6707        ReturnExpr::Arith(l, _, r) => {
6708            collect_agg_nodes(l, out);
6709            collect_agg_nodes(r, out);
6710        }
6711        ReturnExpr::Neg(e) => collect_agg_nodes(e, out),
6712        ReturnExpr::ListLit(items) => {
6713            for item in items {
6714                collect_agg_nodes(item, out);
6715            }
6716        }
6717        ReturnExpr::Index(base, index) => {
6718            collect_agg_nodes(base, out);
6719            collect_agg_nodes(index, out);
6720        }
6721        ReturnExpr::PropOf(base, _) => collect_agg_nodes(base, out),
6722        ReturnExpr::Slice(base, start, end) => {
6723            collect_agg_nodes(base, out);
6724            if let Some(s) = start.as_deref() {
6725                collect_agg_nodes(s, out);
6726            }
6727            if let Some(e) = end.as_deref() {
6728                collect_agg_nodes(e, out);
6729            }
6730        }
6731        // Same `where_clause`-not-checked scope limitation as
6732        // `contains_aggregate`'s matching arm.
6733        ReturnExpr::ListComp {
6734            source, project, ..
6735        } => {
6736            collect_agg_nodes(source, out);
6737            if let Some(p) = project.as_deref() {
6738                collect_agg_nodes(p, out);
6739            }
6740        }
6741        ReturnExpr::Quantifier { source, .. } => collect_agg_nodes(source, out),
6742        ReturnExpr::MapLit(entries) => {
6743            for (_, v) in entries {
6744                collect_agg_nodes(v, out);
6745            }
6746        }
6747        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
6748            collect_agg_nodes(l, out);
6749            collect_agg_nodes(r, out);
6750        }
6751        ReturnExpr::Not(e) => collect_agg_nodes(e, out),
6752        ReturnExpr::Compare(l, _, r) => {
6753            collect_agg_nodes(l, out);
6754            collect_agg_nodes(r, out);
6755        }
6756        ReturnExpr::IsNull(e) => collect_agg_nodes(e, out),
6757        ReturnExpr::In(needle, haystack) => {
6758            collect_agg_nodes(needle, out);
6759            collect_agg_nodes(haystack, out);
6760        }
6761        ReturnExpr::Var(_)
6762        | ReturnExpr::Prop(_)
6763        | ReturnExpr::Lit(_)
6764        | ReturnExpr::HasLabel(..)
6765        | ReturnExpr::PatternPredicate(..)
6766        | ReturnExpr::PatternComprehension { .. }
6767        | ReturnExpr::ExistsPattern { .. }
6768        | ReturnExpr::ExistsSubquery(_) => {}
6769    }
6770}
6771
6772pub(crate) fn contains_aggregate(expr: &ReturnExpr) -> bool {
6773    match expr {
6774        ReturnExpr::CountStar => true,
6775        ReturnExpr::Call { name, args, .. } => {
6776            is_aggregate_name(name) || args.iter().any(contains_aggregate)
6777        }
6778        ReturnExpr::Case { test, whens, else_ } => {
6779            test.as_deref().is_some_and(contains_aggregate)
6780                || whens
6781                    .iter()
6782                    .any(|(w, t)| contains_aggregate(w) || contains_aggregate(t))
6783                || else_.as_deref().is_some_and(contains_aggregate)
6784        }
6785        ReturnExpr::Arith(l, _, r) => contains_aggregate(l) || contains_aggregate(r),
6786        ReturnExpr::Neg(e) => contains_aggregate(e),
6787        ReturnExpr::ListLit(items) => items.iter().any(contains_aggregate),
6788        ReturnExpr::Index(base, index) => contains_aggregate(base) || contains_aggregate(index),
6789        ReturnExpr::PropOf(base, _) => contains_aggregate(base),
6790        ReturnExpr::Slice(base, start, end) => {
6791            contains_aggregate(base)
6792                || start.as_deref().is_some_and(contains_aggregate)
6793                || end.as_deref().is_some_and(contains_aggregate)
6794        }
6795        // `where_clause` isn't checked -- same scope limitation as
6796        // `UnwindClause`'s own filter, which never routes through this
6797        // check either; the source/project halves are the ones a real
6798        // TCK scenario nests an aggregate in (`size([x IN collect(r) ...])`).
6799        ReturnExpr::ListComp {
6800            source, project, ..
6801        } => contains_aggregate(source) || project.as_deref().is_some_and(contains_aggregate),
6802        ReturnExpr::Quantifier { source, .. } => contains_aggregate(source),
6803        ReturnExpr::MapLit(entries) => entries.iter().any(|(_, v)| contains_aggregate(v)),
6804        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
6805            contains_aggregate(l) || contains_aggregate(r)
6806        }
6807        ReturnExpr::Not(e) => contains_aggregate(e),
6808        ReturnExpr::Compare(l, _, r) => contains_aggregate(l) || contains_aggregate(r),
6809        ReturnExpr::IsNull(e) => contains_aggregate(e),
6810        ReturnExpr::In(needle, haystack) => {
6811            contains_aggregate(needle) || contains_aggregate(haystack)
6812        }
6813        ReturnExpr::Var(_)
6814        | ReturnExpr::Prop(_)
6815        | ReturnExpr::Lit(_)
6816        | ReturnExpr::HasLabel(..)
6817        | ReturnExpr::PatternPredicate(..)
6818        // A pattern comprehension's projection runs against its own
6819        // per-match row, not the outer query's group -- an aggregate
6820        // inside it wouldn't mean "aggregate across the outer group,"
6821        // it'd need its own separate grouping concept this codebase
6822        // doesn't have, so (like `PatternPredicate`) it's opaque here
6823        // rather than searched into.
6824        | ReturnExpr::PatternComprehension { .. }
6825        | ReturnExpr::ExistsPattern { .. }
6826        | ReturnExpr::ExistsSubquery(_) => false,
6827    }
6828}
6829
6830/// True iff any item's top-level expression is an aggregate call —
6831/// `materialize_with`/`materialize_return` dispatch to the grouping path
6832/// iff this is true, otherwise the existing row-at-a-time path runs
6833/// completely unchanged (zero perf/behavior impact on non-aggregating
6834/// queries).
6835/// `try_fast_expand_expand_count`'s direction support: single concrete
6836/// direction only — `Either` needs the two-call-plus-dedupe treatment the
6837/// generic path does, out of the fast path's scope.
6838fn fast_direction(dir: ExpandDirection) -> Option<Direction> {
6839    match dir {
6840        ExpandDirection::Out => Some(Direction::Out),
6841        ExpandDirection::In => Some(Direction::In),
6842        ExpandDirection::Either => None,
6843    }
6844}
6845
6846/// Single-type (`Some`) or untyped (`None`) relationship filter — the
6847/// multi-type `[:A|B]` list needs per-type iteration, out of scope.
6848/// Outer `None` = unsupported shape, inner `Option` = the filter itself.
6849#[allow(clippy::option_option)]
6850fn fast_label(labels: &[String]) -> Option<Option<&str>> {
6851    match labels {
6852        [] => Some(None),
6853        [one] => Some(Some(one.as_str())),
6854        _ => None,
6855    }
6856}
6857
6858/// Does this (sub)plan contain any expansion or externally-seeded input?
6859/// The fast path evaluates its leaf through the generic stream, but only
6860/// when the leaf is a pure scan/seek/filter chain.
6861fn plan_contains_expansion(plan: &LogicalPlan) -> bool {
6862    match plan {
6863        LogicalPlan::Expand { .. }
6864        | LogicalPlan::VarExpand { .. }
6865        | LogicalPlan::MatchRelList { .. }
6866        | LogicalPlan::EdgeTypeScan { .. }
6867        | LogicalPlan::Seed { .. } => true,
6868        LogicalPlan::Filter { input, .. } => plan_contains_expansion(input),
6869        LogicalPlan::IndexRangeSeek { .. }
6870        | LogicalPlan::AllNodesScan { .. }
6871        | LogicalPlan::NodeByLabelScan { .. }
6872        | LogicalPlan::IndexSeek { .. } => false,
6873    }
6874}
6875
6876pub(crate) fn has_aggregate(items: &[ReturnItem]) -> bool {
6877    // `contains_aggregate`, not a narrower "is the item's whole top-level
6878    // expression itself an aggregate call" check -- an aggregate nested
6879    // inside a wrapping expression (`1 + count(x)`, real Cypher composition
6880    // -- see `resolve_grouped_rows`) still needs to route to the grouping
6881    // path, both to actually compute it and so `validate_return_items` gets
6882    // a chance to reject an invalid composition with a clear error. A
6883    // narrower top-level-only check here would let such a query silently
6884    // take the ordinary per-row path instead (iterating `rows` directly,
6885    // which is empty for an empty MATCH), producing the wrong row count
6886    // instead of the right (or correctly rejected) one.
6887    items.iter().any(|item| contains_aggregate(&item.expr))
6888}
6889
6890/// True iff `expr` contains a call to `rand()` anywhere inside it, at any
6891/// depth -- same traversal shape as `contains_aggregate`, used only to
6892/// reject `rand()` as (part of) an aggregate's own argument (see
6893/// `validate_return_items`); `rand()` elsewhere in a query is completely
6894/// fine.
6895fn contains_rand_call(expr: &ReturnExpr) -> bool {
6896    match expr {
6897        ReturnExpr::Call { name, args, .. } => {
6898            name.eq_ignore_ascii_case("rand") || args.iter().any(contains_rand_call)
6899        }
6900        ReturnExpr::Case { test, whens, else_ } => {
6901            test.as_deref().is_some_and(contains_rand_call)
6902                || whens
6903                    .iter()
6904                    .any(|(w, t)| contains_rand_call(w) || contains_rand_call(t))
6905                || else_.as_deref().is_some_and(contains_rand_call)
6906        }
6907        ReturnExpr::Arith(l, _, r) => contains_rand_call(l) || contains_rand_call(r),
6908        ReturnExpr::Neg(e) => contains_rand_call(e),
6909        ReturnExpr::ListLit(items) => items.iter().any(contains_rand_call),
6910        ReturnExpr::Index(base, index) => contains_rand_call(base) || contains_rand_call(index),
6911        ReturnExpr::PropOf(base, _) => contains_rand_call(base),
6912        ReturnExpr::Slice(base, start, end) => {
6913            contains_rand_call(base)
6914                || start.as_deref().is_some_and(contains_rand_call)
6915                || end.as_deref().is_some_and(contains_rand_call)
6916        }
6917        ReturnExpr::ListComp {
6918            source, project, ..
6919        } => contains_rand_call(source) || project.as_deref().is_some_and(contains_rand_call),
6920        ReturnExpr::Quantifier { source, .. } => contains_rand_call(source),
6921        ReturnExpr::MapLit(entries) => entries.iter().any(|(_, v)| contains_rand_call(v)),
6922        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
6923            contains_rand_call(l) || contains_rand_call(r)
6924        }
6925        ReturnExpr::Not(e) => contains_rand_call(e),
6926        ReturnExpr::Compare(l, _, r) => contains_rand_call(l) || contains_rand_call(r),
6927        ReturnExpr::IsNull(e) => contains_rand_call(e),
6928        ReturnExpr::In(needle, haystack) => {
6929            contains_rand_call(needle) || contains_rand_call(haystack)
6930        }
6931        ReturnExpr::CountStar
6932        | ReturnExpr::Var(_)
6933        | ReturnExpr::Prop(_)
6934        | ReturnExpr::Lit(_)
6935        | ReturnExpr::HasLabel(..)
6936        | ReturnExpr::PatternPredicate(..)
6937        // Same opaque treatment as `contains_aggregate`'s own arm above --
6938        // a pattern comprehension's projection is checked once it's
6939        // actually evaluated per match, not searched into ahead of time.
6940        | ReturnExpr::PatternComprehension { .. }
6941        | ReturnExpr::ExistsPattern { .. }
6942        | ReturnExpr::ExistsSubquery(_) => false,
6943    }
6944}
6945
6946/// `RETURN *`/`RETURN DISTINCT *` resolved into the equivalent concrete
6947/// item list -- one bare-`Var` item per currently-bound name, sorted
6948/// alphabetically (real Cypher's own `RETURN *` column order, confirmed
6949/// against the TCK's own multi-variable scenarios, not introduction
6950/// order). Shared by `semantic.rs` (`scope.keys()`) and this file's own
6951/// `execute_match` (`carried_vars`) -- each already has its own accurate
6952/// bound-name set on hand at the point `Tail::ReturnStar` is reached, so
6953/// resolving it there (rather than via a separate whole-AST-mutation
6954/// pass before execution) needs no `&mut Statement` ripple through
6955/// `Executor::execute`'s public signature. Real Cypher's own
6956/// `NoVariablesInScope` compile-time error when nothing is bound at all
6957/// (TCK's Return7 `[2]`, `MATCH () RETURN *`). `WITH *` doesn't share this
6958/// restriction -- an empty `WITH *` is a legal, if useless, "carry forward
6959/// nothing" no-op (TCK's Create3 `[2]`/`[3]`: `MATCH () CREATE () WITH *
6960/// CREATE ()`, every token anonymous) -- see `with_star_items` below.
6961pub(crate) fn return_star_items(
6962    names: impl Iterator<Item = String>,
6963) -> Result<Vec<ReturnItem>, QueryError> {
6964    let names: Vec<String> = names.collect();
6965    if names.is_empty() {
6966        return Err(QueryError::Semantic(
6967            "RETURN * needs at least one variable in scope".into(),
6968        ));
6969    }
6970    Ok(star_items(names))
6971}
6972
6973/// `WITH *`'s own version of `return_star_items` -- same alphabetical
6974/// `Var`-per-name expansion, but tolerates an empty name set instead of
6975/// erroring (see that function's docs for why the two differ).
6976pub(crate) fn with_star_items(names: impl Iterator<Item = String>) -> Vec<ReturnItem> {
6977    star_items(names.collect())
6978}
6979
6980fn star_items(mut names: Vec<String>) -> Vec<ReturnItem> {
6981    names.sort();
6982    names
6983        .into_iter()
6984        .map(|name| ReturnItem {
6985            expr: ReturnExpr::Var(name),
6986            alias: None,
6987        })
6988        .collect()
6989}
6990
6991/// Validates a RETURN/WITH item list before any row is processed. Two
6992/// checks, both real Cypher compile-time errors:
6993///
6994/// - Every aggregate call (found anywhere -- not just a return item's
6995///   whole top-level expression, since `RETURN a, count(a) + 3`-style
6996///   composition is real Cypher, TCK's Return6 `[2]` etc) has the right
6997///   number of arguments, doesn't nest another aggregate inside its own
6998///   argument (`NestedAggregation`), and isn't given a non-deterministic
6999///   argument like `rand()` (`NonConstantExpression`).
7000/// - Once *any* item aggregates, every other item's own non-aggregate
7001///   leaf (a bare `Var`/`Prop` used outside any aggregate call) must
7002///   match some *other* item's whole top-level expression verbatim
7003///   (`AmbiguousAggregationExpression`, TCK's Return6 `[20]`/`[21]`) --
7004///   real Cypher's rule that a value used alongside an aggregate must
7005///   itself be an explicit grouping key, not just something that happens
7006///   to be in scope. A literal/param is always fine (same value on every
7007///   row, nothing to group by). This is checked by recursing into every
7008///   item whose expression contains an aggregate anywhere, stopping at
7009///   each aggregate-bearing subexpression itself (its own argument
7010///   doesn't need to be grouping-key-safe -- it's folded per row).
7011pub(crate) fn validate_return_items(items: &[ReturnItem]) -> Result<(), QueryError> {
7012    for item in items {
7013        if contains_aggregate(&item.expr) {
7014            validate_composed_expr(&item.expr, items)?;
7015        }
7016    }
7017    Ok(())
7018}
7019
7020/// Whether `expr` (a leaf found inside some *other* composed expression)
7021/// refers to `item` -- either structurally (`item.expr == *expr`) or, for
7022/// a bare `Var`, by `item`'s own output *alias* (`RETURN me.age AS age
7023/// ... ORDER BY age + count(...)`, TCK's ReturnOrderBy6 `[2]`: `age`
7024/// alone doesn't structurally equal `me.age`, but it's still exactly
7025/// item `age`'s value). Shared by `validate_composed_expr`'s compile-time
7026/// check and `Executor::rewrite_composed_item`'s matching runtime lookup
7027/// -- both need to agree on what counts as "the same grouping key,"
7028/// including this by-alias case, or one would accept what the other
7029/// can't actually evaluate.
7030pub(crate) fn item_matches_leaf(expr: &ReturnExpr, index: usize, item: &ReturnItem) -> bool {
7031    item.expr == *expr
7032        || matches!(expr, ReturnExpr::Var(name) if *name == with_item_output_name((index, item)))
7033}
7034
7035pub(crate) fn validate_composed_expr(
7036    expr: &ReturnExpr,
7037    items: &[ReturnItem],
7038) -> Result<(), QueryError> {
7039    if matches!(expr, ReturnExpr::CountStar) {
7040        return Ok(());
7041    }
7042    if let ReturnExpr::Call { name, args, .. } = expr {
7043        if is_aggregate_name(name) {
7044            // `percentileCont`/`percentileDisc` take a second argument
7045            // (the percentile) alongside the value being aggregated —
7046            // every other aggregate takes exactly one.
7047            let expected_args = if is_percentile_name(name) { 2 } else { 1 };
7048            if args.len() != expected_args {
7049                return Err(QueryError::Semantic(if expected_args == 2 {
7050                    format!("{name}() takes exactly two arguments (the value, then the percentile)")
7051                } else {
7052                    format!(
7053                        "{name}() takes exactly one argument (use count(*) for a row count with no argument)"
7054                    )
7055                }));
7056            }
7057            for arg in args {
7058                if contains_aggregate(arg) {
7059                    return Err(QueryError::Semantic(format!(
7060                        "aggregate function '{name}' can't take another aggregate as an argument"
7061                    )));
7062                }
7063                // `count(rand())` etc -- an aggregate's argument must be
7064                // deterministic per row for grouping/re-execution to have
7065                // well-defined semantics, which `rand()` (a fresh value on
7066                // every call, see its own docs) fundamentally breaks. Real
7067                // Cypher rejects this at compile time (TCK's Return6
7068                // [15], `NonConstantExpression`), not just "whatever value
7069                // it happens to produce."
7070                if contains_rand_call(arg) {
7071                    return Err(QueryError::Semantic(format!(
7072                        "aggregate function '{name}' can't take a non-deterministic expression \
7073                         (e.g. rand()) as an argument"
7074                    )));
7075                }
7076            }
7077            return Ok(());
7078        }
7079    }
7080    if matches!(expr, ReturnExpr::Var(_) | ReturnExpr::Prop(_)) {
7081        let is_grouping_key = items
7082            .iter()
7083            .enumerate()
7084            .any(|(i, it)| item_matches_leaf(expr, i, it) && !contains_aggregate(&it.expr));
7085        return if is_grouping_key {
7086            Ok(())
7087        } else {
7088            Err(QueryError::Semantic(format!(
7089                "{expr:?} is used alongside an aggregate function but isn't itself one of this \
7090                 RETURN/WITH's own items -- once any item aggregates, every other value used \
7091                 with it must be listed as its own explicit grouping key"
7092            )))
7093        };
7094    }
7095    // `Lit`/`HasLabel`/`PatternPredicate`/`PatternComprehension` need no
7096    // check here: a literal is the same value on every row (nothing to
7097    // group by), and the other three are opaque leaves for this same
7098    // reason `contains_aggregate`/`collect_agg_nodes` treat them that way
7099    // (see their own docs) -- not reachable with real content to check
7100    // since none can themselves contain an aggregate.
7101    match expr {
7102        ReturnExpr::Case { test, whens, else_ } => {
7103            if let Some(t) = test.as_deref() {
7104                validate_composed_expr(t, items)?;
7105            }
7106            for (w, t) in whens {
7107                validate_composed_expr(w, items)?;
7108                validate_composed_expr(t, items)?;
7109            }
7110            if let Some(e) = else_.as_deref() {
7111                validate_composed_expr(e, items)?;
7112            }
7113        }
7114        ReturnExpr::Call { args, .. } => {
7115            for arg in args {
7116                validate_composed_expr(arg, items)?;
7117            }
7118        }
7119        ReturnExpr::Arith(l, _, r) => {
7120            validate_composed_expr(l, items)?;
7121            validate_composed_expr(r, items)?;
7122        }
7123        ReturnExpr::Neg(e) => validate_composed_expr(e, items)?,
7124        ReturnExpr::ListLit(list_items) => {
7125            for item in list_items {
7126                validate_composed_expr(item, items)?;
7127            }
7128        }
7129        ReturnExpr::Index(base, index) => {
7130            validate_composed_expr(base, items)?;
7131            validate_composed_expr(index, items)?;
7132        }
7133        ReturnExpr::PropOf(base, _) => validate_composed_expr(base, items)?,
7134        ReturnExpr::Slice(base, start, end) => {
7135            validate_composed_expr(base, items)?;
7136            if let Some(s) = start.as_deref() {
7137                validate_composed_expr(s, items)?;
7138            }
7139            if let Some(e) = end.as_deref() {
7140                validate_composed_expr(e, items)?;
7141            }
7142        }
7143        // `source` may itself be a (possibly composed) aggregate --
7144        // `[x IN collect(p) | head(nodes(x))]` aggregates once per group
7145        // to build the list, then the comprehension iterates its result
7146        // normally (TCK's List12 [4]/[5], real and required) -- recursed
7147        // into below via the generic `Call`/`Arith`/etc. machinery, same
7148        // as any other composed leaf. `project`, in contrast, runs once
7149        // *per element* of that already-built list -- an aggregate
7150        // there has no defined semantics at all (real Cypher flatly
7151        // rejects it, TCK's List12 [7], "Fail when using aggregation in
7152        // list comprehension") and `resolve_grouped_rows` has no
7153        // "fold once per group, then run per element" fold shape for it
7154        // anyway, so it's checked directly here rather than falling
7155        // through to the generic recursion below, which would otherwise
7156        // validate (and `rewrite_composed_item` would then evaluate) a
7157        // nested aggregate as if it were an ordinary composed leaf.
7158        ReturnExpr::ListComp {
7159            source,
7160            project,
7161            where_clause,
7162            ..
7163        } => {
7164            if project.as_deref().is_some_and(contains_aggregate) {
7165                return Err(QueryError::Semantic(
7166                    "an aggregate function can't be used inside a list comprehension's projection"
7167                        .into(),
7168                ));
7169            }
7170            validate_composed_expr(source, items)?;
7171            // `where_clause` isn't checked -- same scope limitation as
7172            // `contains_aggregate`'s own matching arm.
7173            let _ = where_clause;
7174        }
7175        ReturnExpr::Quantifier { source, .. } => validate_composed_expr(source, items)?,
7176        ReturnExpr::MapLit(entries) => {
7177            for (_, v) in entries {
7178                validate_composed_expr(v, items)?;
7179            }
7180        }
7181        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
7182            validate_composed_expr(l, items)?;
7183            validate_composed_expr(r, items)?;
7184        }
7185        ReturnExpr::Not(e) => validate_composed_expr(e, items)?,
7186        ReturnExpr::Compare(l, _, r) => {
7187            validate_composed_expr(l, items)?;
7188            validate_composed_expr(r, items)?;
7189        }
7190        ReturnExpr::IsNull(e) => validate_composed_expr(e, items)?,
7191        ReturnExpr::In(needle, haystack) => {
7192            validate_composed_expr(needle, items)?;
7193            validate_composed_expr(haystack, items)?;
7194        }
7195        ReturnExpr::CountStar
7196        | ReturnExpr::Var(_)
7197        | ReturnExpr::Prop(_)
7198        | ReturnExpr::Lit(_)
7199        | ReturnExpr::HasLabel(..)
7200        | ReturnExpr::PatternPredicate(..)
7201        | ReturnExpr::PatternComprehension { .. }
7202        | ReturnExpr::ExistsPattern { .. }
7203        | ReturnExpr::ExistsSubquery(_) => {}
7204    }
7205    Ok(())
7206}
7207
7208/// Same rules as `validate_composed_expr` (reused directly, first), plus
7209/// one more real Cypher only enforces for an ORDER BY key specifically,
7210/// not for a RETURN/WITH item's own composed expression: every
7211/// aggregate-bearing subexpression found anywhere in it must itself
7212/// verbatim/alias-match some existing RETURN/WITH item (TCK's
7213/// WithOrderBy4 `[14]`, "Fail on sorting by a non-projected aggregation
7214/// on an expression" -- `ORDER BY sum(x)` when the WITH only computes
7215/// `min(x)`, a *different* aggregate over the same argument, is a real
7216/// compile-time error, not "just fold it separately"). A RETURN/WITH
7217/// item's own composed expression has no such restriction -- `RETURN a,
7218/// count(a) + sum(b)` folds both `count(a)` and `sum(b)` fresh as part of
7219/// evaluating that one item, with nothing else either needs to match.
7220pub(crate) fn validate_order_by_composed_expr(
7221    expr: &ReturnExpr,
7222    items: &[ReturnItem],
7223) -> Result<(), QueryError> {
7224    validate_composed_expr(expr, items)?;
7225    let mut agg_nodes = Vec::new();
7226    collect_agg_nodes(expr, &mut agg_nodes);
7227    for node in agg_nodes {
7228        let matches_item = items
7229            .iter()
7230            .enumerate()
7231            .any(|(i, it)| item_matches_leaf(node, i, it));
7232        if !matches_item {
7233            return Err(QueryError::Semantic(
7234                "ORDER BY aggregate does not match any RETURN/WITH item".into(),
7235            ));
7236        }
7237    }
7238    Ok(())
7239}
7240
7241/// Grouping-key hashing — deliberately at the `Binding` level (`NodeId`/
7242/// `EdgeId`/`PropertyValue`), not `Value`: cheaper (no `GraphStore` fetch
7243/// just to compute) and the correct semantics (two `Binding::Node`s are
7244/// the same group iff the same node **identity**, not equal-by-struct-
7245/// contents). `Binding::List`'s elements are `Value`s already, so those
7246/// delegate to `value_hash_key` directly.
7247fn binding_hash_key(b: &Binding) -> Result<HashKey, QueryError> {
7248    Ok(match b {
7249        Binding::Node(id) => HashKey::Node(*id),
7250        Binding::Edge(id) => HashKey::Edge(*id),
7251        Binding::Value(pv) => property_value_hash_key(pv),
7252        Binding::List(items) => HashKey::List(
7253            items
7254                .iter()
7255                .map(value_hash_key)
7256                .collect::<Result<Vec<_>, _>>()?,
7257        ),
7258        // A path's identity is its exact node/edge sequence, in order --
7259        // same graph-identity-by-id convention as the `Node`/`Edge` arms
7260        // above, just walked element-by-element (found via TCK's
7261        // Pattern2 [8]: `WITH [p = (n)-->() | p] AS ps, count(b) AS c`
7262        // makes `ps` -- a list of paths -- an implicit GROUP BY key,
7263        // real Cypher's own rule that every non-aggregate WITH/RETURN
7264        // item groups by).
7265        Binding::Path(elems) => HashKey::List(
7266            elems
7267                .iter()
7268                .map(|e| match e {
7269                    PathBinding::Node(id) => HashKey::Node(*id),
7270                    PathBinding::Edge(id) => HashKey::Edge(*id),
7271                })
7272                .collect(),
7273        ),
7274        // Same canonical-sorted-entries encoding as `value_hash_key`'s
7275        // matching `Value::Map` arm (a `BTreeMap` already iterates in
7276        // sorted key order).
7277        Binding::Map(m) => HashKey::List(
7278            m.iter()
7279                .map(|(k, v)| -> Result<HashKey, QueryError> {
7280                    Ok(HashKey::List(vec![
7281                        HashKey::Str(k.clone()),
7282                        value_hash_key(v)?,
7283                    ]))
7284                })
7285                .collect::<Result<Vec<_>, _>>()?,
7286        ),
7287    })
7288}
7289
7290/// Projects one of `ProcedureProvider::call`'s raw output rows (positional,
7291/// `sig.outputs.len()` values in that order) down to whatever `yield_items`
7292/// actually asked for -- `YIELD *` keeps every output under its own name;
7293/// an explicit item list picks out just those (by the procedure's own
7294/// declared name, not any rename yet) and pairs each with its `AS` alias
7295/// if it had one, same output order the `YIELD` itself was written in
7296/// (TCK's Call5 `[3]`: order is irrelevant to the *result*, but this still
7297/// preserves whatever order was written, which `materialize_return`-style
7298/// column ordering downstream expects to already be correct).
7299fn project_call_row(
7300    sig: &ProcedureSignature,
7301    proc_row: &[Value],
7302    yield_items: &CallYield,
7303) -> Result<Vec<Value>, QueryError> {
7304    match yield_items {
7305        CallYield::Star => Ok(proc_row.to_vec()),
7306        CallYield::Items(items, _) => items
7307            .iter()
7308            .map(|(name, _)| {
7309                let idx = sig.outputs.iter().position(|o| o == name).ok_or_else(|| {
7310                    QueryError::Semantic(format!(
7311                        "'{name}' isn't a declared output of this procedure"
7312                    ))
7313                })?;
7314                Ok(proc_row[idx].clone())
7315            })
7316            .collect(),
7317    }
7318}
7319
7320/// Coarse compile-time-shaped argument-type check (TCK's Call2
7321/// `[5]`/`[6]`: passing a `BOOLEAN` where `INTEGER` is declared must
7322/// error, even against an empty mock table that would otherwise just
7323/// silently return zero rows). `Value::Null` always matches regardless of
7324/// declared type -- every signature this codebase's own callers declare
7325/// is nullable (`INTEGER?` etc, TCK's Call4), and there's no dedicated
7326/// non-null marker to check against anyway. An unrecognized type name is
7327/// tolerated (accepts anything) rather than rejected -- this is a coarse
7328/// sanity check for the handful of type names TCK's own procedures
7329/// actually declare (`INTEGER`/`FLOAT`/`NUMBER`/`STRING`/`BOOLEAN`), not a
7330/// full type system.
7331fn value_matches_declared_type(value: &Value, declared: &str) -> bool {
7332    if matches!(value, Value::Null) {
7333        return true;
7334    }
7335    let is_int = matches!(
7336        value,
7337        Value::Literal(Literal::Int(_)) | Value::Property(PropertyValue::Int(_))
7338    );
7339    let is_float = matches!(
7340        value,
7341        Value::Literal(Literal::Float(_)) | Value::Property(PropertyValue::Float(_))
7342    );
7343    match declared.trim_end_matches('?').to_ascii_uppercase().as_str() {
7344        "INTEGER" => is_int,
7345        "FLOAT" | "NUMBER" => is_int || is_float,
7346        "STRING" => matches!(
7347            value,
7348            Value::Literal(Literal::String(_)) | Value::Property(PropertyValue::String(_))
7349        ),
7350        "BOOLEAN" => matches!(
7351            value,
7352            Value::Literal(Literal::Bool(_)) | Value::Property(PropertyValue::Bool(_))
7353        ),
7354        _ => true,
7355    }
7356}
7357
7358/// Converts a finished `AggAcc::finish()` result to the `Binding` it's
7359/// carried as through a `WITH` boundary — `collect()`'s `Value::List`
7360/// needs `Binding::List`, not `Binding::Value(PropertyValue::List(_))`:
7361/// `Binding::List` carries full `Value` elements (a `Node`/`Edge`'s real
7362/// id, restorable graph identity), while `PropertyValue::List` is the
7363/// flatter, storage-format shape (scalar elements only) -- collapsing a
7364/// `collect()` of nodes down to that would lose the ability to keep
7365/// traversing from them after the `WITH`. Everything else collapses to
7366/// `Binding::Value` same as any other computed WITH item.
7367fn value_to_binding(v: Value) -> Binding {
7368    match v {
7369        Value::List(items) => Binding::List(items),
7370        Value::Map(m) => Binding::Map(m),
7371        other => Binding::Value(value_to_property_value(&other)),
7372    }
7373}
7374
7375/// `UNWIND`'s counterpart to `value_to_binding` — restores graph identity
7376/// from a `collect()`'d element instead of collapsing it. `Value::Node`/
7377/// `Edge` carry their full `id`, so this isn't lossy the way carrying only
7378/// a display value would be: a `MATCH` after the `UNWIND` can keep
7379/// traversing from the restored `Binding::Node`/`Edge`, exactly as if it
7380/// had been bound by a fresh scan/expand. See `Binding::List`'s docs,
7381/// which anticipated this exact restoration.
7382fn value_to_binding_restore(v: &Value) -> Binding {
7383    match v {
7384        Value::Node(n) => Binding::Node(n.id),
7385        Value::Edge(e) => Binding::Edge(e.id),
7386        Value::Property(pv) => Binding::Value(pv.clone()),
7387        Value::Literal(lit) => Binding::Value(literal_to_value(lit)),
7388        Value::List(items) => Binding::List(items.clone()),
7389        Value::Map(m) => Binding::Map(m.clone()),
7390        Value::Path(elems) => Binding::Path(elems.iter().map(path_elem_to_binding).collect()),
7391        Value::Null => Binding::Value(PropertyValue::Null),
7392    }
7393}
7394
7395fn path_elem_to_binding(elem: &PathElem) -> PathBinding {
7396    match elem {
7397        PathElem::Node(n) => PathBinding::Node(n.id),
7398        PathElem::Edge(e) => PathBinding::Edge(e.id),
7399    }
7400}
7401
7402/// When a path is being captured, every hop's rel/node needs a trackable
7403/// binding even if the user left it anonymous — `Expand` only inserts a
7404/// `rel_var` into the row `if let Some(rv) = rel_var`, silently dropping
7405/// anonymous rels, which is fine for ordinary matching but loses exactly
7406/// the information path assembly needs. Returns a clone of `pattern` with
7407/// every position named (synthesizing `__path_elemN` for anything
7408/// anonymous), plus the set of names that were synthesized so
7409/// `execute_match` can strip them from the row again after `assemble_path`
7410/// runs — they were never something the user could reference. Only this
7411/// renamed clone is used for plan-building/OPTIONAL-MATCH null-padding
7412/// bookkeeping *within this one clause*; `carried_vars` (what's exposed to
7413/// later clauses) is still computed from the original `part.pattern`
7414/// elsewhere, so synthesized names never leak past this function's caller.
7415fn name_pattern_for_path(pattern: &Pattern) -> (Pattern, HashSet<String>) {
7416    fn fresh(counter: &mut usize, synthesized: &mut HashSet<String>) -> String {
7417        *counter += 1;
7418        let name = format!("__path_elem{counter}");
7419        synthesized.insert(name.clone());
7420        name
7421    }
7422    let mut counter = 0usize;
7423    let mut synthesized = HashSet::new();
7424    let mut start = pattern.start.clone();
7425    if start.var.is_none() {
7426        start.var = Some(fresh(&mut counter, &mut synthesized));
7427    }
7428    let hops = pattern
7429        .hops
7430        .iter()
7431        .map(|(rel, node)| {
7432            let mut rel = rel.clone();
7433            if rel.hop_range.is_some() {
7434                // A variable-length hop's own internally-traversed edges
7435                // are exposed via a fresh synthesized binding name (same
7436                // `fresh()` mechanism as every other anonymous token
7437                // here, so multiple variable-length hops in one pattern
7438                // each get their own, no collision -- TCK's Match6
7439                // `[17]`), read by `planner::build_match_plan` (its
7440                // `VarExpand`'s `path_segment_var`) and `assemble_path`.
7441                // The user's own real rel-list variable, if this hop had
7442                // one (`p = (a)-[r*1..3]->(b)`, TCK's Match9 `[9]`), is
7443                // preserved separately in `rel_list_var` rather than lost
7444                // to this overwrite -- `var` itself is always this hop's
7445                // internal path-segment bookkeeping name from here on.
7446                rel.rel_list_var = rel.var.take();
7447                rel.var = Some(fresh(&mut counter, &mut synthesized));
7448                rel.capture_path_segment = true;
7449            } else if rel.var.is_none() {
7450                rel.var = Some(fresh(&mut counter, &mut synthesized));
7451            }
7452            let mut node = node.clone();
7453            if node.var.is_none() {
7454                node.var = Some(fresh(&mut counter, &mut synthesized));
7455            }
7456            (rel, node)
7457        })
7458        .collect();
7459    (Pattern { start, hops }, synthesized)
7460}
7461
7462/// Assembles a `Binding::Path` from `pattern`'s (fully-named, via
7463/// `name_pattern_for_path`) start/hop variables, in pattern order. Falls
7464/// back to `Binding::Value(Null)` — never errors — if any position isn't a
7465/// real node/edge binding, which only happens when this row came from
7466/// `OPTIONAL MATCH` null-padding (every position `name_pattern_for_path`
7467/// named is guaranteed present in the row either way, as a real binding or
7468/// as `Binding::Value(Null)`, so "missing key" isn't a case this needs to
7469/// handle) — same "no match survives as Null, not a dropped row" outcome
7470/// `OPTIONAL MATCH` already gives every other variable.
7471fn assemble_path(pattern: &Pattern, row: &BindingRow) -> Binding {
7472    let Some(start_id) = path_node_id(pattern.start.var.as_deref(), row) else {
7473        return Binding::Value(PropertyValue::Null);
7474    };
7475    let mut elems = vec![PathBinding::Node(start_id)];
7476    for (rel, node) in &pattern.hops {
7477        if rel.capture_path_segment {
7478            // A variable-length hop's own segment, deposited by
7479            // `expand_variable_row` under this hop's own synthesized
7480            // `rel.var` -- already the exact alternating Edge/Node/.../
7481            // Node sequence this hop contributes, ending at `node`'s own
7482            // binding (so no separate `path_node_id(node.var, ...)` read
7483            // is needed after this).
7484            let Some(Binding::Path(segment)) = rel.var.as_deref().and_then(|v| row.get(v)) else {
7485                return Binding::Value(PropertyValue::Null);
7486            };
7487            elems.extend(segment.iter().cloned());
7488            continue;
7489        }
7490        let Some(edge_id) = path_edge_id(rel.var.as_deref(), row) else {
7491            return Binding::Value(PropertyValue::Null);
7492        };
7493        let Some(node_id) = path_node_id(node.var.as_deref(), row) else {
7494            return Binding::Value(PropertyValue::Null);
7495        };
7496        elems.push(PathBinding::Edge(edge_id));
7497        elems.push(PathBinding::Node(node_id));
7498    }
7499    Binding::Path(elems)
7500}
7501
7502/// `[r:TYPE*1..3]`'s own `r` -- real Cypher binds the traversed
7503/// relationships as a *list*, fully materialized (not just ids the way
7504/// `path_segment_var`'s cheaper `Binding::Path` segment stays), since
7505/// `Binding::List` -- like every other post-projection value shape --
7506/// only ever holds already-resolved `Value`s (TCK's Match4 `[1]`/`[6]`).
7507fn segment_edges_to_list(txn: Txn, segment: &[PathBinding]) -> Result<Binding, QueryError> {
7508    let edges = segment
7509        .iter()
7510        .filter_map(|elem| match elem {
7511            PathBinding::Edge(id) => Some(*id),
7512            PathBinding::Node(_) => None,
7513        })
7514        .map(|id| {
7515            let edge = deleted_entity_access(GraphStore::get_edge_in_txn(txn, id)?)?;
7516            Ok(Value::Edge(edge))
7517        })
7518        .collect::<Result<Vec<_>, QueryError>>()?;
7519    Ok(Binding::List(edges))
7520}
7521
7522fn path_node_id(var: Option<&str>, row: &BindingRow) -> Option<NodeId> {
7523    match var.and_then(|v| row.get(v)) {
7524        Some(Binding::Node(id)) => Some(*id),
7525        _ => None,
7526    }
7527}
7528
7529fn path_edge_id(var: Option<&str>, row: &BindingRow) -> Option<EdgeId> {
7530    match var.and_then(|v| row.get(v)) {
7531        Some(Binding::Edge(id)) => Some(*id),
7532        _ => None,
7533    }
7534}
7535
7536fn require_bound_node(row: &BindingRow, var: &str) -> Result<NodeId, QueryError> {
7537    match row.get(var) {
7538        Some(Binding::Node(id)) => Ok(*id),
7539        _ => Err(QueryError::UnboundVariable(format!(
7540            "'{var}' must already be bound to a node before shortestPath() — match it in a preceding MATCH"
7541        ))),
7542    }
7543}
7544
7545/// Walks `parent` (populated by `shortest_path_between`'s BFS) backward
7546/// from `end` to `start`, then reverses — `parent` only ever needs to
7547/// answer "how did BFS first reach this node," not support any other
7548/// traversal, so a plain `HashMap` (not a `LogicalPlan`/adjacency
7549/// structure) is enough.
7550fn reconstruct_path(
7551    parent: &HashMap<NodeId, (NodeId, EdgeId)>,
7552    start: NodeId,
7553    end: NodeId,
7554) -> Vec<PathBinding> {
7555    let mut hops = Vec::new();
7556    let mut current = end;
7557    while current != start {
7558        let (prev, edge_id) = parent[&current];
7559        hops.push((edge_id, current));
7560        current = prev;
7561    }
7562    hops.reverse();
7563    let mut elems = vec![PathBinding::Node(start)];
7564    for (edge_id, node) in hops {
7565        elems.push(PathBinding::Edge(edge_id));
7566        elems.push(PathBinding::Node(node));
7567    }
7568    elems
7569}
7570
7571/// Coerces a materialized `Value` down to a `PropertyValue` for storing in
7572/// `Binding::Value` — used by `item_binding` for a computed (non-bare-var)
7573/// WITH/RETURN item. `Value::Node`/`Edge` can't occur here in practice (no
7574/// non-aggregate `ReturnExpr` form produces one except `Var`, which takes
7575/// the bare-variable path instead), and a bare `collect()` result is
7576/// routed to `Binding::List` before reaching here (see `has_aggregate`) --
7577/// both still fall back to `Null` rather than needing a fallible signature
7578/// for an unreachable case. `Value::List` genuinely *can* reach here now,
7579/// though (`WITH n.numbers + [4] AS x` -- a real computed list expression,
7580/// not a bare `collect()`, once list-valued properties round-trip through
7581/// `lookup_prop_value` as real `Value::List`s) -- recurses per-element,
7582/// same as `value_to_storable_property`'s own list handling.
7583fn value_to_property_value(v: &Value) -> PropertyValue {
7584    match v {
7585        Value::Null => PropertyValue::Null,
7586        Value::Property(pv) => pv.clone(),
7587        Value::Literal(lit) => literal_to_value(lit),
7588        Value::List(items) => {
7589            PropertyValue::List(items.iter().map(value_to_property_value).collect())
7590        }
7591        Value::Node(_) | Value::Edge(_) | Value::Map(_) | Value::Path(_) => PropertyValue::Null,
7592    }
7593}
7594
7595/// `eval_props_to_values`'s stricter cousin of `value_to_property_value`
7596/// above -- a CREATE/SET prop value that evaluates to a node/edge/path/map
7597/// is a real, reportable error (`None` here), not a silent `Null`.
7598/// `value_to_property_value`'s silent-`Null` fallback is correct at *its*
7599/// call sites (a WITH-projected scalar, where those shapes genuinely can't
7600/// occur — see its own doc comment) but was never meant for CREATE/SET's
7601/// prop value, where writing one of those is a real, everyday mistake
7602/// (`CREATE (n {tags: some_node})`) that should say so, not silently store
7603/// `null`. `Value::List` *is* storable (`PropertyValue::List`, real
7604/// Cypher/Neo4j's own "homogeneous array property" shape) -- recurses
7605/// per-element, so a list containing something unstorable (a nested list
7606/// isn't rejected here, since no TCK scenario tests that restriction and
7607/// nothing about `PropertyValue::List`'s own storage format requires it,
7608/// but a node/edge/path/map element still correctly fails the whole list).
7609fn value_to_storable_property(v: &Value) -> Option<PropertyValue> {
7610    match v {
7611        Value::Null => Some(PropertyValue::Null),
7612        Value::Property(pv) => Some(pv.clone()),
7613        Value::Literal(lit) => Some(literal_to_value(lit)),
7614        Value::List(items) => Some(PropertyValue::List(
7615            items
7616                .iter()
7617                .map(value_to_storable_property)
7618                .collect::<Option<Vec<_>>>()?,
7619        )),
7620        Value::Node(_) | Value::Edge(_) | Value::Map(_) | Value::Path(_) => None,
7621    }
7622}
7623
7624/// `value_to_storable_property`'s inverse -- turns a raw stored/bound
7625/// `PropertyValue` back into a real `Value`, the read-time counterpart
7626/// every property-access site (`lookup_prop_value`, `binding_to_value`,
7627/// `eval_projected_expr`'s node/edge prop arms) needs. A scalar wraps as
7628/// `Value::Property` exactly as before; `PropertyValue::List` becomes a
7629/// genuine `Value::List` (not `Value::Property(PropertyValue::List(_))`)
7630/// so every existing list operation (`size()`, `tail()`, indexing, `IN`,
7631/// `UNWIND`, ...) -- all of which pattern-match on `Value::List`
7632/// specifically -- works transparently on a property-sourced list the
7633/// same as a list literal/`collect()` result, with no special-casing
7634/// needed anywhere else. `PropertyValue::Null` collapses to `Value::Null`,
7635/// matching every other property-read site's existing null convention.
7636fn property_value_to_value(pv: PropertyValue) -> Value {
7637    match pv {
7638        PropertyValue::Null => Value::Null,
7639        PropertyValue::List(items) => {
7640            Value::List(items.into_iter().map(property_value_to_value).collect())
7641        }
7642        other => Value::Property(other),
7643    }
7644}
7645
7646/// A bound `NodeId`/`EdgeId` whose record is no longer in the store means
7647/// exactly one thing within a single statement's transaction: it was
7648/// deleted earlier in this same statement (e.g. `MATCH (n) DELETE n RETURN
7649/// n.num` -- real Cypher's `DeletedEntityAccess` error, TCK's Return2
7650/// scenarios [15]/[16]/[17]). Nothing else can cause a `None` here --
7651/// there's no concurrent deletion mid-statement, and a `Binding::Node`/
7652/// `Edge` only ever gets constructed from an id a prior MATCH/CREATE/MERGE
7653/// in this same transaction actually found or made. Centralized here
7654/// (rather than each of `binding_to_value`/`resolve_path_elems`/
7655/// `lookup_prop` re-deriving the message) so the wording stays one place.
7656fn deleted_entity_access<T>(record: Option<T>) -> Result<T, QueryError> {
7657    record.ok_or_else(|| {
7658        QueryError::UnboundVariable(
7659            "refers to a node/relationship that no longer exists — it was deleted earlier in this statement".into(),
7660        )
7661    })
7662}
7663
7664pub(crate) fn literal_to_value(lit: &Literal) -> PropertyValue {
7665    match lit {
7666        Literal::Int(i) => PropertyValue::Int(*i),
7667        Literal::Float(f) => PropertyValue::Float(*f),
7668        Literal::String(s) => PropertyValue::String(s.clone()),
7669        Literal::Bool(b) => PropertyValue::Bool(*b),
7670        Literal::Null => PropertyValue::Null,
7671        Literal::Param(name) => {
7672            unreachable!("param ${name} must be substituted before execution — see params::substitute_params")
7673        }
7674    }
7675}
7676
7677fn tag_merge_created(mut row: BindingRow, created: bool) -> BindingRow {
7678    row.insert(
7679        MERGE_CREATED_KEY.to_string(),
7680        Binding::Value(PropertyValue::Bool(created)),
7681    );
7682    row
7683}
7684
7685/// `Either` (undirected `-[r:TYPE]-`) has no single storage-level call —
7686/// query both directions and dedupe by `edge_id` (a self-loop would
7687/// otherwise appear twice, once from each direction's adjacency table).
7688/// Multiple `rel_labels` (`[:A|B]`) has no single storage-level call
7689/// either — `GraphStore::neighbors_in_txn` only ever filters by one label
7690/// at a time, so this makes one call per type (per direction) and
7691/// dedupes by `edge_id` across all of them, same technique as `Either`
7692/// above (an edge whose type is in `rel_labels` is only ever returned by
7693/// exactly one of those per-type calls, so the only real duplication risk
7694/// is the same direction-crossing one `Either` already handles). Empty
7695/// `rel_labels` means untyped — matches any relationship, same as
7696/// `neighbors_in_txn`'s own `None` behavior.
7697fn neighbors_for_direction(
7698    txn: Txn,
7699    node: NodeId,
7700    direction: ExpandDirection,
7701    rel_labels: &[String],
7702) -> Result<Vec<AdjEntry>, QueryError> {
7703    let dirs: &[Direction] = match direction {
7704        ExpandDirection::Out => &[Direction::Out],
7705        ExpandDirection::In => &[Direction::In],
7706        ExpandDirection::Either => &[Direction::Out, Direction::In],
7707    };
7708    let mut out = Vec::new();
7709    let mut seen: HashSet<EdgeId> = HashSet::new();
7710    let label_filters: Vec<Option<&str>> = if rel_labels.is_empty() {
7711        vec![None]
7712    } else {
7713        rel_labels.iter().map(|l| Some(l.as_str())).collect()
7714    };
7715    for label in label_filters {
7716        for &dir in dirs {
7717            for entry in GraphStore::neighbors_in_txn(txn, node, dir, label)? {
7718                if seen.insert(entry.edge_id) {
7719                    out.push(entry);
7720                }
7721            }
7722        }
7723    }
7724    Ok(out)
7725}
7726
7727/// `<expr>.prop` where `<expr>` isn't a bare row variable (`ReturnExpr::
7728/// PropOf`, e.g. `startNode(r).id`, `head(nodes(p)).name`, `{a: 1}.a`) --
7729/// unlike `lookup_prop_value`'s `Prop(PropAccess)` arm, there's no row/txn
7730/// lookup to do here, `v` already *is* the fully-evaluated base value, so
7731/// this reads straight off it. Same node/edge/map/temporal-value-or-error
7732/// shape as `lookup_prop_value`, minus the "unbound variable" case (there's
7733/// no variable name to report -- a `PropOf` base that evaluates to
7734/// `Value::Null` propagates `Null` here the same way a bound-but-null row
7735/// variable's own `.prop` access already does).
7736fn property_of_value(v: &Value, prop: &str) -> Result<Value, QueryError> {
7737    match v {
7738        Value::Node(n) => Ok(n
7739            .props
7740            .get(prop)
7741            .cloned()
7742            .map(property_value_to_value)
7743            .unwrap_or(Value::Null)),
7744        Value::Edge(e) => Ok(e
7745            .props
7746            .get(prop)
7747            .cloned()
7748            .map(property_value_to_value)
7749            .unwrap_or(Value::Null)),
7750        Value::Map(m) => Ok(m.get(prop).cloned().unwrap_or(Value::Null)),
7751        Value::Null => Ok(Value::Null),
7752        Value::Property(PropertyValue::Null) => Ok(Value::Null),
7753        Value::Property(pv) => match temporal_component(pv, prop) {
7754            Some(component) => Ok(Value::Property(component)),
7755            None if is_temporal_property_value(pv) => Ok(Value::Null),
7756            None => Err(QueryError::Type(
7757                "property access requires a node, relationship, map, or temporal value".into(),
7758            )),
7759        },
7760        Value::List(_) | Value::Path(_) => Err(QueryError::Type(
7761            "property access requires a node, relationship, map, or temporal value, not a list \
7762             or path"
7763                .into(),
7764        )),
7765        Value::Literal(_) => Err(QueryError::Type(
7766            "property access requires a node, relationship, map, or temporal value".into(),
7767        )),
7768    }
7769}