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, build_match_plan_scoped, pattern_all_vars,
27    pattern_new_vars, plan_edge_scan, plan_reversed_pattern, MatchClauseScope,
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        // Relationship-uniqueness scope shared by the comma-separated
1684        // parts of one MATCH clause (`QueryPart::continues_clause`) —
1685        // reset whenever a Match part starts a new clause, threaded into
1686        // `build_match_plan_scoped` so a later part's hops exclude every
1687        // earlier part's relationships (real Cypher's edge-isomorphism
1688        // rule spans the whole clause pattern, not each part).
1689        let mut clause_scope = MatchClauseScope::default();
1690        for (clause_index, clause) in clauses.iter().enumerate() {
1691            let is_final_clause = clause_index + 1 == clauses.len();
1692            match clause {
1693                QueryClause::Match(part) => {
1694                    if !part.continues_clause {
1695                        clause_scope = MatchClauseScope::default();
1696                    }
1697                    // Parts sharing a clause must all take the generic
1698                    // scoped-plan path: `plan_edge_scan`'s whole-pattern
1699                    // sweep bypasses `build_match_plan_scoped`, so a
1700                    // part it planned would neither record its own
1701                    // relationships into the scope nor exclude an
1702                    // earlier part's. (The aggregating-expansion fast
1703                    // path needs no gate — it requires a WITH and an
1704                    // empty carried row, which no shared-clause part can
1705                    // satisfy.)
1706                    let shares_clause = part.continues_clause
1707                        || matches!(
1708                            clauses.get(clause_index + 1),
1709                            Some(QueryClause::Match(next)) if next.continues_clause
1710                        );
1711                    let plan_limit = is_final_clause
1712                        .then_some(final_stream_limit)
1713                        .flatten()
1714                        .filter(|_| !part.shortest_path && !part.optional && part.with.is_none());
1715                    current_rows = if part.shortest_path {
1716                        // Not a LogicalPlan/eval_plan traversal at all —
1717                        // see eval_shortest_path's docs.
1718                        self.eval_shortest_path(txn, part, &current_rows, guard)?
1719                    } else if let Some(path_var) = &part.path_var {
1720                        let (named_pattern, synthesized) = name_pattern_for_path(&part.pattern);
1721                        // A named path's own inline `WHERE` can reference
1722                        // the path variable itself (`WHERE length(p) =
1723                        // 1`, TCK's MatchWhere1 `[12]`/`[13]`) -- `p`
1724                        // isn't in the row until *after* `assemble_path`
1725                        // below, so (for a plain, non-`OPTIONAL` MATCH)
1726                        // it can't be pushed into the plan the way an
1727                        // ordinary pattern's `WHERE` is; applied as a
1728                        // post-filter instead, once every row really has
1729                        // `p`. `OPTIONAL MATCH` still pushes it into the
1730                        // plan -- its own null-padding semantics need the
1731                        // filter fused into the "did this seed row match
1732                        // anything" check `eval_optional_part` does, and
1733                        // a `WHERE` referencing `p` there is a narrower,
1734                        // untested-by-the-TCK edge case left as-is.
1735                        let defer_where = !part.optional && part.where_clause.is_some();
1736                        let plan_where = if defer_where {
1737                            &None
1738                        } else {
1739                            &part.where_clause
1740                        };
1741                        let plan = apply_index_seeks(
1742                            build_match_plan(&named_pattern, plan_where, &carried_vars)?,
1743                            txn,
1744                        )?;
1745                        let mut rows = if part.optional {
1746                            let new_vars = pattern_new_vars(&named_pattern, &carried_vars);
1747                            self.eval_optional_part(txn, &plan, &current_rows, &new_vars, guard)?
1748                        } else {
1749                            // `plan_limit`'s own early-stop assumes every
1750                            // emitted row is already a real, final row --
1751                            // not true when the WHERE filter above got
1752                            // deferred (a limited prefix could still get
1753                            // filtered further below), so it's skipped
1754                            // for that case (limiting instead happens
1755                            // naturally via the smaller `rows` this
1756                            // clause returns).
1757                            let limit = plan_limit.filter(|_| !defer_where);
1758                            self.eval_plan_with_limit(txn, &plan, &current_rows, guard, limit)?
1759                        };
1760                        for row in &mut rows {
1761                            let path_binding = assemble_path(&named_pattern, row);
1762                            for key in &synthesized {
1763                                row.remove(key);
1764                            }
1765                            row.insert(path_var.clone(), path_binding);
1766                        }
1767                        if defer_where {
1768                            let where_clause = part
1769                                .where_clause
1770                                .as_ref()
1771                                .expect("defer_where implies where_clause is Some");
1772                            let mut filtered = Vec::with_capacity(rows.len());
1773                            for row in rows {
1774                                if self.eval_expr(txn, where_clause, &row, guard)? == Some(true) {
1775                                    filtered.push(row);
1776                                }
1777                            }
1778                            rows = filtered;
1779                        }
1780                        rows
1781                    } else if let Some(plan) = if part.optional || shares_clause {
1782                        // OPTIONAL MATCH needs eval_optional_part's
1783                        // null-padding semantics -- never the sweep.
1784                        // Shared-clause parts need the scoped-plan path
1785                        // (see `shares_clause` above).
1786                        None
1787                    } else {
1788                        plan_edge_scan(&part.pattern, &part.where_clause, &carried_vars, txn)?
1789                    } {
1790                        // Whole single-hop pattern bound by one sequential
1791                        // EDGES sweep -- see plan_edge_scan's cost gate.
1792                        // No fast-path/tail-hint interplay: the sweep is
1793                        // already the fast path for this shape.
1794                        self.eval_plan_with_limit(txn, &plan, &current_rows, guard, plan_limit)?
1795                    } else {
1796                        // Start-point selection: walk the pattern from its
1797                        // cheaper endpoint (see `plan_reversed_pattern`).
1798                        // Only this plain branch — a named path or
1799                        // shortestPath exposes traversal order, and MERGE's
1800                        // match phase stays as-written.
1801                        let reversed = plan_reversed_pattern(
1802                            &part.pattern,
1803                            &part.where_clause,
1804                            &carried_vars,
1805                            txn,
1806                        )?;
1807                        let pattern = reversed.as_ref().unwrap_or(&part.pattern);
1808                        let plan = apply_index_seeks(
1809                            build_match_plan_scoped(
1810                                pattern,
1811                                &part.where_clause,
1812                                &carried_vars,
1813                                &mut clause_scope,
1814                            )?,
1815                            txn,
1816                        )?;
1817                        if part.optional {
1818                            let new_vars = pattern_new_vars(&part.pattern, &carried_vars);
1819                            self.eval_optional_part(txn, &plan, &current_rows, &new_vars, guard)?
1820                        } else {
1821                            // Aggregating-expansion fast path: when the
1822                            // plan+WITH match the counted-double-expand
1823                            // shape, the tight loop replaces BOTH the row
1824                            // materialization and the WITH's own grouping
1825                            // pass — so on a hit, this clause is done.
1826                            let tail_hint = if is_final_clause {
1827                                match (order_by, limit, tail) {
1828                                    (
1829                                        Some(keys),
1830                                        Some(tail_limit),
1831                                        Some(Tail::Return(items, false)),
1832                                    ) if keys.len() == 1 && !has_aggregate(items) => {
1833                                        let (key, dir) = &keys[0];
1834                                        Some((
1835                                            key,
1836                                            *dir,
1837                                            skip.unwrap_or(0).max(0) as usize
1838                                                + tail_limit.max(0) as usize,
1839                                        ))
1840                                    }
1841                                    _ => None,
1842                                }
1843                            } else {
1844                                None
1845                            };
1846                            if let Some((rows, out_names)) = self.try_fast_expand_expand_count(
1847                                txn,
1848                                &plan,
1849                                &part.with,
1850                                &current_rows,
1851                                tail_hint,
1852                                guard,
1853                            )? {
1854                                current_rows = rows;
1855                                carried_vars = out_names;
1856                                continue;
1857                            }
1858                            self.eval_plan_with_limit(txn, &plan, &current_rows, guard, plan_limit)?
1859                        }
1860                    };
1861                    let mut new_vars = pattern_all_vars(&part.pattern);
1862                    if let Some(path_var) = &part.path_var {
1863                        new_vars.insert(path_var.clone());
1864                    }
1865                    current_rows = self.apply_with_or_carry(
1866                        txn,
1867                        &part.with,
1868                        current_rows,
1869                        new_vars,
1870                        &mut carried_vars,
1871                        guard,
1872                    )?;
1873                }
1874                QueryClause::Unwind(u) => {
1875                    current_rows = self.eval_unwind(txn, u, &current_rows, guard)?;
1876                    current_rows = self.apply_with_or_carry(
1877                        txn,
1878                        &u.with,
1879                        current_rows,
1880                        HashSet::from([u.var.clone()]),
1881                        &mut carried_vars,
1882                        guard,
1883                    )?;
1884                }
1885                QueryClause::Call(call) => {
1886                    current_rows = self.eval_call_clause(txn, call, &current_rows, guard)?;
1887                    let new_vars: HashSet<String> = match &call.yield_items {
1888                        Some(CallYield::Items(items, _)) => items
1889                            .iter()
1890                            .map(|(name, alias)| alias.clone().unwrap_or_else(|| name.clone()))
1891                            .collect(),
1892                        // `Star` never reaches here (`queryCallSt`'s own
1893                        // grammar has no `YIELD *` alternative) and `None`
1894                        // binds nothing new.
1895                        Some(CallYield::Star) | None => HashSet::new(),
1896                    };
1897                    current_rows = self.apply_with_or_carry(
1898                        txn,
1899                        &call.with,
1900                        current_rows,
1901                        new_vars,
1902                        &mut carried_vars,
1903                        guard,
1904                    )?;
1905                }
1906                QueryClause::Merge(m) => {
1907                    // MERGE always needs real `.insert`-capable write
1908                    // access, whether or not the rest of the statement
1909                    // would otherwise be read-only (e.g. `MERGE (n) RETURN
1910                    // n`) — see `is_read_only`, which already accounts for
1911                    // this by checking `clauses` too, so `txn` is
1912                    // guaranteed to be `Txn::Write` here.
1913                    let write_txn = require_write_txn(txn);
1914                    current_rows = self.eval_merge(write_txn, m, &current_rows, guard)?;
1915                    let mut new_vars = pattern_all_vars(&m.pattern);
1916                    if let Some(path_var) = &m.path_var {
1917                        new_vars.insert(path_var.clone());
1918                    }
1919                    current_rows = self.apply_with_or_carry(
1920                        txn,
1921                        &m.with,
1922                        current_rows,
1923                        new_vars,
1924                        &mut carried_vars,
1925                        guard,
1926                    )?;
1927                }
1928                // A statement-leading WITH -- no pattern was matched, so
1929                // there's nothing to seed `new_vars` with beyond what the
1930                // WITH clause itself projects (`apply_with_or_carry`
1931                // always takes the `Some(with)` branch here, never the
1932                // "no WITH, just extend carried_vars" one, since `with` is
1933                // always present on this variant by construction).
1934                QueryClause::With(with) => {
1935                    current_rows = self.apply_with_or_carry(
1936                        txn,
1937                        &Some(with.clone()),
1938                        current_rows,
1939                        HashSet::new(),
1940                        &mut carried_vars,
1941                        guard,
1942                    )?;
1943                }
1944                // `SET ... WITH ...` -- same real `.set_*_prop_in_txn`
1945                // write access `materialize_set`'s own per-row loop
1946                // already needs (guaranteed `Txn::Write` here for the
1947                // same reason its own docs give). Doesn't change any
1948                // row's bindings, only mutates the underlying graph --
1949                // `current_rows`/`carried_vars` both pass through
1950                // unchanged, the following `clause` (always a `WITH`,
1951                // see `set_as_clause`'s grammar) handles its own
1952                // projection/`WHERE`/`ORDER BY` normally from there.
1953                QueryClause::Set(items) => {
1954                    let write_txn = require_write_txn(txn);
1955                    for row in &current_rows {
1956                        for item in items {
1957                            self.apply_set_item(txn, write_txn, row, item, guard)?;
1958                        }
1959                    }
1960                }
1961                // `DELETE/DETACH DELETE ... WITH ...` -- same passthrough
1962                // reasoning as `QueryClause::Set` above (see
1963                // `delete_as_clause`'s grammar docs). Reuses the same
1964                // `delete_binding`/`delete_value` helpers `materialize_delete`
1965                // itself calls.
1966                QueryClause::Delete { items, detach } => {
1967                    let write_txn = require_write_txn(txn);
1968                    self.delete_targets(txn, write_txn, items, &current_rows, *detach, guard)?;
1969                }
1970                // `REMOVE ... WITH ...` -- same passthrough reasoning as
1971                // `QueryClause::Set` above (see `remove_as_clause`'s
1972                // grammar docs).
1973                QueryClause::Remove(items) => {
1974                    let write_txn = require_write_txn(txn);
1975                    for row in &current_rows {
1976                        for item in items {
1977                            apply_remove_item(self, write_txn, row, item)?;
1978                        }
1979                    }
1980                }
1981                // `CREATE ... WITH ...` -- unlike Set/Delete/Remove above,
1982                // this DOES change every row's bindings (each pattern's
1983                // own fresh/reused vars), so `current_rows` is replaced,
1984                // not passed through, and `carried_vars` is extended
1985                // directly (no bundled `.with` field on this variant to
1986                // route through `apply_with_or_carry` the way `Merge`
1987                // does above -- the following `WITH` is its own separate
1988                // `QueryClause::With` entry, picked up by this same loop's
1989                // next iteration, which needs `carried_vars` to already
1990                // reflect these new names by then).
1991                QueryClause::Create(patterns) => {
1992                    let write_txn = require_write_txn(txn);
1993                    current_rows =
1994                        self.materialize_create(write_txn, patterns, &current_rows, guard)?;
1995                    carried_vars.extend(patterns.iter().flat_map(pattern_all_vars));
1996                }
1997            }
1998            guard.check_intermediate_rows(current_rows.len())?;
1999        }
2000        // ORDER BY must see every matching row before LIMIT truncates —
2001        // sort, then take N, not the other way around. Only pre-truncate
2002        // (the v1 "doesn't short-circuit" path) when there's no ORDER BY to
2003        // invalidate it; DELETE/SET+LIMIT keep their "stop after N
2004        // bindings" behavior since they have no ORDER BY position in the
2005        // grammar. RETURN DISTINCT is excluded too, same reasoning as
2006        // ORDER BY: DISTINCT can still drop rows *after* this point, so
2007        // pre-truncating the raw input here could return fewer than
2008        // `limit` distinct rows even when more exist -- its LIMIT gets
2009        // applied after dedup instead, below.
2010        let distinct_return = tail_is_distinct_return(tail);
2011        if order_by.is_none() && !distinct_return {
2012            let skip_n = skip.unwrap_or(0).max(0) as usize;
2013            if skip_n > 0 {
2014                current_rows.drain(0..skip_n.min(current_rows.len()));
2015            }
2016            if let Some(count) = limit {
2017                current_rows.truncate(count.max(0) as usize);
2018            }
2019        }
2020        // Delete/Set need real `.insert`/`.remove`-capable write access,
2021        // not just `Txn`'s read-only `get`/`iter` — but they're only ever
2022        // reached via `Executor::execute`'s write-dispatch path (see
2023        // `is_read_only`), which always opens a `WriteTransaction`, so
2024        // `txn` is guaranteed to be `Txn::Write` here.
2025        // A non-aggregating RETURN's ORDER BY can reference either a
2026        // RETURN-introduced alias (`RETURN friend.id AS friendId ORDER BY
2027        // friendId`) or a variable still in scope that isn't returned at
2028        // all (`RETURN n.num AS prop ORDER BY n.num` — `n` itself never
2029        // appears in the RETURN list) — real Cypher allows both. Sorting
2030        // needs both the pre-projection bindings *and* the post-projection
2031        // output columns available at once, so it happens after
2032        // `materialize_return`, against a combined view of the two (see
2033        // `apply_order_by_with_scope`) rather than either alone. The
2034        // aggregating case can't use pre-projection bindings at all
2035        // (grouping has already collapsed the per-row bindings by then), so
2036        // it keeps sorting the post-projection output alone via
2037        // `apply_order_by`, further down.
2038        let mut order_by_pre_applied = false;
2039        let mut result = match tail {
2040            // A missing tail only ever occurs with a MERGE clause and
2041            // nothing after it — a pure write, same empty result shape
2042            // standalone CREATE already returns (not one blank row per
2043            // `current_rows`, which a synthetic `Tail::Return(vec![])`
2044            // would produce instead).
2045            None => QueryResult {
2046                columns: vec![],
2047                rows: vec![],
2048                stats: QueryStats::default(),
2049            },
2050            Some(Tail::Return(items, distinct)) => {
2051                if let Some(ob) = order_by {
2052                    // DISTINCT (like aggregation) can drop rows, breaking
2053                    // the 1:1 correspondence `apply_order_by_with_scope`
2054                    // needs between `current_rows` and the projected
2055                    // output -- ORDER BY after DISTINCT can only sort the
2056                    // post-projection, post-dedup result, same as the
2057                    // aggregating case just below.
2058                    if !has_aggregate(items) && !distinct {
2059                        let projected =
2060                            self.materialize_return(txn, items, &current_rows, *distinct, guard)?;
2061                        order_by_pre_applied = true;
2062                        self.apply_order_by_with_scope(
2063                            txn,
2064                            &current_rows,
2065                            projected,
2066                            ob,
2067                            skip,
2068                            limit,
2069                        )?
2070                    } else if !distinct {
2071                        order_by_pre_applied = true;
2072                        self.materialize_aggregating_return_with_order(
2073                            txn,
2074                            items,
2075                            &current_rows,
2076                            ob,
2077                            (skip, limit),
2078                            guard,
2079                        )?
2080                    } else {
2081                        self.materialize_return(txn, items, &current_rows, *distinct, guard)?
2082                    }
2083                } else {
2084                    self.materialize_return(txn, items, &current_rows, *distinct, guard)?
2085                }
2086            }
2087            Some(Tail::ReturnStar(distinct)) => {
2088                let items = return_star_items(carried_vars.iter().cloned())?;
2089                let projected =
2090                    self.materialize_return(txn, &items, &current_rows, *distinct, guard)?;
2091                if let Some(ob) = order_by {
2092                    if !distinct {
2093                        order_by_pre_applied = true;
2094                        self.apply_order_by_with_scope(
2095                            txn,
2096                            &current_rows,
2097                            projected,
2098                            ob,
2099                            skip,
2100                            limit,
2101                        )?
2102                    } else {
2103                        projected
2104                    }
2105                } else {
2106                    projected
2107                }
2108            }
2109            Some(Tail::Delete(vars, ret)) => {
2110                self.materialize_delete(txn, vars, &current_rows, false, ret, guard)?
2111            }
2112            Some(Tail::DetachDelete(vars, ret)) => {
2113                self.materialize_delete(txn, vars, &current_rows, true, ret, guard)?
2114            }
2115            Some(Tail::Set(items, ret)) => {
2116                self.materialize_set(txn, items, &current_rows, ret, guard)?
2117            }
2118            Some(Tail::Remove(items, ret)) => {
2119                self.materialize_remove(txn, items, &current_rows, ret, guard)?
2120            }
2121            Some(Tail::Create(patterns, ret)) => {
2122                let updated_rows = self.materialize_create(
2123                    require_write_txn(txn),
2124                    patterns,
2125                    &current_rows,
2126                    guard,
2127                )?;
2128                match ret {
2129                    Some(rt) => {
2130                        self.materialize_return(txn, &rt.items, &updated_rows, rt.distinct, guard)?
2131                    }
2132                    None => QueryResult {
2133                        columns: vec![],
2134                        rows: vec![],
2135                        stats: QueryStats::default(),
2136                    },
2137                }
2138            }
2139        };
2140        if let Some(order_by) = order_by {
2141            if !order_by_pre_applied {
2142                let tail_items: Option<&[ReturnItem]> = match tail {
2143                    Some(Tail::Return(items, _)) => Some(items),
2144                    _ => None,
2145                };
2146                result.rows = apply_order_by(
2147                    result.rows,
2148                    &result.columns,
2149                    order_by,
2150                    tail_items,
2151                    skip,
2152                    limit,
2153                )?;
2154            }
2155        } else if distinct_return {
2156            // The pre-truncate above was skipped for exactly this case --
2157            // apply SKIP/LIMIT now, after materialize_return's dedup,
2158            // instead.
2159            let skip_n = skip.unwrap_or(0).max(0) as usize;
2160            if skip_n > 0 {
2161                result.rows.drain(0..skip_n.min(result.rows.len()));
2162            }
2163            if let Some(count) = limit {
2164                result.rows.truncate(count.max(0) as usize);
2165            }
2166        }
2167        guard.check_result_rows(result.rows.len())?;
2168        Ok(result)
2169    }
2170
2171    /// Applies a clause's optional trailing `WITH` (shared by both
2172    /// `QueryClause::Match` and `QueryClause::Unwind`, which can each end
2173    /// in one — see `QueryClause`'s docs), or, with no `WITH`, grows
2174    /// `carried_vars` by `new_vars` so the next clause shares this one's
2175    /// binding scope — same "no WITH means stay in scope" rule `OPTIONAL
2176    /// MATCH` already gets, now uniform across clause kinds.
2177    fn apply_with_or_carry(
2178        &self,
2179        txn: Txn,
2180        with: &Option<WithClause>,
2181        rows: Vec<BindingRow>,
2182        new_vars: HashSet<String>,
2183        carried_vars: &mut HashSet<String>,
2184        guard: &ExecutionGuard<'_>,
2185    ) -> Result<Vec<BindingRow>, QueryError> {
2186        let Some(with) = with else {
2187            carried_vars.extend(new_vars);
2188            return Ok(rows);
2189        };
2190        // `WITH *` -- expand to every name already carried into this
2191        // clause *plus* whatever this same clause's own pattern just
2192        // bound (`new_vars`, e.g. MERGE's own target -- `carried_vars`
2193        // alone wouldn't have that yet, since it's only ever updated at
2194        // this function's very end). `with_owned` only exists to give
2195        // the rest of this function a `&WithClause` with `items` already
2196        // containing the expanded names, without touching any of its
2197        // other fields (`order_by`/`skip`/`limit`/`distinct`/
2198        // `where_clause` all stay exactly as parsed).
2199        let with_owned;
2200        let with: &WithClause = if with.star {
2201            // A `HashSet` union, not a plain chain -- `new_vars` can
2202            // legitimately overlap with `carried_vars` (e.g. `MATCH (a)
2203            // MERGE (a)-[:R]->(b)` reuses the already-bound `a`), and a
2204            // raw chain would double it up into two identical columns.
2205            let star_items = with_star_items(carried_vars.union(&new_vars).cloned());
2206            let mut owned = with.clone();
2207            let mut items = star_items;
2208            items.extend(owned.items);
2209            owned.items = items;
2210            with_owned = owned;
2211            &with_owned
2212        } else {
2213            with
2214        };
2215        let with_skip = self.resolve_skip_limit(txn, with.skip.as_ref(), "SKIP", guard)?;
2216        let with_limit = self.resolve_skip_limit(txn, with.limit.as_ref(), "LIMIT", guard)?;
2217        let rows = if let Some(with_order_by) = with
2218            .order_by
2219            .as_ref()
2220            .filter(|_| has_aggregate(&with.items))
2221        {
2222            // `materialize_aggregating_with_with_order` folds its own
2223            // extra composed ORDER BY keys through the same grouping pass
2224            // as `with.items` -- also covers `with.distinct` correctly
2225            // without any extra handling here, since grouping already
2226            // makes every output row unique by its own grouping-key
2227            // columns (see that function's `RETURN`-side twin's own docs
2228            // on why that makes `DISTINCT` a no-op downstream of
2229            // aggregation).
2230            self.materialize_aggregating_with_with_order(
2231                txn,
2232                &with.items,
2233                &rows,
2234                with_order_by,
2235                (with_skip, with_limit),
2236                guard,
2237            )?
2238        } else {
2239            // Only cloned when actually needed below (ORDER BY on a
2240            // non-aggregating, non-`DISTINCT` WITH) -- avoids the copy on
2241            // every other WITH shape.
2242            let pre_with_rows = (with.order_by.is_some() && !with.distinct).then(|| rows.clone());
2243            let mut rows = self.materialize_with(txn, with, &rows, guard)?;
2244            if let Some(with_order_by) = &with.order_by {
2245                // Only a non-aggregating, non-`DISTINCT` WITH keeps a 1:1
2246                // row correspondence with its pre-WITH input -- see
2247                // `apply_order_by_bindings`'s own docs on why that's
2248                // exactly when ORDER BY can also see the pre-WITH scope.
2249                rows = self.apply_order_by_bindings(
2250                    txn,
2251                    rows,
2252                    pre_with_rows.as_deref(),
2253                    &with.items,
2254                    with_order_by,
2255                    (with_skip, with_limit),
2256                )?;
2257            } else {
2258                let skip_n = with_skip.unwrap_or(0).max(0) as usize;
2259                if skip_n > 0 {
2260                    rows.drain(0..skip_n.min(rows.len()));
2261                }
2262                if let Some(with_limit) = with_limit {
2263                    rows.truncate(with_limit.max(0) as usize);
2264                }
2265            }
2266            rows
2267        };
2268        *carried_vars = with
2269            .items
2270            .iter()
2271            .enumerate()
2272            .map(with_item_output_name)
2273            .collect();
2274        Ok(rows)
2275    }
2276
2277    /// `UNWIND`'s fan-out. Not a graph traversal — like `WITH`, handled
2278    /// directly here rather than through a `LogicalPlan`/`eval_plan` (see
2279    /// `UnwindClause`'s docs). Cross-joins each input row against every
2280    /// element of that row's resolved list, then applies the clause's own
2281    /// `WHERE`.
2282    fn eval_unwind(
2283        &self,
2284        txn: Txn,
2285        clause: &UnwindClause,
2286        rows: &[BindingRow],
2287        guard: &ExecutionGuard<'_>,
2288    ) -> Result<Vec<BindingRow>, QueryError> {
2289        let mut out = Vec::new();
2290        for row in rows {
2291            let source_value = self.eval_return_expr(txn, &clause.source.0, row, guard)?;
2292            let elements: Vec<Binding> = match source_value {
2293                Value::List(items) => items.iter().map(value_to_binding_restore).collect(),
2294                // `UNWIND null AS x` behaves like unwinding an empty list
2295                // (zero rows) in real Cypher, not an error.
2296                Value::Null => Vec::new(),
2297                other => {
2298                    return Err(QueryError::Type(format!(
2299                        "UNWIND needs a list, got {other:?}"
2300                    )))
2301                }
2302            };
2303            for element in elements {
2304                let mut new_row = row.clone();
2305                new_row.insert(clause.var.clone(), element);
2306                out.push(new_row);
2307            }
2308        }
2309        if let Some(where_clause) = &clause.where_clause {
2310            let mut filtered = Vec::with_capacity(out.len());
2311            for row in out {
2312                if self.eval_with_expr(txn, where_clause, &row, guard)? == Some(true) {
2313                    filtered.push(row);
2314                }
2315            }
2316            out = filtered;
2317        }
2318        Ok(out)
2319    }
2320
2321    /// `shortestPath((a)-[:TYPE*..N]-(b))` — a real parent-pointer BFS
2322    /// between two already-bound endpoints, not a `LogicalPlan`/
2323    /// `VarExpand` traversal (which only tracks final position plus a
2324    /// visited set, not the hop-by-hop chain a path needs to reconstruct).
2325    /// BFS visits in non-decreasing depth order, so the first time `b` is
2326    /// reached is *a* shortest path — stop there and reconstruct via
2327    /// parent pointers, rather than enumerating every path up to some
2328    /// bound the way `VarExpand` does.
2329    ///
2330    /// Both endpoints must already be bound by a preceding clause (e.g.
2331    /// `MATCH (a:Person{name:'Alice'}), (b:Person{name:'Bob'}) MATCH p =
2332    /// shortestPath((a)-[:KNOWS*]-(b)) RETURN p` — parser-enforced, see
2333    /// `parser::validate_shortest_path_pattern`) — v1 doesn't attempt to
2334    /// resolve a fresh/scanned endpoint here the way ordinary MATCH does,
2335    /// since "shortest path to *any* node matching these constraints" is a
2336    /// different, more ambiguous question than "shortest path between
2337    /// these two specific nodes."
2338    ///
2339    /// Every input row always survives (unlike an ordinary pattern match,
2340    /// which can produce zero rows for a non-match) — an unreachable pair
2341    /// binds the path variable to `Null`, same as `OPTIONAL MATCH`'s
2342    /// null-padding, rather than dropping the row. `part.optional` is
2343    /// therefore a no-op here, not separately handled. Exceeding the
2344    /// safety depth cap on an unbounded (`*..`) search also resolves to
2345    /// `Null`, not an error — unlike `VarExpand`'s cap (which errors,
2346    /// because truncating there would silently produce an *incomplete
2347    /// set* of paths, a wrong-answer risk), `shortestPath()` is only ever
2348    /// answering "is there a path within the searched horizon," which is
2349    /// a well-defined answer either way.
2350    fn eval_shortest_path(
2351        &self,
2352        txn: Txn,
2353        part: &QueryPart,
2354        rows: &[BindingRow],
2355        guard: &ExecutionGuard<'_>,
2356    ) -> Result<Vec<BindingRow>, QueryError> {
2357        let Some(path_var) = &part.path_var else {
2358            // Nothing names the result, so there's nothing to bind and no
2359            // filtering effect (see this function's docs) — pure no-op.
2360            return Ok(rows.to_vec());
2361        };
2362        let start_var = part.pattern.start.var.as_deref().expect(
2363            "shortestPath()'s start node always has a var — validated at parse time by \
2364             validate_shortest_path_pattern",
2365        );
2366        let (rel, end_node) = &part.pattern.hops[0];
2367        let end_var = end_node.var.as_deref().expect(
2368            "shortestPath()'s end node always has a var — validated at parse time by \
2369             validate_shortest_path_pattern",
2370        );
2371        let (min_hops, max_hops) = rel.hop_range.expect(
2372            "shortestPath()'s relationship is always variable-length — validated at parse time by \
2373             validate_shortest_path_pattern",
2374        );
2375        let direction = match rel.direction {
2376            RelDirection::Right => ExpandDirection::Out,
2377            RelDirection::Left => ExpandDirection::In,
2378            RelDirection::Either => ExpandDirection::Either,
2379        };
2380        let rel_labels = &rel.rel_types;
2381
2382        let mut out = Vec::with_capacity(rows.len());
2383        for row in rows {
2384            let start_id = require_bound_node(row, start_var)?;
2385            let end_id = require_bound_node(row, end_var)?;
2386            let path = self.shortest_path_between(
2387                txn,
2388                start_id,
2389                end_id,
2390                ShortestPathSpec {
2391                    direction,
2392                    rel_labels,
2393                    min_hops,
2394                    max_hops,
2395                },
2396            )?;
2397            let mut new_row = row.clone();
2398            let binding = match path {
2399                Some(elems) => Binding::Path(elems),
2400                None => Binding::Value(PropertyValue::Null),
2401            };
2402            new_row.insert(path_var.clone(), binding);
2403            out.push(new_row);
2404        }
2405        if let Some(where_clause) = &part.where_clause {
2406            let mut filtered = Vec::with_capacity(out.len());
2407            for row in out {
2408                if self.eval_expr(txn, where_clause, &row, guard)? == Some(true) {
2409                    filtered.push(row);
2410                }
2411            }
2412            out = filtered;
2413        }
2414        Ok(out)
2415    }
2416
2417    /// The BFS itself. `min_hops` is only ever 0 or 1 (`validate_shortest_
2418    /// path_pattern` rejects anything higher) — deliberately: a plain
2419    /// visited-set BFS can't correctly answer "shortest path of at least N
2420    /// hops" for N > 1 (a node first reached at a too-early depth would
2421    /// need to stay revisitable for a later, longer route to it, which a
2422    /// visited-set structurally can't represent) without a different
2423    /// (node, depth)-keyed algorithm. Rejecting the case outright at parse
2424    /// time is safer than silently answering it wrong.
2425    fn shortest_path_between(
2426        &self,
2427        txn: Txn,
2428        start: NodeId,
2429        end: NodeId,
2430        spec: ShortestPathSpec<'_>,
2431    ) -> Result<Option<Vec<PathBinding>>, QueryError> {
2432        if start == end && spec.min_hops == 0 {
2433            return Ok(Some(vec![PathBinding::Node(start)]));
2434        }
2435        let cap = spec.max_hops.unwrap_or(VAR_EXPAND_DEPTH_CAP);
2436        let mut parent: HashMap<NodeId, (NodeId, EdgeId)> = HashMap::new();
2437        let mut visited: HashSet<NodeId> = HashSet::new();
2438        visited.insert(start);
2439        let mut frontier = vec![start];
2440        let mut depth = 0u32;
2441        while depth < cap && !frontier.is_empty() {
2442            depth += 1;
2443            let mut next_frontier = Vec::new();
2444            for node in frontier {
2445                for entry in neighbors_for_direction(txn, node, spec.direction, spec.rel_labels)? {
2446                    if entry.other == end {
2447                        parent.insert(entry.other, (node, entry.edge_id));
2448                        return Ok(Some(reconstruct_path(&parent, start, end)));
2449                    }
2450                    if visited.insert(entry.other) {
2451                        parent.insert(entry.other, (node, entry.edge_id));
2452                        next_frontier.push(entry.other);
2453                    }
2454                }
2455            }
2456            frontier = next_frontier;
2457        }
2458        Ok(None)
2459    }
2460
2461    /// Projects `rows` through a `WITH` clause. Unlike `materialize_return`
2462    /// (which resolves everything down to display `Value`s), a bare
2463    /// variable reference (`WITH message`) must keep its graph identity
2464    /// (`Binding::Node`/`Edge`) so the next `QueryPart` can keep
2465    /// traversing from it — only computed expressions collapse to a
2466    /// scalar `Binding::Value`.
2467    fn materialize_with(
2468        &self,
2469        txn: Txn,
2470        with: &WithClause,
2471        rows: &[BindingRow],
2472        guard: &ExecutionGuard<'_>,
2473    ) -> Result<Vec<BindingRow>, QueryError> {
2474        let is_aggregating = has_aggregate(&with.items);
2475        let mut out = if !is_aggregating {
2476            let mut out = Vec::with_capacity(rows.len());
2477            for row in rows {
2478                let mut new_row = BindingRow::new();
2479                for (i, item) in with.items.iter().enumerate() {
2480                    let name = with_item_output_name((i, item));
2481                    let binding = self.item_binding(txn, &item.expr, row, guard)?;
2482                    new_row.insert(name, binding);
2483                }
2484                out.push(new_row);
2485            }
2486            out
2487        } else {
2488            validate_return_items(&with.items)?;
2489            let grouped = self.resolve_grouped_rows(txn, &with.items, rows, guard)?;
2490            grouped
2491                .into_iter()
2492                .map(|bindings| {
2493                    with.items
2494                        .iter()
2495                        .enumerate()
2496                        .zip(bindings)
2497                        .map(|((i, item), b)| (with_item_output_name((i, item)), b))
2498                        .collect()
2499                })
2500                .collect()
2501        };
2502        if let Some(where_clause) = &with.where_clause {
2503            let mut filtered = Vec::with_capacity(out.len());
2504            if is_aggregating {
2505                // Aggregation collapses many input rows into one group --
2506                // there's no single pre-WITH row left to fall back to, so
2507                // (matching real Cypher) WHERE only sees the grouped/
2508                // aggregated names, same as `RETURN`'s own aggregate WHERE.
2509                for row in out {
2510                    if self.eval_with_expr(txn, where_clause, &row, guard)? == Some(true) {
2511                        filtered.push(row);
2512                    }
2513                }
2514            } else {
2515                // Real Cypher lets a `WITH x AS y WHERE ...` immediately
2516                // following see *both* the pre-WITH binding (`x`) and the
2517                // new alias (`y`) -- confirmed via the TCK's own
2518                // `WithWhere7` scenarios. New aliases shadow same-named
2519                // old bindings on conflict. Still true with `DISTINCT` --
2520                // unlike aggregation, `DISTINCT` alone doesn't collapse
2521                // several pre-WITH rows into one *ambiguous* group; it's
2522                // a dedup applied to the *surviving*, still individually-
2523                // real rows, which is why the dedup itself happens below,
2524                // after this filter, not before it (TCK's WithWhere1
2525                // `[2]`: `WITH DISTINCT a.name2 AS name WHERE a.name2 =
2526                // 'B'` needs `a` from the row that produced each
2527                // candidate `name`, not just `name` itself).
2528                for (row, new_row) in rows.iter().zip(out) {
2529                    let mut merged = row.clone();
2530                    merged.extend(new_row.iter().map(|(k, v)| (k.clone(), v.clone())));
2531                    if self.eval_with_expr(txn, where_clause, &merged, guard)? == Some(true) {
2532                        filtered.push(new_row);
2533                    }
2534                }
2535            }
2536            out = filtered;
2537        }
2538        if with.distinct {
2539            out = dedup_binding_rows(&with.items, out)?;
2540        }
2541        Ok(out)
2542    }
2543
2544    /// `materialize_aggregating_return_with_order`'s `WITH`-side twin --
2545    /// same "fold extra composed ORDER BY keys through the same grouping
2546    /// pass as `with_items` themselves" approach (TCK's WithOrderBy4
2547    /// `[16]`-`[18]`), just producing `Vec<BindingRow>` (preserving graph
2548    /// identity for whatever clause comes after this `WITH`) instead of a
2549    /// final `QueryResult` -- the extra keys' own values are only ever
2550    /// used for sorting here, never carried into the output rows.
2551    fn materialize_aggregating_with_with_order(
2552        &self,
2553        txn: Txn,
2554        with_items: &[ReturnItem],
2555        rows: &[BindingRow],
2556        order_by: &[(ReturnExpr, SortDir)],
2557        skip_limit: (Option<i64>, Option<i64>),
2558        guard: &ExecutionGuard<'_>,
2559    ) -> Result<Vec<BindingRow>, QueryError> {
2560        let (skip, limit) = skip_limit;
2561        enum OrderKeySource {
2562            RealColumn(usize),
2563            Extra(usize),
2564        }
2565        let mut extra_exprs: Vec<ReturnExpr> = Vec::new();
2566        let order_by_source: Vec<OrderKeySource> = order_by
2567            .iter()
2568            .map(|(expr, _)| {
2569                match with_items
2570                    .iter()
2571                    .enumerate()
2572                    .position(|(i, it)| item_matches_leaf(expr, i, it))
2573                {
2574                    Some(i) => OrderKeySource::RealColumn(i),
2575                    None => {
2576                        let idx = extra_exprs.len();
2577                        extra_exprs.push(expr.clone());
2578                        OrderKeySource::Extra(idx)
2579                    }
2580                }
2581            })
2582            .collect();
2583        let extended_items: Vec<ReturnItem> = with_items
2584            .iter()
2585            .cloned()
2586            .chain(
2587                extra_exprs
2588                    .into_iter()
2589                    .map(|expr| ReturnItem { expr, alias: None }),
2590            )
2591            .collect();
2592        validate_return_items(&extended_items)?;
2593        let grouped = self.resolve_grouped_rows(txn, &extended_items, rows, guard)?;
2594        let real_len = with_items.len();
2595        let mut keyed: Vec<(Vec<Value>, BindingRow)> = Vec::with_capacity(grouped.len());
2596        for bindings in grouped {
2597            let (real, extra) = bindings.split_at(real_len);
2598            let real_values: Vec<Value> = real
2599                .iter()
2600                .map(|b| self.binding_to_value(txn, b))
2601                .collect::<Result<Vec<_>, _>>()?;
2602            let extra_values: Vec<Value> = extra
2603                .iter()
2604                .map(|b| self.binding_to_value(txn, b))
2605                .collect::<Result<Vec<_>, _>>()?;
2606            let keys: Vec<Value> = order_by_source
2607                .iter()
2608                .map(|src| match src {
2609                    OrderKeySource::RealColumn(i) => real_values[*i].clone(),
2610                    OrderKeySource::Extra(k) => extra_values[*k].clone(),
2611                })
2612                .collect();
2613            let real_row: BindingRow = with_items
2614                .iter()
2615                .enumerate()
2616                .zip(real)
2617                .map(|((i, item), binding)| (with_item_output_name((i, item)), binding.clone()))
2618                .collect();
2619            keyed.push((keys, real_row));
2620        }
2621        Ok(top_k_by(keyed, order_by, skip, limit)
2622            .into_iter()
2623            .map(|(_, row)| row)
2624            .collect())
2625    }
2626
2627    /// The `Binding` one WITH/RETURN item evaluates to for one input row. A
2628    /// bare `Var` keeps its graph identity (`Binding::Node`/`Edge`) so a
2629    /// later `QueryPart` can keep traversing from it; anything else
2630    /// (computed expressions) collapses to `Binding::Value`. Shared by the
2631    /// non-aggregating `materialize_with` path and grouping-key evaluation.
2632    fn item_binding(
2633        &self,
2634        txn: Txn,
2635        expr: &ReturnExpr,
2636        row: &BindingRow,
2637        guard: &ExecutionGuard<'_>,
2638    ) -> Result<Binding, QueryError> {
2639        match expr {
2640            ReturnExpr::Var(v) => row
2641                .get(v)
2642                .cloned()
2643                .ok_or_else(|| QueryError::UnboundVariable(v.clone())),
2644            other => {
2645                let value = self.eval_return_expr(txn, other, row, guard)?;
2646                // `value_to_property_value` collapses Node/Edge/List/Path
2647                // to Null -- fine for a bare Var (handled above, never
2648                // reaches here) but wrong for any *wrapped* non-Var
2649                // expression that still evaluates to one of those (a list
2650                // literal/index/slice, or a CASE branch returning a bound
2651                // node/edge): those need the matching real Binding kind,
2652                // not a silently-nulled scalar. `Path` still falls back to
2653                // Null here -- a real, separate gap (needs a `Value::Path`
2654                // -> `Binding::Path` conversion this doesn't have yet),
2655                // not something any currently-reachable expression form
2656                // produces though.
2657                Ok(match value {
2658                    Value::Node(n) => Binding::Node(n.id),
2659                    Value::Edge(e) => Binding::Edge(e.id),
2660                    Value::List(items) => Binding::List(items),
2661                    Value::Map(m) => Binding::Map(m),
2662                    other => Binding::Value(value_to_property_value(&other)),
2663                })
2664            }
2665        }
2666    }
2667
2668    /// Same sort as `apply_order_by`, but over `BindingRow`s (a `WITH`
2669    /// clause's own ORDER BY, which must run before that row set becomes
2670    /// the seed for the next `QueryPart` — sorting/limiting a WITH changes
2671    /// *which* rows continue, not just their presentation order).
2672    fn apply_order_by_bindings(
2673        &self,
2674        txn: Txn,
2675        rows: Vec<BindingRow>,
2676        // `Some`, same length as `rows`, only for a non-aggregating,
2677        // non-`DISTINCT` WITH (1:1 row correspondence with the pre-WITH
2678        // input) -- lets ORDER BY see both the pre-WITH scope and the
2679        // new aliases, matching `where_clause`'s own merge (real Cypher:
2680        // `WITH a.count AS count ORDER BY a.count`, `a` isn't projected
2681        // but is still a valid sort key, TCK's With4 [6]). `None` for an
2682        // aggregating/`DISTINCT` WITH -- many pre-WITH rows collapse
2683        // into one output row there, so no single pre-WITH scope exists
2684        // to merge in.
2685        pre_with_rows: Option<&[BindingRow]>,
2686        with_items: &[ReturnItem],
2687        order_by: &[(ReturnExpr, SortDir)],
2688        skip_limit: (Option<i64>, Option<i64>),
2689    ) -> Result<Vec<BindingRow>, QueryError> {
2690        let (skip, limit) = skip_limit;
2691        // Same reasoning as `apply_order_by`'s `order_by_col` shortcut: an
2692        // ORDER BY item that repeats a WITH item's expression verbatim
2693        // (`WITH sum(x) AS s ORDER BY sum(x)`, TCK's WithOrderBy4 [11])
2694        // refers to that already-computed item, not a fresh expression --
2695        // look it up by its output name directly (works whether or not
2696        // that item has an alias) rather than re-evaluating the
2697        // expression, which would need pre-aggregation bindings that no
2698        // longer exist at this post-`materialize_with` point (an
2699        // aggregate call reaching `eval_projected_expr` always errors, by
2700        // design).
2701        let order_by_output: Vec<Option<String>> = order_by
2702            .iter()
2703            .map(|(expr, _)| {
2704                with_items
2705                    .iter()
2706                    .enumerate()
2707                    .find(|(_, item)| item.expr == *expr)
2708                    .map(with_item_output_name)
2709            })
2710            .collect();
2711        let mut keyed: Vec<(Vec<Value>, BindingRow)> = Vec::with_capacity(rows.len());
2712        for (i, row) in rows.into_iter().enumerate() {
2713            let mut value_map = self.binding_row_to_value_map(txn, &row)?;
2714            if let Some(pre) = pre_with_rows {
2715                // Pre-WITH names fill in gaps only -- a new alias with the
2716                // same name already occupies that key in `value_map` and
2717                // must keep winning (matches `materialize_with`'s own
2718                // "new aliases shadow same-named old bindings" rule).
2719                for (k, v) in self.binding_row_to_value_map(txn, &pre[i])? {
2720                    value_map.entry(k).or_insert(v);
2721                }
2722            }
2723            let keys = order_by
2724                .iter()
2725                .zip(&order_by_output)
2726                .map(|((expr, _), output_name)| match output_name {
2727                    Some(name) => Ok(value_map.get(name).cloned().unwrap_or(Value::Null)),
2728                    None => eval_projected_expr(expr, &value_map),
2729                })
2730                .collect::<Result<Vec<_>, _>>()?;
2731            keyed.push((keys, row));
2732        }
2733        Ok(top_k_by(keyed, order_by, skip, limit)
2734            .into_iter()
2735            .map(|(_, row)| row)
2736            .collect())
2737    }
2738
2739    /// Sorts an already-`materialize_return`d result for a non-aggregating
2740    /// `RETURN`, evaluating each ORDER BY expression against *both* the
2741    /// pre-projection `BindingRow` it came from and its own projected
2742    /// output columns overlaid on top — real Cypher allows ORDER BY to
2743    /// reference either a RETURN alias or a still-in-scope variable that
2744    /// wasn't returned at all, so neither view alone is enough (see the
2745    /// call site in `execute_match`). `binding_rows` and `result.rows` are
2746    /// the same length and pairwise correspond — `materialize_return`'s
2747    /// non-aggregating path preserves row order 1:1 with its input.
2748    fn apply_order_by_with_scope(
2749        &self,
2750        txn: Txn,
2751        binding_rows: &[BindingRow],
2752        result: QueryResult,
2753        order_by: &[(ReturnExpr, SortDir)],
2754        skip: Option<i64>,
2755        limit: Option<i64>,
2756    ) -> Result<QueryResult, QueryError> {
2757        let QueryResult { columns, rows, .. } = result;
2758        let mut keyed: Vec<(Vec<Value>, Vec<Value>)> = Vec::with_capacity(rows.len());
2759        for (binding_row, row) in binding_rows.iter().zip(rows) {
2760            let mut value_map = self.binding_row_to_value_map(txn, binding_row)?;
2761            for (col, val) in columns.iter().zip(&row) {
2762                value_map.insert(col.clone(), val.clone());
2763            }
2764            let keys = order_by
2765                .iter()
2766                .map(|(expr, _)| eval_projected_expr(expr, &value_map))
2767                .collect::<Result<Vec<_>, _>>()?;
2768            keyed.push((keys, row));
2769        }
2770        let rows = top_k_by(keyed, order_by, skip, limit)
2771            .into_iter()
2772            .map(|(_, row)| row)
2773            .collect();
2774        Ok(QueryResult {
2775            columns,
2776            rows,
2777            stats: QueryStats::default(),
2778        })
2779    }
2780
2781    fn binding_row_to_value_map(
2782        &self,
2783        txn: Txn,
2784        row: &BindingRow,
2785    ) -> Result<HashMap<String, Value>, QueryError> {
2786        let mut map = HashMap::with_capacity(row.len());
2787        for (k, binding) in row {
2788            map.insert(k.clone(), self.binding_to_value(txn, binding)?);
2789        }
2790        Ok(map)
2791    }
2792
2793    /// Resolves a `Binding` to its display `Value` — a `Node`/`Edge`
2794    /// binding fetches the full current record, a scalar `Value` binding
2795    /// passes through (collapsing a stored `PropertyValue::Null` to
2796    /// `Value::Null`, same as everywhere else null is represented).
2797    fn binding_to_value(&self, txn: Txn, b: &Binding) -> Result<Value, QueryError> {
2798        Ok(match b {
2799            Binding::Node(id) => {
2800                Value::Node((*deleted_entity_access(self.get_node_cached(txn, *id)?)?).clone())
2801            }
2802            Binding::Edge(id) => Value::Edge(deleted_entity_access(GraphStore::get_edge_in_txn(
2803                txn, *id,
2804            )?)?),
2805            Binding::Value(PropertyValue::Null) => Value::Null,
2806            Binding::Value(pv) => property_value_to_value(pv.clone()),
2807            Binding::List(items) => Value::List(items.clone()),
2808            Binding::Map(m) => Value::Map(m.clone()),
2809            Binding::Path(elems) => Value::Path(self.resolve_path_elems(txn, elems)?),
2810        })
2811    }
2812
2813    /// `startNode(r)`/`endNode(r)` — unlike every other builtin function
2814    /// (`labels()`, `type()`, ...), which reads straight off the already-
2815    /// materialized `Value::Node`/`Edge` it's given, this needs a *second*
2816    /// `GraphStore` lookup: `Edge.src`/`.dst` are bare `NodeId`s, not full
2817    /// records. `call_builtin` (the free function every other builtin
2818    /// dispatches through) has no `Txn` to do that lookup with, so these
2819    /// two are special-cased here instead, before ever reaching it.
2820    fn start_or_end_node(
2821        &self,
2822        txn: Txn,
2823        which: &str,
2824        arg: Option<&Value>,
2825    ) -> Result<Value, QueryError> {
2826        match arg {
2827            None | Some(Value::Null) => Ok(Value::Null),
2828            Some(Value::Edge(e)) => {
2829                let id = if which == "startnode" { e.src } else { e.dst };
2830                let node = deleted_entity_access(self.get_node_cached(txn, id)?)?;
2831                Ok(Value::Node((*node).clone()))
2832            }
2833            Some(other) => Err(QueryError::Type(format!(
2834                "{which}() expects a relationship, got {other:?}"
2835            ))),
2836        }
2837    }
2838
2839    /// `type(r)` -- unlike every other property/label access, real Cypher
2840    /// still allows this after `DELETE r` deleted the relationship
2841    /// earlier in the same statement (a relationship's type never
2842    /// changes, so there's nothing mutable a live record could be hiding
2843    /// -- unlike `labels()`/property access, which stay real
2844    /// `DeletedEntityAccess` errors, TCK's Return2 `[14]`-`[17]`). Tries
2845    /// the ordinary evaluation first; only on failure, and only for a
2846    /// bare `Var` bound to an edge, falls back to `guard`'s cached type
2847    /// from the moment it was deleted (`ExecutionGuard::
2848    /// deleted_edge_types`'s own docs). Any other failure (unbound
2849    /// variable, a genuinely wrong argument type, ...) propagates
2850    /// unchanged.
2851    fn eval_type_call(
2852        &self,
2853        txn: Txn,
2854        arg_expr: Option<&ReturnExpr>,
2855        row: &BindingRow,
2856        guard: &ExecutionGuard<'_>,
2857    ) -> Result<Value, QueryError> {
2858        let Some(arg_expr) = arg_expr else {
2859            return type_builtin(None);
2860        };
2861        match self.eval_return_expr(txn, arg_expr, row, guard) {
2862            Ok(v) => type_builtin(Some(&v)),
2863            Err(err) => {
2864                if let ReturnExpr::Var(v) = arg_expr {
2865                    if let Some(Binding::Edge(id)) = row.get(v) {
2866                        if let Some(label) = guard.deleted_edge_type(*id) {
2867                            return Ok(Value::Property(PropertyValue::String(label)));
2868                        }
2869                    }
2870                }
2871                Err(err)
2872            }
2873        }
2874    }
2875
2876    /// `binding_to_value`'s per-element helper for `Binding::Path` — fetches
2877    /// each element's full current record, same "keep just the id in the
2878    /// row, resolve to a full record only when materializing for display"
2879    /// split `Binding::Node`/`Edge` already use above.
2880    fn resolve_path_elems(
2881        &self,
2882        txn: Txn,
2883        elems: &[PathBinding],
2884    ) -> Result<Vec<PathElem>, QueryError> {
2885        elems
2886            .iter()
2887            .map(|e| {
2888                Ok(match e {
2889                    PathBinding::Node(id) => PathElem::Node(
2890                        (*deleted_entity_access(self.get_node_cached(txn, *id)?)?).clone(),
2891                    ),
2892                    PathBinding::Edge(id) => PathElem::Edge(deleted_entity_access(
2893                        GraphStore::get_edge_in_txn(txn, *id)?,
2894                    )?),
2895                })
2896            })
2897            .collect()
2898    }
2899
2900    /// Folds `rows` into groups keyed by every non-aggregate item's per-row
2901    /// `Binding` (via `item_binding`), then finishes each aggregating
2902    /// item's accumulator(s) per group. Returns one `Vec<Binding>` per
2903    /// output group, column-aligned with `items`. Shared by
2904    /// `materialize_with` and `materialize_return` — both already take the
2905    /// same `rows: &[BindingRow]` input type, so the grouping core stays
2906    /// in `Binding`-space (preserving graph identity for bare-var grouping
2907    /// keys) and each caller does its own thin final conversion.
2908    ///
2909    /// An item "aggregates" (`contains_aggregate`) in one of two shapes:
2910    /// purely (`count(a)`, `count(*)`, the only shape this used to
2911    /// support) or composed with other expressions (`count(a) + 3`, `a,
2912    /// count(a)` isn't this -- `a` is its own separate, non-aggregating
2913    /// item). Either way, `Group.accs[i]` holds one `AggAcc` per
2914    /// aggregate-bearing subexpression found in that item's tree
2915    /// (`collect_agg_nodes`'s order — empty for a non-aggregating item,
2916    /// exactly one for the purely-aggregating shape), and finishing a
2917    /// composed item evaluates its whole expression tree via
2918    /// `rewrite_composed_item` rather than just unwrapping a single
2919    /// accumulator. `validate_return_items` (which callers must run
2920    /// first) already guarantees every non-aggregate leaf inside a
2921    /// composed item's tree matches some *other* item's own top-level
2922    /// expression verbatim, so this function trusts that invariant rather
2923    /// than re-checking it.
2924    ///
2925    /// Grouping-key lookup is a hash-map lookup (`group_index`, keyed by
2926    /// `binding_hash_key`'s output — `Binding`/`PropertyValue` don't
2927    /// derive `Eq`/`Hash` themselves, `PropertyValue::Float` can't, so
2928    /// `HashKey` stands in for them; see its docs) into `groups`, which
2929    /// stays a plain `Vec` for insertion-order-stable output when there's
2930    /// no ORDER BY. O(1) average per row, not the O(rows × groups) linear
2931    /// scan this used to be — see BENCHMARKS.md for the measured
2932    /// before/after.
2933    fn resolve_grouped_rows(
2934        &self,
2935        txn: Txn,
2936        items: &[ReturnItem],
2937        rows: &[BindingRow],
2938        guard: &ExecutionGuard<'_>,
2939    ) -> Result<Vec<Vec<Binding>>, QueryError> {
2940        struct Group {
2941            // Aligned to `items`: `Some` at a non-aggregating item's
2942            // index, `None` at an aggregating one's (whether purely
2943            // aggregating or composed) -- exactly one of
2944            // `key_bindings[i]`/`!accs[i].is_empty()` holds per `i`.
2945            key_bindings: Vec<Option<Binding>>,
2946            accs: Vec<Vec<AggAcc>>,
2947            row_count: i64,
2948        }
2949        fn fresh_accs(items: &[ReturnItem]) -> Vec<Vec<AggAcc>> {
2950            items
2951                .iter()
2952                .map(|item| {
2953                    let mut nodes = Vec::new();
2954                    collect_agg_nodes(&item.expr, &mut nodes);
2955                    nodes
2956                        .into_iter()
2957                        .map(|node| match node {
2958                            ReturnExpr::CountStar => AggAcc::identity("count", false),
2959                            ReturnExpr::Call { name, distinct, .. } => {
2960                                AggAcc::identity(name, *distinct)
2961                            }
2962                            _ => unreachable!(
2963                                "collect_agg_nodes only ever collects CountStar/aggregate Call nodes"
2964                            ),
2965                        })
2966                        .collect()
2967                })
2968                .collect()
2969        }
2970        // Computed once, not per row -- `item_agg_nodes[i][k]` is exactly
2971        // the node `group.accs[i][k]` accumulates for, every row.
2972        let item_agg_nodes: Vec<Vec<&ReturnExpr>> = items
2973            .iter()
2974            .map(|item| {
2975                let mut nodes = Vec::new();
2976                collect_agg_nodes(&item.expr, &mut nodes);
2977                nodes
2978            })
2979            .collect();
2980
2981        // Groups live in `groups` (insertion order, for stable output when
2982        // there's no ORDER BY) with `group_index` as a hash-based lookup
2983        // into it, keyed by a hashable stand-in for `key_bindings` (see
2984        // `HashKey` — `Binding`/`PropertyValue` don't derive `Eq`/`Hash`
2985        // themselves, `PropertyValue::Float` can't). O(1) average lookup
2986        // per row instead of the O(groups) linear scan this replaced —
2987        // see BENCHMARKS.md for the measured before/after.
2988        let mut groups: Vec<Group> = Vec::new();
2989        let mut group_index: HashMap<Vec<Option<HashKey>>, usize> = HashMap::new();
2990        for row in rows {
2991            let mut key_bindings = Vec::with_capacity(items.len());
2992            for item in items {
2993                key_bindings.push(if contains_aggregate(&item.expr) {
2994                    None
2995                } else {
2996                    Some(self.item_binding(txn, &item.expr, row, guard)?)
2997                });
2998            }
2999            let hash_key: Vec<Option<HashKey>> = key_bindings
3000                .iter()
3001                .map(|b| b.as_ref().map(binding_hash_key).transpose())
3002                .collect::<Result<Vec<_>, _>>()?;
3003            let group_idx = *group_index.entry(hash_key).or_insert_with(|| {
3004                groups.push(Group {
3005                    key_bindings: key_bindings.clone(),
3006                    accs: fresh_accs(items),
3007                    row_count: 0,
3008                });
3009                groups.len() - 1
3010            });
3011            let group = &mut groups[group_idx];
3012            group.row_count += 1;
3013            for (i, nodes) in item_agg_nodes.iter().enumerate() {
3014                for (k, node) in nodes.iter().enumerate() {
3015                    match node {
3016                        // `count(*)` counts rows, not values -- folded
3017                        // unconditionally (no null-skip: there's no
3018                        // per-row expression to be null) via a dummy
3019                        // always-non-null argument, reusing `AggAcc::
3020                        // Count`'s existing fold logic instead of a
3021                        // separate no-accumulator path (see `fresh_accs`).
3022                        ReturnExpr::CountStar => {
3023                            group.accs[i][k].fold(&Value::Literal(Literal::Bool(true)))?;
3024                        }
3025                        ReturnExpr::Call { name, args, .. } => {
3026                            // `count(<bare var>)` of a node/relationship
3027                            // needs only the entity's identity, never its
3028                            // record — fold by id directly, skipping the
3029                            // record decode `eval_return_expr` would do.
3030                            // Besides the per-row saving, this is what lets
3031                            // `... DELETE p RETURN count(p)` count an
3032                            // entity deleted earlier in the same statement
3033                            // (Neo4j-compatible), while property access on
3034                            // it keeps erroring via `deleted_entity_access`.
3035                            if name.eq_ignore_ascii_case("count") {
3036                                let key = match args.first() {
3037                                    Some(ReturnExpr::Var(v)) => match row.get(v) {
3038                                        Some(Binding::Node(id)) => Some(HashKey::Node(*id)),
3039                                        Some(Binding::Edge(id)) => Some(HashKey::Edge(*id)),
3040                                        _ => None,
3041                                    },
3042                                    _ => None,
3043                                };
3044                                if let Some(key) = key {
3045                                    group.accs[i][k].fold_count_entity(key)?;
3046                                    continue;
3047                                }
3048                            }
3049                            // Standard Cypher null-skipping: a null
3050                            // argument (e.g. an unmatched OPTIONAL MATCH
3051                            // variable) contributes to neither the
3052                            // accumulator nor its DISTINCT dedup set.
3053                            let value = self.eval_return_expr(txn, &args[0], row, guard)?;
3054                            if is_percentile_name(name) {
3055                                // percentileCont/percentileDisc's second
3056                                // argument (the percentile) is evaluated
3057                                // per row too -- in practice always a
3058                                // constant across the group, but nothing
3059                                // structurally requires that, so it's just
3060                                // evaluated fresh every row like any other
3061                                // expression rather than memoized once.
3062                                let percentile =
3063                                    self.eval_return_expr(txn, &args[1], row, guard)?;
3064                                if !matches!(value, Value::Null) {
3065                                    group.accs[i][k].fold_percentile(&value, &percentile)?;
3066                                }
3067                            } else if !matches!(value, Value::Null) {
3068                                group.accs[i][k].fold(&value)?;
3069                            }
3070                        }
3071                        _ => unreachable!(
3072                            "collect_agg_nodes only ever collects CountStar/aggregate Call nodes"
3073                        ),
3074                    }
3075                }
3076            }
3077        }
3078
3079        // Global aggregate over an empty result set (no grouping-key items
3080        // at all, and no rows to seed a group from) still produces exactly
3081        // one output row — `count`/`count(*)` -> 0, `sum` -> 0,
3082        // `avg`/`min`/`max` -> Null, `collect` -> [] — via the same
3083        // fresh-accumulator `finish()` path a normal empty-contribution
3084        // group already uses below, not a separate code path.
3085        let no_key_items = items.iter().all(|item| contains_aggregate(&item.expr));
3086        if groups.is_empty() && no_key_items {
3087            groups.push(Group {
3088                key_bindings: vec![None; items.len()],
3089                accs: fresh_accs(items),
3090                row_count: 0,
3091            });
3092        }
3093
3094        let mut out = Vec::with_capacity(groups.len());
3095        for mut group in groups {
3096            let ctx = GroupFinishCtx {
3097                items,
3098                key_bindings: &group.key_bindings,
3099            };
3100            let mut row_out = Vec::with_capacity(items.len());
3101            for (i, item) in items.iter().enumerate() {
3102                let binding = match &group.key_bindings[i] {
3103                    Some(b) => b.clone(),
3104                    None => {
3105                        let mut accs = std::mem::take(&mut group.accs[i]).into_iter();
3106                        let mut subst = HashMap::new();
3107                        let rewritten = self
3108                            .rewrite_composed_item(txn, &item.expr, &ctx, &mut accs, &mut subst)?;
3109                        value_to_binding(eval_projected_expr(&rewritten, &subst)?)
3110                    }
3111                };
3112                row_out.push(binding);
3113            }
3114            out.push(row_out);
3115        }
3116        Ok(out)
3117    }
3118
3119    /// Finishing half of a composed aggregate item (`count(a) + 3`):
3120    /// rewrites `expr`'s tree into an equivalent one `eval_projected_expr`
3121    /// can evaluate without any further graph access, replacing every
3122    /// aggregate-bearing subexpression with a synthetic `Var` referencing
3123    /// its now-finished accumulator's value in `subst` (consumed from
3124    /// `accs` in `collect_agg_nodes`'s order, the same order `fresh_accs`/
3125    /// the per-row fold loop in `resolve_grouped_rows` built them in), and
3126    /// every non-aggregate `Var`/`Prop` leaf with a synthetic `Var`
3127    /// referencing whichever *other* item's own grouping-key `Binding` it
3128    /// structurally matches (`validate_return_items` already guarantees
3129    /// exactly one such match exists — never reached otherwise). Each
3130    /// substituted value gets its own fresh, guaranteed-unique slot name
3131    /// (`subst.len()` at insertion time), so nothing here can collide with
3132    /// a real Cypher identifier the user wrote.
3133    fn rewrite_composed_item(
3134        &self,
3135        txn: Txn,
3136        expr: &ReturnExpr,
3137        ctx: &GroupFinishCtx<'_>,
3138        accs: &mut std::vec::IntoIter<AggAcc>,
3139        subst: &mut HashMap<String, Value>,
3140    ) -> Result<ReturnExpr, QueryError> {
3141        if matches!(expr, ReturnExpr::CountStar)
3142            || matches!(expr, ReturnExpr::Call { name, .. } if is_aggregate_name(name))
3143        {
3144            let value = accs
3145                .next()
3146                .expect("accs is aligned with this same expr's collect_agg_nodes traversal order")
3147                .finish();
3148            let slot = format!("__slot{}", subst.len());
3149            subst.insert(slot.clone(), value);
3150            return Ok(ReturnExpr::Var(slot));
3151        }
3152        if matches!(expr, ReturnExpr::Var(_) | ReturnExpr::Prop(_)) {
3153            let j = ctx
3154                .items
3155                .iter()
3156                .enumerate()
3157                .position(|(i, it)| item_matches_leaf(expr, i, it) && !contains_aggregate(&it.expr))
3158                .expect(
3159                    "validate_return_items already checked this leaf matches a grouping-key item",
3160                );
3161            let binding = ctx.key_bindings[j]
3162                .clone()
3163                .expect("a non-aggregating item always has a key binding");
3164            let value = self.binding_to_value(txn, &binding)?;
3165            let slot = format!("__slot{}", subst.len());
3166            subst.insert(slot.clone(), value);
3167            return Ok(ReturnExpr::Var(slot));
3168        }
3169        Ok(match expr {
3170            ReturnExpr::Lit(lit) => ReturnExpr::Lit(lit.clone()),
3171            ReturnExpr::Call {
3172                name,
3173                args,
3174                distinct,
3175            } => ReturnExpr::Call {
3176                name: name.clone(),
3177                distinct: *distinct,
3178                args: args
3179                    .iter()
3180                    .map(|a| self.rewrite_composed_item(txn, a, ctx, accs, subst))
3181                    .collect::<Result<_, _>>()?,
3182            },
3183            ReturnExpr::Case { test, whens, else_ } => ReturnExpr::Case {
3184                test: test
3185                    .as_deref()
3186                    .map(|t| self.rewrite_composed_item(txn, t, ctx, accs, subst))
3187                    .transpose()?
3188                    .map(Box::new),
3189                whens: whens
3190                    .iter()
3191                    .map(|(w, t)| {
3192                        Ok::<_, QueryError>((
3193                            self.rewrite_composed_item(txn, w, ctx, accs, subst)?,
3194                            self.rewrite_composed_item(txn, t, ctx, accs, subst)?,
3195                        ))
3196                    })
3197                    .collect::<Result<_, _>>()?,
3198                else_: else_
3199                    .as_deref()
3200                    .map(|e| self.rewrite_composed_item(txn, e, ctx, accs, subst))
3201                    .transpose()?
3202                    .map(Box::new),
3203            },
3204            ReturnExpr::Arith(l, op, r) => ReturnExpr::Arith(
3205                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
3206                *op,
3207                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
3208            ),
3209            ReturnExpr::Neg(e) => ReturnExpr::Neg(Box::new(
3210                self.rewrite_composed_item(txn, e, ctx, accs, subst)?,
3211            )),
3212            ReturnExpr::ListLit(list_items) => ReturnExpr::ListLit(
3213                list_items
3214                    .iter()
3215                    .map(|i| self.rewrite_composed_item(txn, i, ctx, accs, subst))
3216                    .collect::<Result<_, _>>()?,
3217            ),
3218            ReturnExpr::Index(base, index) => ReturnExpr::Index(
3219                Box::new(self.rewrite_composed_item(txn, base, ctx, accs, subst)?),
3220                Box::new(self.rewrite_composed_item(txn, index, ctx, accs, subst)?),
3221            ),
3222            ReturnExpr::PropOf(base, prop) => ReturnExpr::PropOf(
3223                Box::new(self.rewrite_composed_item(txn, base, ctx, accs, subst)?),
3224                prop.clone(),
3225            ),
3226            ReturnExpr::Slice(base, start, end) => ReturnExpr::Slice(
3227                Box::new(self.rewrite_composed_item(txn, base, ctx, accs, subst)?),
3228                start
3229                    .as_deref()
3230                    .map(|s| self.rewrite_composed_item(txn, s, ctx, accs, subst))
3231                    .transpose()?
3232                    .map(Box::new),
3233                end.as_deref()
3234                    .map(|e| self.rewrite_composed_item(txn, e, ctx, accs, subst))
3235                    .transpose()?
3236                    .map(Box::new),
3237            ),
3238            // `where_clause`/`project` are deliberately left untouched
3239            // (cloned verbatim), not recursed into -- they run once per
3240            // *element* of `source`'s own already-rewritten result, in a
3241            // scope `eval_projected_expr`'s own `ListComp`/`Quantifier`
3242            // handling builds itself (the outer `subst` map plus a fresh
3243            // binding for `var`, per element). Rewriting a `Var`/`Prop`
3244            // leaf in here the same way `source` gets rewritten would
3245            // wrongly try to resolve the comprehension's own *local* loop
3246            // variable (`x`/`ok`) as if it had to be some other item's
3247            // grouping key -- there's no such item, since it's not an
3248            // outer reference at all (found via TCK's List11 [3]: `ALL(ok
3249            // IN collect(...) WHERE ok)` panicked trying to resolve `ok`
3250            // this way). `validate_composed_expr`'s own `ListComp` arm
3251            // already guarantees `project` has no aggregate to substitute
3252            // in the first place; `where_clause` is the same documented
3253            // scope gap `contains_aggregate` has everywhere else.
3254            ReturnExpr::ListComp {
3255                var,
3256                source,
3257                where_clause,
3258                project,
3259            } => ReturnExpr::ListComp {
3260                var: var.clone(),
3261                source: Box::new(self.rewrite_composed_item(txn, source, ctx, accs, subst)?),
3262                where_clause: where_clause.clone(),
3263                project: project.clone(),
3264            },
3265            ReturnExpr::Quantifier {
3266                kind,
3267                var,
3268                source,
3269                where_clause,
3270            } => ReturnExpr::Quantifier {
3271                kind: *kind,
3272                var: var.clone(),
3273                source: Box::new(self.rewrite_composed_item(txn, source, ctx, accs, subst)?),
3274                where_clause: where_clause.clone(),
3275            },
3276            ReturnExpr::MapLit(entries) => ReturnExpr::MapLit(
3277                entries
3278                    .iter()
3279                    .map(|(k, v)| {
3280                        Ok::<_, QueryError>((
3281                            k.clone(),
3282                            self.rewrite_composed_item(txn, v, ctx, accs, subst)?,
3283                        ))
3284                    })
3285                    .collect::<Result<_, _>>()?,
3286            ),
3287            ReturnExpr::And(l, r) => ReturnExpr::And(
3288                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
3289                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
3290            ),
3291            ReturnExpr::Or(l, r) => ReturnExpr::Or(
3292                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
3293                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
3294            ),
3295            ReturnExpr::Xor(l, r) => ReturnExpr::Xor(
3296                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
3297                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
3298            ),
3299            ReturnExpr::Not(e) => ReturnExpr::Not(Box::new(
3300                self.rewrite_composed_item(txn, e, ctx, accs, subst)?,
3301            )),
3302            ReturnExpr::Compare(l, op, r) => ReturnExpr::Compare(
3303                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
3304                *op,
3305                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
3306            ),
3307            ReturnExpr::IsNull(e) => ReturnExpr::IsNull(Box::new(
3308                self.rewrite_composed_item(txn, e, ctx, accs, subst)?,
3309            )),
3310            ReturnExpr::In(needle, haystack) => ReturnExpr::In(
3311                Box::new(self.rewrite_composed_item(txn, needle, ctx, accs, subst)?),
3312                Box::new(self.rewrite_composed_item(txn, haystack, ctx, accs, subst)?),
3313            ),
3314            ReturnExpr::HasLabel(v, l) => ReturnExpr::HasLabel(v.clone(), l.clone()),
3315            ReturnExpr::PatternPredicate(p) => ReturnExpr::PatternPredicate(p.clone()),
3316            ReturnExpr::PatternComprehension { .. } => expr.clone(),
3317            ReturnExpr::ExistsPattern { .. } => expr.clone(),
3318            ReturnExpr::ExistsSubquery(_) => expr.clone(),
3319            ReturnExpr::Var(_) | ReturnExpr::Prop(_) | ReturnExpr::CountStar => {
3320                unreachable!("handled above, before this match")
3321            }
3322        })
3323    }
3324
3325    /// WITH's HAVING-equivalent — evaluated against the already-projected/
3326    /// grouped row, same as ORDER BY. Never pushed into the planner (see
3327    /// `WithExpr`'s docs).
3328    /// `Option<bool>` — `None` is Cypher's "unknown" (see `compare()`'s
3329    /// docs), propagated through `AND`/`OR`/`NOT` via `and3`/`or3`/`map`
3330    /// instead of collapsing to `false` partway through. Every call site
3331    /// filters a row by checking `== Some(true)` — unknown behaves like
3332    /// `false` for filtering purposes, but *only* at that final step, not
3333    /// internally, since `AND`/`OR`'s truth tables need to tell "false"
3334    /// and "unknown" apart to combine correctly.
3335    fn eval_with_expr(
3336        &self,
3337        txn: Txn,
3338        expr: &WithExpr,
3339        row: &BindingRow,
3340        guard: &ExecutionGuard<'_>,
3341    ) -> Result<Option<bool>, QueryError> {
3342        Ok(match expr {
3343            WithExpr::And(l, r) => and3(
3344                self.eval_with_expr(txn, l, row, guard)?,
3345                self.eval_with_expr(txn, r, row, guard)?,
3346            ),
3347            WithExpr::Or(l, r) => or3(
3348                self.eval_with_expr(txn, l, row, guard)?,
3349                self.eval_with_expr(txn, r, row, guard)?,
3350            ),
3351            WithExpr::Not(e) => self.eval_with_expr(txn, e, row, guard)?.map(|b| !b),
3352            WithExpr::Compare(lhs, op, rhs) => {
3353                let lv = self.eval_return_expr(txn, lhs, row, guard)?;
3354                let rv = self.eval_return_expr(txn, rhs, row, guard)?;
3355                compare_values(&lv, *op, &rv)
3356            }
3357            // Always definite -- same reasoning as `Expr::IsNull`.
3358            WithExpr::IsNull(e) => Some(matches!(
3359                self.eval_return_expr(txn, e, row, guard)?,
3360                Value::Null
3361            )),
3362            // Unlike an ordinary MATCH's own `WHERE` (`Expr`), which folds
3363            // a bare pattern predicate into `Expr::Pattern` at parse time
3364            // (`return_expr_to_expr`), `WithExpr` has no such folding --
3365            // `WITH ... WHERE a.id = 0 AND (a)-->(b)` embeds it straight
3366            // as a `ReturnExpr::PatternPredicate` inside `Bare`/`And`/`Or`.
3367            // Special-cased here (rather than in `eval_return_expr`, which
3368            // errors on it -- a pattern predicate is only ever meaningful
3369            // as a predicate, never as a real projected value) so `WITH
3370            // ... WHERE` gets the same existential-search semantics MATCH's
3371            // own `WHERE` already has (TCK's WithWhere4 `[2]`).
3372            WithExpr::Bare(ReturnExpr::PatternPredicate(pattern)) => {
3373                Some(self.eval_pattern_predicate_exists(txn, pattern, row, guard)?)
3374            }
3375            WithExpr::Bare(e) => self.eval_return_expr_bool3(txn, e, row, guard)?,
3376        })
3377    }
3378
3379    /// `WHERE (n)-[:REL]->()` etc (TCK's Pattern1) -- existential: true
3380    /// iff at least one real match of `pattern` exists, with every
3381    /// already-bound named endpoint (`n`, and `m` in `(n)-->(m)` when `m`
3382    /// is also bound by an earlier MATCH) held fixed to this row's own
3383    /// binding rather than searched freely. `semantic::
3384    /// validate_pattern_predicate` already rejected any named endpoint
3385    /// that ISN'T already bound (real Cypher's `UndefinedVariable`), so
3386    /// every named var here is safe to seed. Reuses the exact same
3387    /// `build_match_plan` "already-bound var -> Seed, not a fresh scan"
3388    /// mechanism `eval_merge`'s own "try as an ordinary MATCH first" half
3389    /// already relies on -- for a one-hop pattern this is a real
3390    /// connected-subgraph search (Expand + Filter), not an isolated
3391    /// per-node check. `Some(1)`-limited: existence is all that's needed,
3392    /// so there's no reason to enumerate every match. Shared by `Expr::
3393    /// Pattern` (an ordinary MATCH's own WHERE) and `WithExpr::Bare`'s
3394    /// `PatternPredicate` case (a WITH's own WHERE) -- same semantics
3395    /// either way, just reached from two different expression shapes.
3396    fn eval_pattern_predicate_exists(
3397        &self,
3398        txn: Txn,
3399        pattern: &Pattern,
3400        row: &BindingRow,
3401        guard: &ExecutionGuard<'_>,
3402    ) -> Result<bool, QueryError> {
3403        let carried_vars: HashSet<String> = row.keys().cloned().collect();
3404        let plan = apply_index_seeks(build_match_plan(pattern, &None, &carried_vars)?, txn)?;
3405        let found =
3406            self.eval_plan_with_limit(txn, &plan, std::slice::from_ref(row), guard, Some(1))?;
3407        Ok(!found.is_empty())
3408    }
3409
3410    /// `exists { MATCH ... RETURN ... }`'s "full" form (TCK's
3411    /// ExistentialSubquery2/3) -- runs `stmt` (always a `Statement::Match`,
3412    /// `semantic::validate_statement` rejects anything else reaching here
3413    /// and rejects every mutating clause inside it, so this only ever sees
3414    /// a real read-only pipeline) correlated against `row` via
3415    /// `execute_match_seeded`, then checks whether it produced at least
3416    /// one output row -- the inner RETURN's own projected *values* are
3417    /// never inspected, only whether the row exists at all, same as
3418    /// `eval_pattern_predicate_exists`/`Expr::Exists` above.
3419    fn eval_exists_subquery(
3420        &self,
3421        txn: Txn,
3422        stmt: &Statement,
3423        row: &BindingRow,
3424        guard: &ExecutionGuard<'_>,
3425    ) -> Result<bool, QueryError> {
3426        let Statement::Match {
3427            clauses,
3428            tail,
3429            order_by,
3430            skip,
3431            limit,
3432        } = stmt
3433        else {
3434            unreachable!(
3435                "semantic::validate_statement only allows Statement::Match inside exists {{}}"
3436            )
3437        };
3438        let skip = self.resolve_skip_limit(txn, skip.as_deref(), "SKIP", guard)?;
3439        let limit = self.resolve_skip_limit(txn, limit.as_deref(), "LIMIT", guard)?;
3440        let result = self.execute_match_seeded(
3441            txn,
3442            clauses,
3443            tail,
3444            ResultModifiers {
3445                order_by,
3446                skip,
3447                limit,
3448            },
3449            Some(row),
3450            guard,
3451        )?;
3452        Ok(!result.rows.is_empty())
3453    }
3454
3455    /// Evaluates an `OPTIONAL MATCH` part with left-outer-join semantics:
3456    /// every outer row survives, whether or not the optional pattern
3457    /// matched anything for it. Must wrap the *whole* subplan rather than
3458    /// null-padding inside `Expand`/`VarExpand` themselves — baking it in
3459    /// there would turn every default (non-optional) `Expand` into a
3460    /// left-outer-join too (breaking existing inner-join semantics), and
3461    /// would mis-handle multi-hop optional patterns: IS7's optional
3462    /// pattern is 2 hops, and per-hop null-padding would emit one
3463    /// null-padded row per *hop-1* match even when hop 2 also matched,
3464    /// instead of collapsing to exactly one row per outer row that had
3465    /// zero end-to-end matches.
3466    ///
3467    /// Implementation: tag each outer row with its index, evaluate the
3468    /// subplan once over the whole tagged batch (a single seed, not one
3469    /// call per row), group results back by that index, then for any
3470    /// outer index with zero results, emit the outer row unchanged plus
3471    /// `Null` for every variable the optional pattern would have newly
3472    /// introduced.
3473    fn eval_optional_part(
3474        &self,
3475        txn: Txn,
3476        plan: &LogicalPlan,
3477        outer_rows: &[BindingRow],
3478        new_vars: &HashSet<String>,
3479        guard: &ExecutionGuard<'_>,
3480    ) -> Result<Vec<BindingRow>, QueryError> {
3481        let tagged: Vec<BindingRow> = outer_rows
3482            .iter()
3483            .enumerate()
3484            .map(|(i, row)| {
3485                let mut r = row.clone();
3486                r.insert(
3487                    OPTIONAL_SEED_IDX_KEY.to_string(),
3488                    Binding::Value(PropertyValue::Int(i as i64)),
3489                );
3490                r
3491            })
3492            .collect();
3493        guard.check_intermediate_rows(tagged.len())?;
3494        let results = self.eval_plan(txn, plan, &tagged, guard)?;
3495        let mut by_idx: HashMap<i64, Vec<BindingRow>> = HashMap::new();
3496        for mut row in results {
3497            let idx = match row.remove(OPTIONAL_SEED_IDX_KEY) {
3498                Some(Binding::Value(PropertyValue::Int(i))) => i,
3499                other => unreachable!(
3500                    "__seed_idx tagged internally as Binding::Value(Int), got {other:?}"
3501                ),
3502            };
3503            by_idx.entry(idx).or_default().push(row);
3504        }
3505        let mut out = Vec::with_capacity(outer_rows.len());
3506        for (i, outer_row) in outer_rows.iter().enumerate() {
3507            match by_idx.remove(&(i as i64)) {
3508                Some(matches) => out.extend(matches),
3509                None => {
3510                    let mut padded = outer_row.clone();
3511                    for var in new_vars {
3512                        padded.insert(var.clone(), Binding::Value(PropertyValue::Null));
3513                    }
3514                    out.push(padded);
3515                }
3516            }
3517            guard.check_intermediate_rows(out.len())?;
3518        }
3519        Ok(out)
3520    }
3521
3522    fn eval_plan(
3523        &self,
3524        txn: Txn,
3525        plan: &LogicalPlan,
3526        seed: &[BindingRow],
3527        guard: &ExecutionGuard<'_>,
3528    ) -> Result<Vec<BindingRow>, QueryError> {
3529        self.eval_plan_with_limit(txn, plan, seed, guard, None)
3530    }
3531
3532    fn eval_plan_with_limit(
3533        &self,
3534        txn: Txn,
3535        plan: &LogicalPlan,
3536        seed: &[BindingRow],
3537        guard: &ExecutionGuard<'_>,
3538        limit: Option<usize>,
3539    ) -> Result<Vec<BindingRow>, QueryError> {
3540        let stream = self.stream_plan(txn, plan, seed, guard, limit);
3541        match limit {
3542            Some(limit) => stream.take(limit).collect(),
3543            None => stream.collect(),
3544        }
3545    }
3546
3547    /// Build a pull-based row pipeline. Each iterator owns only its current
3548    /// row (plus one relationship fan-out at an Expand), so scan/filter/
3549    /// expand chains no longer allocate a Vec at every logical-plan node.
3550    /// Blocking clause boundaries still collect this stream explicitly.
3551    fn stream_plan<'s>(
3552        &'s self,
3553        txn: Txn<'s>,
3554        plan: &'s LogicalPlan,
3555        seed: &'s [BindingRow],
3556        guard: &'s ExecutionGuard<'_>,
3557        scan_limit: Option<usize>,
3558    ) -> RowStream<'s> {
3559        match plan {
3560            LogicalPlan::Seed { var } => {
3561                debug_assert!(
3562                    seed.first().is_none_or(|row| row.contains_key(var)),
3563                    "Seed{{var: {var:?}}} planned for a var not present in the carried-forward rows"
3564                );
3565                Self::count_stream(Box::new(seed.iter().cloned().map(Ok)), guard)
3566            }
3567            LogicalPlan::AllNodesScan { var } => {
3568                self.stream_scan(txn, var, None, seed, guard, scan_limit)
3569            }
3570            LogicalPlan::NodeByLabelScan { var, label } => {
3571                self.stream_scan(txn, var, Some(label), seed, guard, scan_limit)
3572            }
3573            LogicalPlan::IndexRangeSeek {
3574                var,
3575                label,
3576                prop,
3577                lo,
3578                hi,
3579            } => self.stream_index_range_seek(txn, var, label, prop, lo, hi, seed, guard),
3580            LogicalPlan::EdgeTypeScan {
3581                src_var,
3582                rel_var,
3583                dst_var,
3584                rel_types,
3585                src_label,
3586                dst_label,
3587                rel_predicate,
3588            } => self.stream_edge_type_scan(
3589                txn,
3590                EdgeTypeScanSpec {
3591                    src_var,
3592                    rel_var,
3593                    dst_var,
3594                    rel_types,
3595                    src_label: src_label.as_deref(),
3596                    dst_label: dst_label.as_deref(),
3597                    rel_predicate: rel_predicate.as_ref(),
3598                },
3599                seed,
3600                guard,
3601            ),
3602            LogicalPlan::IndexSeek {
3603                var,
3604                label,
3605                prop,
3606                value,
3607            } => self.stream_index_seek(
3608                txn,
3609                IndexSeekSpec {
3610                    var,
3611                    label,
3612                    prop,
3613                    value,
3614                },
3615                seed,
3616                guard,
3617                scan_limit,
3618            ),
3619            LogicalPlan::Expand {
3620                input,
3621                from_var,
3622                to_var,
3623                rel_var,
3624                rel_labels,
3625                direction,
3626            } => {
3627                let input = self.stream_plan(txn, input, seed, guard, None);
3628                let stream = input.flat_map(move |res| -> RowStream<'s> {
3629                    let row = match res {
3630                        Ok(row) => row,
3631                        Err(error) => return Box::new(std::iter::once(Err(error))),
3632                    };
3633                    let from_id = match row.get(from_var) {
3634                        Some(Binding::Node(id)) => *id,
3635                        // A null binding has no neighbors and contributes
3636                        // no rows. Missing or structurally invalid bindings
3637                        // remain errors.
3638                        Some(Binding::Value(PropertyValue::Null)) => {
3639                            return Box::new(std::iter::empty())
3640                        }
3641                        _ => {
3642                            return Box::new(std::iter::once(Err(QueryError::UnboundVariable(
3643                                from_var.clone(),
3644                            ))))
3645                        }
3646                    };
3647                    match neighbors_for_direction(txn, from_id, *direction, rel_labels) {
3648                        Ok(entries) => Box::new(entries.into_iter().map(move |entry| {
3649                            guard.relationship_expansion()?;
3650                            let mut new_row = row.clone();
3651                            new_row.insert(to_var.clone(), Binding::Node(entry.other));
3652                            if let Some(rel_var) = rel_var {
3653                                new_row.insert(rel_var.clone(), Binding::Edge(entry.edge_id));
3654                            }
3655                            Ok(new_row)
3656                        })),
3657                        Err(error) => Box::new(std::iter::once(Err(error))),
3658                    }
3659                });
3660                Self::count_stream(Box::new(stream), guard)
3661            }
3662            LogicalPlan::VarExpand {
3663                input,
3664                from_var,
3665                to_var,
3666                rel_labels,
3667                direction,
3668                min_hops,
3669                max_hops,
3670                exclude_edge_vars,
3671                exclude_edge_sets,
3672                exclude_edge_var,
3673                path_segment_var,
3674                rel_list_var,
3675                rel_props,
3676            } => {
3677                let input = self.stream_plan(txn, input, seed, guard, None);
3678                let stream = input.flat_map(move |res| {
3679                    let rows = res.and_then(|row| {
3680                        self.expand_variable_row(
3681                            txn,
3682                            row,
3683                            VarExpandSpec {
3684                                from_var,
3685                                to_var,
3686                                rel_labels,
3687                                direction: *direction,
3688                                min_hops: *min_hops,
3689                                max_hops: *max_hops,
3690                                exclude_edge_vars,
3691                                exclude_edge_sets,
3692                                exclude_edge_var,
3693                                path_segment_var: path_segment_var.as_deref(),
3694                                rel_list_var: rel_list_var.as_deref(),
3695                                rel_props,
3696                            },
3697                            guard,
3698                        )
3699                    });
3700                    match rows {
3701                        Ok(rows) => Box::new(rows.into_iter().map(Ok)) as RowStream<'s>,
3702                        Err(error) => Box::new(std::iter::once(Err(error))),
3703                    }
3704                });
3705                Self::count_stream(Box::new(stream), guard)
3706            }
3707            LogicalPlan::MatchRelList {
3708                input,
3709                from_var,
3710                to_var,
3711                rel_list_var,
3712                rel_labels,
3713                direction,
3714                min_hops,
3715                max_hops,
3716            } => {
3717                let input = self.stream_plan(txn, input, seed, guard, None);
3718                let stream = input.filter_map(move |res| {
3719                    let row = match res {
3720                        Ok(row) => row,
3721                        Err(error) => return Some(Err(error)),
3722                    };
3723                    self.match_bound_rel_list_row(
3724                        row,
3725                        MatchRelListSpec {
3726                            from_var,
3727                            to_var,
3728                            rel_list_var,
3729                            rel_labels,
3730                            direction: *direction,
3731                            min_hops: *min_hops,
3732                            max_hops: *max_hops,
3733                        },
3734                    )
3735                    .transpose()
3736                });
3737                Self::count_stream(Box::new(stream), guard)
3738            }
3739            LogicalPlan::Filter { input, predicate } => {
3740                let input = self.stream_plan(txn, input, seed, guard, None);
3741                let stream = input.filter_map(move |res| {
3742                    let row = match res {
3743                        Ok(row) => row,
3744                        Err(error) => return Some(Err(error)),
3745                    };
3746                    if let Err(error) = guard.checkpoint() {
3747                        return Some(Err(error));
3748                    }
3749                    match self.eval_expr(txn, predicate, &row, guard) {
3750                        Ok(Some(true)) => Some(Ok(row)),
3751                        Ok(_) => None,
3752                        Err(error) => Some(Err(error)),
3753                    }
3754                });
3755                Self::count_stream(Box::new(stream), guard)
3756            }
3757        }
3758    }
3759
3760    /// Wraps every `stream_plan` operator's output: counts produced rows
3761    /// against the guard's intermediate-row limit, and FUSES the stream
3762    /// after the first `Err` — `next()` returns `None` from then on, so
3763    /// the erroring operator (and everything beneath it) is never polled
3764    /// again. The operator closures in `stream_plan` rely on this instead
3765    /// of each tracking its own post-error `done` flag: after they emit an
3766    /// `Err`, this wrapper guarantees they're not resumed.
3767    fn count_stream<'s>(mut stream: RowStream<'s>, guard: &'s ExecutionGuard<'_>) -> RowStream<'s> {
3768        let mut produced = 0usize;
3769        let mut done = false;
3770        Box::new(std::iter::from_fn(move || {
3771            if done {
3772                return None;
3773            }
3774            let item = stream.next()?;
3775            if item.is_ok() {
3776                produced = match produced.checked_add(1) {
3777                    Some(produced) => produced,
3778                    None => {
3779                        done = true;
3780                        return Some(Err(QueryError::ResourceLimit(
3781                            "stream row counter overflow".into(),
3782                        )));
3783                    }
3784                };
3785                if let Err(error) = guard.check_intermediate_rows(produced) {
3786                    done = true;
3787                    return Some(Err(error));
3788                }
3789            } else {
3790                done = true;
3791            }
3792            Some(item)
3793        }))
3794    }
3795
3796    /// Fast path for aggregating expansion chains -- one or two `Expand`
3797    /// hops feeding a `WITH` that groups by the final node and computes
3798    /// `count(*)` and/or `collect(<mid-node>.prop)`:
3799    ///
3800    /// ```text
3801    /// MATCH (s ...)-[:X]-(b)            WITH b, count(*) ...           (1 hop)
3802    /// MATCH (s ...)-[:X]-(a)-[:Y]-(b)   WITH b, count(*) ...           (2 hops)
3803    /// MATCH (s ...)-[:X]-(a)-[:Y]-(b)   WITH b, collect(a.p), count(*) (2 hops)
3804    /// ```
3805    ///
3806    /// Counts/collects in a tight loop over `neighbors_in_txn` instead of
3807    /// materializing a `BindingRow` per intermediate path. Motivation is
3808    /// measured, not assumed: the same algorithm hand-rolled runs in ~1ms
3809    /// where the generic pipeline takes ~100ms on the recommendations
3810    /// dataset (`marsdb/examples/csr_falsifier.rs`) -- the row machinery,
3811    /// not storage, is ~99% of that query's time; the first (2-hop count)
3812    /// entry measured ~25x end-to-end on that suite.
3813    ///
3814    /// Deliberately conservative: returns `Ok(None)` (generic path) for
3815    /// ANY shape it doesn't fully recognize. What it accepts:
3816    /// - plan = `[Filter*] Expand([Filter*] Expand(leaf))` or
3817    ///   `[Filter*] Expand(leaf)`, every expansion single-typed (or
3818    ///   untyped) and directed (no `Either`), leaf free of any
3819    ///   expansion/`Seed` (evaluated via the generic stream);
3820    /// - filters drawn only from the shapes `build_match_plan`
3821    ///   synthesizes here: `HasLabel` on the hop nodes, and the
3822    ///   edge-isomorphism `Not(VarEq(r2, r1))` between the two hops
3823    ///   (honored in-loop by skipping `e2.edge_id == e1.edge_id`);
3824    /// - `WITH` = `Var(final-node)` plus any mix of `count(*)` and
3825    ///   `collect(<mid-node>.prop)` (2-hop only, non-DISTINCT), no
3826    ///   `*`/`WHERE`, ORDER BY only on the count column;
3827    /// - no carried bindings entering the clause.
3828    ///
3829    /// `collect()` skips null/absent values (real Cypher's rule), reads
3830    /// the property through the per-prop directory path, and memoizes it
3831    /// per mid-node. Group and in-group encounter order both follow
3832    /// traversal order, matching the generic grouping pass's
3833    /// first-encounter semantics for ORDER BY ties and collect contents.
3834    ///
3835    /// `HasLabel` checks use per-label node-id sets loaded once via
3836    /// `NODE_LABEL_INDEX` -- O(label size) setup instead of a per-candidate
3837    /// record read in the hot loop.
3838    fn try_fast_expand_expand_count(
3839        &self,
3840        txn: Txn,
3841        plan: &LogicalPlan,
3842        with: &Option<WithClause>,
3843        current_rows: &[BindingRow],
3844        // When this MATCH is the statement's final clause and the tail is
3845        // a plain (non-aggregating, non-DISTINCT) RETURN whose ORDER
3846        // BY/SKIP/LIMIT ride on the count column, the hint lets the loop
3847        // sort groups and keep only skip+limit of them BEFORE building
3848        // any rows -- the generic tail then re-sorts and slices that tiny
3849        // prefix exactly (same key, same tie order), so semantics are
3850        // unchanged while the 6k-groups-for-a-LIMIT-5 case stops
3851        // materializing 6k rows. Measured motivation: inception's
3852        // remaining ~40ms was almost entirely this tail.
3853        tail_hint: Option<(&ReturnExpr, SortDir, usize)>,
3854        guard: &ExecutionGuard<'_>,
3855    ) -> Result<Option<FastCountResult>, QueryError> {
3856        // -- clause-context checks --------------------------------------
3857        if current_rows.len() != 1 || !current_rows[0].is_empty() {
3858            return Ok(None);
3859        }
3860        let Some(with) = with else { return Ok(None) };
3861        if with.star || with.distinct || with.where_clause.is_some() || with.items.len() < 2 {
3862            return Ok(None);
3863        }
3864
3865        // -- plan shape: 1 or 2 Expand stages over a non-expanding leaf --
3866        fn peel<'p>(mut plan: &'p LogicalPlan, preds: &mut Vec<&'p Expr>) -> &'p LogicalPlan {
3867            while let LogicalPlan::Filter { input, predicate } = plan {
3868                push_conjunct_refs(predicate, preds);
3869                plan = input;
3870            }
3871            plan
3872        }
3873        fn push_conjunct_refs<'p>(expr: &'p Expr, out: &mut Vec<&'p Expr>) {
3874            if let Expr::And(l, r) = expr {
3875                push_conjunct_refs(l, out);
3876                push_conjunct_refs(r, out);
3877            } else {
3878                out.push(expr);
3879            }
3880        }
3881        struct Stage<'p> {
3882            from: &'p str,
3883            to: &'p str,
3884            rel_var: Option<&'p str>,
3885            label: Option<&'p str>,
3886            dir: Direction,
3887            preds: Vec<&'p Expr>,
3888        }
3889        // Collected outermost-first, reversed to innermost-first below.
3890        let mut stages: Vec<Stage<'_>> = Vec::new();
3891        let mut cursor = plan;
3892        let leaf = loop {
3893            let mut preds = Vec::new();
3894            match peel(cursor, &mut preds) {
3895                LogicalPlan::Expand {
3896                    input,
3897                    from_var,
3898                    to_var,
3899                    rel_var,
3900                    rel_labels,
3901                    direction,
3902                } if stages.len() < 2 => {
3903                    let (Some(dir), Some(label)) =
3904                        (fast_direction(*direction), fast_label(rel_labels))
3905                    else {
3906                        return Ok(None);
3907                    };
3908                    stages.push(Stage {
3909                        from: from_var,
3910                        to: to_var,
3911                        rel_var: rel_var.as_deref(),
3912                        label,
3913                        dir,
3914                        preds,
3915                    });
3916                    cursor = input;
3917                }
3918                _ => {
3919                    if stages.is_empty() || plan_contains_expansion(cursor) {
3920                        return Ok(None);
3921                    }
3922                    // The leaf keeps its own filter chain (`cursor`, not
3923                    // the peeled node): a start-node predicate the planner
3924                    // pushed down (`WHERE m.title = ...` without an index)
3925                    // is just part of leaf evaluation, which runs through
3926                    // the generic stream anyway.
3927                    break cursor;
3928                }
3929            }
3930        };
3931        stages.reverse(); // innermost (hop 1) first
3932        if stages.len() == 2 && stages[1].from != stages[0].to {
3933            return Ok(None);
3934        }
3935        let final_to = stages.last().expect("at least one stage").to;
3936        let origin = stages[0].from;
3937        let mid_var = (stages.len() == 2).then(|| stages[0].to);
3938
3939        // -- WITH-shape: Var(final) + {count(*) | collect(mid.prop)}* ----
3940        enum OutCol<'p> {
3941            Group,
3942            Count,
3943            Collect(&'p str), // mid-node property name
3944        }
3945        let mut cols: Vec<OutCol<'_>> = Vec::with_capacity(with.items.len());
3946        // The grouping key: either the chain's far end (collaborative
3947        // filtering) or its origin (matrix_review_counts groups by the
3948        // seed and counts its expansions).
3949        let mut group_seen = false;
3950        let mut group_by_origin = false;
3951        let mut count_seen = false;
3952        for item in &with.items {
3953            match &item.expr {
3954                ReturnExpr::Var(v) if v == final_to && !group_seen => {
3955                    group_seen = true;
3956                    cols.push(OutCol::Group);
3957                }
3958                ReturnExpr::Var(v) if v == origin && !group_seen => {
3959                    group_seen = true;
3960                    group_by_origin = true;
3961                    cols.push(OutCol::Group);
3962                }
3963                ReturnExpr::CountStar if !count_seen => {
3964                    count_seen = true;
3965                    cols.push(OutCol::Count);
3966                }
3967                ReturnExpr::Call {
3968                    name,
3969                    args,
3970                    distinct: false,
3971                } if name.eq_ignore_ascii_case("collect") => {
3972                    let [ReturnExpr::Prop(pa)] = args.as_slice() else {
3973                        return Ok(None);
3974                    };
3975                    let Some(mid) = mid_var else { return Ok(None) };
3976                    if pa.var != mid {
3977                        return Ok(None);
3978                    }
3979                    cols.push(OutCol::Collect(&pa.prop));
3980                }
3981                _ => return Ok(None),
3982            }
3983        }
3984        if !group_seen {
3985            return Ok(None);
3986        }
3987        let names: Vec<String> = with
3988            .items
3989            .iter()
3990            .enumerate()
3991            .map(with_item_output_name)
3992            .collect();
3993        let count_name = cols
3994            .iter()
3995            .position(|c| matches!(c, OutCol::Count))
3996            .map(|i| names[i].as_str());
3997        // ORDER BY: only "by the count column" (any direction) or absent.
3998        let mut pre_keep: Option<usize> = None;
3999        let count_sort: Option<SortDir> = match &with.order_by {
4000            None => {
4001                // No WITH-level ordering: the tail hint (final clause,
4002                // plain RETURN ordered by the count column) can take over.
4003                match tail_hint {
4004                    Some((key, dir, keep)) if with.skip.is_none() && with.limit.is_none() => {
4005                        let matches_count = match key {
4006                            ReturnExpr::Var(v) => count_name == Some(v.as_str()),
4007                            ReturnExpr::CountStar => count_seen,
4008                            _ => false,
4009                        };
4010                        if matches_count {
4011                            pre_keep = Some(keep);
4012                            Some(dir)
4013                        } else {
4014                            None
4015                        }
4016                    }
4017                    _ => None,
4018                }
4019            }
4020            Some(keys) => {
4021                let [(key, dir)] = keys.as_slice() else {
4022                    return Ok(None);
4023                };
4024                let matches_count = match key {
4025                    ReturnExpr::Var(v) => count_name == Some(v.as_str()),
4026                    ReturnExpr::CountStar => count_seen,
4027                    _ => false,
4028                };
4029                if !matches_count {
4030                    return Ok(None);
4031                }
4032                Some(*dir)
4033            }
4034        };
4035
4036        // -- predicate classification per stage --------------------------
4037        let mut stage_label_filters: Vec<Vec<&str>> = vec![Vec::new(); stages.len()];
4038        let mut isomorphism = false;
4039        for (i, stage) in stages.iter().enumerate() {
4040            for pred in &stage.preds {
4041                match pred {
4042                    Expr::HasLabel(v, l) if v == stage.to => stage_label_filters[i].push(l),
4043                    Expr::Not(inner) if i == 1 => {
4044                        match (&**inner, stages[0].rel_var, stage.rel_var) {
4045                            (Expr::VarEq(x, y), Some(r1), Some(r2))
4046                                if (x == r1 && y == r2) || (x == r2 && y == r1) =>
4047                            {
4048                                isomorphism = true;
4049                            }
4050                            _ => return Ok(None),
4051                        }
4052                    }
4053                    _ => return Ok(None),
4054                }
4055            }
4056        }
4057
4058        // -- resolve everything the loop needs ---------------------------
4059        let skip = self.resolve_skip_limit(txn, with.skip.as_ref(), "SKIP", guard)?;
4060        let limit = self.resolve_skip_limit(txn, with.limit.as_ref(), "LIMIT", guard)?;
4061        let label_set = |label: &str| -> Result<std::collections::HashSet<u64>, QueryError> {
4062            Ok(
4063                GraphStore::all_node_ids_limited_in_txn(txn, Some(label), usize::MAX)?
4064                    .into_iter()
4065                    .map(|n| n.0)
4066                    .collect(),
4067            )
4068        };
4069        let stage_sets: Vec<Vec<std::collections::HashSet<u64>>> = stage_label_filters
4070            .iter()
4071            .map(|labels| labels.iter().map(|l| label_set(l)).collect())
4072            .collect::<Result<_, _>>()?;
4073        // Collected properties: resolve names to interned ids once.
4074        let collect_prop_ids: Vec<Option<u32>> = cols
4075            .iter()
4076            .map(|c| match c {
4077                OutCol::Collect(prop) => self.prop_id_for(txn, prop),
4078                _ => Ok(None),
4079            })
4080            .collect::<Result<_, _>>()?;
4081
4082        // Seed nodes. For a filtered scan/seek leaf, enumerate candidate
4083        // ids directly and evaluate the leaf's predicates against ONE
4084        // reused row buffer -- the generic stream builds a fresh
4085        // `HashMap` row per candidate, which for an unindexed predicate
4086        // over a big label (matrix_review_counts: `title CONTAINS` over
4087        // 9k movies) was the query's remaining cost. Any leaf shape this
4088        // doesn't cover falls back to the generic stream.
4089        let mut seeds = Vec::new();
4090        let mut leaf_preds = Vec::new();
4091        let leaf_base = peel(leaf, &mut leaf_preds);
4092        let leaf_candidates: Option<Vec<NodeId>> = match leaf_base {
4093            LogicalPlan::AllNodesScan { var } if var == stages[0].from => Some(
4094                GraphStore::all_node_ids_limited_in_txn(txn, None, usize::MAX)?,
4095            ),
4096            LogicalPlan::NodeByLabelScan { var, label } if var == stages[0].from => Some(
4097                GraphStore::all_node_ids_limited_in_txn(txn, Some(label), usize::MAX)?,
4098            ),
4099            LogicalPlan::IndexSeek {
4100                var,
4101                label,
4102                prop,
4103                value: crate::ir::IndexSeekValue::Fixed(value),
4104            } if var == stages[0].from => {
4105                Some(GraphStore::lookup_by_index_in_txn(txn, label, prop, value)?)
4106            }
4107            _ => None,
4108        };
4109        match leaf_candidates {
4110            Some(candidates) => {
4111                // All-simple-predicate leaves (`var.prop <op> literal`,
4112                // matrix's `title CONTAINS ...`) evaluate through one
4113                // pre-opened NODES handle and the shared `compare` --
4114                // no per-candidate table open, no probe row, no
4115                // `eval_expr` dispatch. Anything else keeps the probe-row
4116                // route below.
4117                let simple: Option<Vec<(&PropAccess, CompareOp, &Literal)>> = leaf_preds
4118                    .iter()
4119                    .map(|pred| match pred {
4120                        Expr::Compare(pa, op, lit) if pa.var == stages[0].from => {
4121                            Some((pa, *op, lit))
4122                        }
4123                        _ => None,
4124                    })
4125                    .collect();
4126                if let Some(simple) = simple {
4127                    let pred_ids: Vec<Option<u32>> = simple
4128                        .iter()
4129                        .map(|(pa, _, _)| self.prop_id_for(txn, &pa.prop))
4130                        .collect::<Result<_, _>>()?;
4131                    let mut read_prop = GraphStore::node_prop_reader(txn)?;
4132                    'cand: for id in candidates {
4133                        guard.checkpoint()?;
4134                        for ((_, op, lit), prop_id) in simple.iter().zip(&pred_ids) {
4135                            let value = match prop_id {
4136                                Some(pid) => read_prop(id, *pid)?.flatten(),
4137                                None => None,
4138                            };
4139                            if compare(&value, *op, lit) != Some(true) {
4140                                continue 'cand;
4141                            }
4142                        }
4143                        seeds.push(id);
4144                    }
4145                } else {
4146                    let mut probe = BindingRow::new();
4147                    for id in candidates {
4148                        guard.checkpoint()?;
4149                        probe.insert(stages[0].from.to_string(), Binding::Node(id));
4150                        let mut pass = true;
4151                        for pred in &leaf_preds {
4152                            if self.eval_expr(txn, pred, &probe, guard)? != Some(true) {
4153                                pass = false;
4154                                break;
4155                            }
4156                        }
4157                        if pass {
4158                            seeds.push(id);
4159                        }
4160                    }
4161                }
4162            }
4163            None => {
4164                for row in self.eval_plan(txn, leaf, current_rows, guard)? {
4165                    match row.get(stages[0].from) {
4166                        Some(Binding::Node(id)) => seeds.push(*id),
4167                        _ => return Ok(None),
4168                    }
4169                }
4170            }
4171        }
4172
4173        // -- the tight loop ----------------------------------------------
4174        struct Group {
4175            count: i64,
4176            collects: Vec<Vec<Value>>,
4177        }
4178        let n_collects = cols
4179            .iter()
4180            .filter(|c| matches!(c, OutCol::Collect(_)))
4181            .count();
4182        let mut order: Vec<u64> = Vec::new();
4183        let mut groups: HashMap<u64, Group> = HashMap::new();
4184        // Per-mid-node property memo: the same mid node recurs across
4185        // seeds/edges and its collected property is stable within the
4186        // snapshot.
4187        let mut mid_prop_memo: HashMap<(u64, u32), Option<Value>> = HashMap::new();
4188        let mut mid_values: Vec<Option<Value>> = vec![None; n_collects];
4189        let one_hop = stages.len() == 1;
4190        for &s in &seeds {
4191            guard.checkpoint()?;
4192            for e1 in GraphStore::neighbors_in_txn(txn, s, stages[0].dir, stages[0].label)? {
4193                guard.relationship_expansion()?;
4194                if !stage_sets[0].iter().all(|set| set.contains(&e1.other.0)) {
4195                    continue;
4196                }
4197                if one_hop {
4198                    let key = if group_by_origin { s.0 } else { e1.other.0 };
4199                    let group = groups.entry(key).or_insert_with(|| {
4200                        order.push(key);
4201                        Group {
4202                            count: 0,
4203                            collects: vec![Vec::new(); n_collects],
4204                        }
4205                    });
4206                    group.count += 1;
4207                    continue;
4208                }
4209                // Resolve this mid node's collected properties once.
4210                let mut ci = 0usize;
4211                for (col, prop_id) in cols.iter().zip(&collect_prop_ids) {
4212                    if let OutCol::Collect(_) = col {
4213                        mid_values[ci] = match prop_id {
4214                            Some(pid) => mid_prop_memo
4215                                .entry((e1.other.0, *pid))
4216                                .or_insert_with(|| {
4217                                    GraphStore::get_node_prop_in_txn(txn, e1.other, *pid)
4218                                        .ok()
4219                                        .flatten()
4220                                        .flatten()
4221                                        .map(property_value_to_value)
4222                                })
4223                                .clone(),
4224                            None => None, // never-interned property: absent everywhere
4225                        };
4226                        ci += 1;
4227                    }
4228                }
4229                guard.checkpoint()?;
4230                for e2 in
4231                    GraphStore::neighbors_in_txn(txn, e1.other, stages[1].dir, stages[1].label)?
4232                {
4233                    guard.relationship_expansion()?;
4234                    if isomorphism && e2.edge_id == e1.edge_id {
4235                        continue;
4236                    }
4237                    if !stage_sets[1].iter().all(|set| set.contains(&e2.other.0)) {
4238                        continue;
4239                    }
4240                    let key = if group_by_origin { s.0 } else { e2.other.0 };
4241                    let group = groups.entry(key).or_insert_with(|| {
4242                        order.push(key);
4243                        Group {
4244                            count: 0,
4245                            collects: vec![Vec::new(); n_collects],
4246                        }
4247                    });
4248                    group.count += 1;
4249                    for (ci, value) in mid_values.iter().enumerate() {
4250                        // collect() skips nulls, real Cypher's rule.
4251                        if let Some(v) = value {
4252                            group.collects[ci].push(v.clone());
4253                        }
4254                    }
4255                }
4256            }
4257        }
4258
4259        // -- project, order, skip/limit ----------------------------------
4260        let mut grouped: Vec<(u64, Group)> = order
4261            .into_iter()
4262            .map(|id| {
4263                let group = groups.remove(&id).expect("group recorded in order");
4264                (id, group)
4265            })
4266            .collect();
4267        match count_sort {
4268            Some(SortDir::Asc) => grouped.sort_by_key(|(_, g)| g.count),
4269            Some(SortDir::Desc) => grouped.sort_by_key(|(_, g)| std::cmp::Reverse(g.count)),
4270            None => {}
4271        }
4272        if let Some(keep) = pre_keep {
4273            grouped.truncate(keep);
4274        }
4275        let skip_n = skip.unwrap_or(0).max(0) as usize;
4276        if skip_n > 0 {
4277            grouped.drain(0..skip_n.min(grouped.len()));
4278        }
4279        if let Some(limit) = limit {
4280            grouped.truncate(limit.max(0) as usize);
4281        }
4282        let rows: Vec<BindingRow> = grouped
4283            .into_iter()
4284            .map(|(id, group)| {
4285                let mut row = BindingRow::new();
4286                let mut collects = group.collects.into_iter();
4287                for (col, name) in cols.iter().zip(&names) {
4288                    let binding = match col {
4289                        OutCol::Group => Binding::Node(NodeId(id)),
4290                        OutCol::Count => Binding::Value(PropertyValue::Int(group.count)),
4291                        OutCol::Collect(_) => {
4292                            Binding::List(collects.next().expect("one list per collect column"))
4293                        }
4294                    };
4295                    row.insert(name.clone(), binding);
4296                }
4297                row
4298            })
4299            .collect();
4300        if std::env::var("MARSDB_FAST_DEBUG").is_ok() {
4301            eprintln!(
4302                "[fast-path FIRED] stages={} groups={}",
4303                stages.len(),
4304                rows.len()
4305            );
4306        }
4307        Ok(Some((rows, names.into_iter().collect())))
4308    }
4309
4310    fn stream_scan<'s>(
4311        &'s self,
4312        txn: Txn<'s>,
4313        var: &'s str,
4314        label: Option<&'s str>,
4315        seed: &'s [BindingRow],
4316        guard: &'s ExecutionGuard<'_>,
4317        row_limit: Option<usize>,
4318    ) -> RowStream<'s> {
4319        let mut initialized = false;
4320        let mut node_ids = Vec::new();
4321        let mut seed_index = 0usize;
4322        let mut node_index = 0usize;
4323        let mut done = false;
4324        let stream = std::iter::from_fn(move || {
4325            if done || seed.is_empty() {
4326                return None;
4327            }
4328            if !initialized {
4329                initialized = true;
4330                let budget_node_limit = guard.options.max_intermediate_rows.map(|max_rows| {
4331                    max_rows
4332                        .checked_div(seed.len())
4333                        .unwrap_or(0)
4334                        .saturating_add(1)
4335                });
4336                let storage_limit = match (row_limit, budget_node_limit) {
4337                    (Some(a), Some(b)) => Some(a.min(b)),
4338                    (Some(a), None) => Some(a),
4339                    (None, Some(b)) => Some(b),
4340                    (None, None) => None,
4341                };
4342                let storage_limit = storage_limit.unwrap_or(usize::MAX);
4343                match GraphStore::all_node_ids_limited_in_txn(txn, label, storage_limit) {
4344                    Ok(ids) => node_ids = ids,
4345                    Err(error) => {
4346                        done = true;
4347                        return Some(Err(error.into()));
4348                    }
4349                }
4350            }
4351            if node_ids.is_empty() || seed_index >= seed.len() {
4352                return None;
4353            }
4354            if let Err(error) = guard.checkpoint() {
4355                done = true;
4356                return Some(Err(error));
4357            }
4358            let mut row = seed[seed_index].clone();
4359            row.insert(var.to_string(), Binding::Node(node_ids[node_index]));
4360            node_index += 1;
4361            if node_index == node_ids.len() {
4362                node_index = 0;
4363                seed_index += 1;
4364            }
4365            Some(Ok(row))
4366        });
4367        Self::count_stream(Box::new(stream), guard)
4368    }
4369
4370    /// `LogicalPlan::IndexSeek`'s streaming operator -- same cross-join-
4371    /// against-`seed` shape as `stream_scan`, but the id list comes from
4372    /// one exact-match `PROPERTY_INDEX` lookup instead of a label scan.
4373    /// `row_limit` bounds the lookup itself the same way `stream_scan`'s
4374    /// does -- a non-unique index can still match far more nodes than a
4375    /// `LIMIT` needs, so the same "ask storage for at most the budget,
4376    /// not everything" reasoning applies, just against `PROPERTY_INDEX`
4377    /// instead of `NODE_LABEL_INDEX`.
4378    ///
4379    /// `spec.value` is either fixed for the whole seek (a literal, or a
4380    /// `$param` already resolved to one -- looked up once, reused across
4381    /// every seed row, same as before this `enum` existed) or row-
4382    /// dependent (`IndexSeekValue::RowExpr`, e.g. `row.field` from an
4383    /// enclosing `UNWIND`) -- re-evaluated and re-looked-up for each seed
4384    /// row, since a different row can mean a different lookup value. This
4385    /// is the fix for what was previously *always* a `NodeByLabelScan` +
4386    /// `Filter` for that shape (`planner::apply_index_seeks` only
4387    /// recognized a literal-valued equality, never a per-row one) -- an
4388    /// O(label size) scan repeated per incoming row, the exact pattern a
4389    /// bulk import's relationship-creation pass hits hardest.
4390    /// `IndexRangeSeek` evaluation: one bounded index scan, reused
4391    /// across every seed row (same cross-join shape as
4392    /// `stream_index_seek`'s `Fixed` arm). The residual `Filter` the
4393    /// planner keeps above this node applies the exact predicate; this
4394    /// stream only narrows candidates.
4395    #[allow(clippy::too_many_arguments)]
4396    /// `EdgeTypeScan` evaluation: a demand-driven sequential sweep of
4397    /// the whole `EDGES` table (chunked `EdgeScanCursor`, raw record
4398    /// bytes in hand), binding the full single-hop pattern per matching
4399    /// edge. Rejection order is cheapest-first: type id, then the
4400    /// pushed-down relationship predicate straight off the record bytes
4401    /// (no storage get), then endpoint label checks through the
4402    /// statement node cache. Matches accumulate so later seed rows
4403    /// replay them (same cross-join contract as every other leaf).
4404    fn stream_edge_type_scan<'s>(
4405        &'s self,
4406        txn: Txn<'s>,
4407        spec: EdgeTypeScanSpec<'s>,
4408        seed: &'s [BindingRow],
4409        guard: &'s ExecutionGuard<'_>,
4410    ) -> RowStream<'s> {
4411        const CHUNK: usize = 512;
4412        /// One-time per-scan resolutions, done lazily on first pull.
4413        struct ScanPrep {
4414            /// `None` = untyped hop (any edge).
4415            type_ids: Option<Vec<u32>>,
4416            prop_ids: HashMap<String, Option<u32>>,
4417        }
4418        let mut prepared: Option<ScanPrep> = None;
4419        let mut cursor = GraphStore::edge_scan_cursor();
4420        let mut matched: Vec<(u64, u64, u64)> = Vec::new(); // (edge, src, dst)
4421        let mut exhausted = false;
4422        let mut seed_index = 0usize;
4423        let mut match_index = 0usize;
4424        let mut done = false;
4425        let stream = std::iter::from_fn(move || {
4426            if done || seed.is_empty() {
4427                return None;
4428            }
4429            loop {
4430                if prepared.is_none() {
4431                    let type_ids = match resolve_type_ids(txn, spec.rel_types) {
4432                        Ok(ids) => ids,
4433                        Err(error) => {
4434                            done = true;
4435                            return Some(Err(error));
4436                        }
4437                    };
4438                    let mut prop_ids = HashMap::new();
4439                    if let Some(pred) = spec.rel_predicate {
4440                        if let Err(error) = self.collect_scan_prop_ids(txn, pred, &mut prop_ids) {
4441                            done = true;
4442                            return Some(Err(error));
4443                        }
4444                    }
4445                    prepared = Some(ScanPrep { type_ids, prop_ids });
4446                }
4447                let ScanPrep { type_ids, prop_ids } = prepared.as_ref().expect("set above");
4448                // An impossible type list (a name never interned) can
4449                // never match anything.
4450                if type_ids.as_ref().is_some_and(|ids| ids.is_empty()) {
4451                    return None;
4452                }
4453
4454                if !exhausted && match_index >= matched.len() && seed_index == 0 {
4455                    let chunk = match cursor.next_chunk(txn, CHUNK) {
4456                        Ok(c) => c,
4457                        Err(error) => {
4458                            done = true;
4459                            return Some(Err(error.into()));
4460                        }
4461                    };
4462                    if chunk.len() < CHUNK {
4463                        exhausted = true;
4464                    }
4465                    for (id, bytes) in chunk {
4466                        if let Err(error) = guard.checkpoint() {
4467                            done = true;
4468                            return Some(Err(error));
4469                        }
4470                        let (label_id, src, dst) = match GraphStore::edge_record_header(&bytes) {
4471                            Ok(h) => h,
4472                            Err(error) => {
4473                                done = true;
4474                                return Some(Err(error.into()));
4475                            }
4476                        };
4477                        if type_ids
4478                            .as_ref()
4479                            .is_some_and(|ids| !ids.contains(&label_id))
4480                        {
4481                            continue;
4482                        }
4483                        if let Some(pred) = spec.rel_predicate {
4484                            match eval_scan_predicate(&bytes, pred, prop_ids) {
4485                                Ok(true) => {}
4486                                Ok(false) => continue,
4487                                Err(error) => {
4488                                    done = true;
4489                                    return Some(Err(error));
4490                                }
4491                            }
4492                        }
4493                        match self.scan_endpoints_pass(
4494                            txn,
4495                            src,
4496                            dst,
4497                            spec.src_label,
4498                            spec.dst_label,
4499                        ) {
4500                            Ok(true) => matched.push((id, src, dst)),
4501                            Ok(false) => {}
4502                            Err(error) => {
4503                                done = true;
4504                                return Some(Err(error));
4505                            }
4506                        }
4507                    }
4508                    continue;
4509                }
4510                if match_index >= matched.len() {
4511                    if exhausted {
4512                        seed_index += 1;
4513                        match_index = 0;
4514                        if seed_index >= seed.len() || matched.is_empty() {
4515                            return None;
4516                        }
4517                    } else {
4518                        continue;
4519                    }
4520                }
4521                if let Err(error) = guard.checkpoint() {
4522                    done = true;
4523                    return Some(Err(error));
4524                }
4525                let (edge, src, dst) = matched[match_index];
4526                match_index += 1;
4527                let mut row = seed[seed_index].clone();
4528                row.insert(spec.src_var.to_string(), Binding::Node(NodeId(src)));
4529                row.insert(spec.rel_var.to_string(), Binding::Edge(EdgeId(edge)));
4530                row.insert(spec.dst_var.to_string(), Binding::Node(NodeId(dst)));
4531                return Some(Ok(row));
4532            }
4533        });
4534        Self::count_stream(Box::new(stream), guard)
4535    }
4536
4537    /// Both endpoints exist (swept-edge invariant; a miss means
4538    /// corruption and is treated as non-match rather than a panic) and
4539    /// carry the required labels. Node-cache-backed: one decode per
4540    /// distinct node per statement.
4541    fn scan_endpoints_pass(
4542        &self,
4543        txn: Txn,
4544        src: u64,
4545        dst: u64,
4546        src_label: Option<&str>,
4547        dst_label: Option<&str>,
4548    ) -> Result<bool, QueryError> {
4549        for (id, wanted) in [(src, src_label), (dst, dst_label)] {
4550            let Some(label) = wanted else { continue };
4551            let Some(node) = self.get_node_cached(txn, NodeId(id))? else {
4552                return Ok(false);
4553            };
4554            if !node.labels.iter().any(|l| l == label) {
4555                return Ok(false);
4556            }
4557        }
4558        Ok(true)
4559    }
4560
4561    /// Interned ids for every prop the scan predicate references --
4562    /// resolved once per scan through the statement memo. A name never
4563    /// interned maps to `None` (absent on every record by construction).
4564    fn collect_scan_prop_ids(
4565        &self,
4566        txn: Txn,
4567        pred: &Expr,
4568        out: &mut HashMap<String, Option<u32>>,
4569    ) -> Result<(), QueryError> {
4570        match pred {
4571            Expr::And(l, r) => {
4572                self.collect_scan_prop_ids(txn, l, out)?;
4573                self.collect_scan_prop_ids(txn, r, out)?;
4574            }
4575            Expr::Not(inner) => self.collect_scan_prop_ids(txn, inner, out)?,
4576            Expr::Compare(pa, _, _) | Expr::IsNull(pa) => {
4577                if !out.contains_key(&pa.prop) {
4578                    let id = self.prop_id_for(txn, &pa.prop)?;
4579                    out.insert(pa.prop.clone(), id);
4580                }
4581            }
4582            other => {
4583                return Err(QueryError::Semantic(format!(
4584                    "internal: non-scan-evaluable predicate reached EdgeTypeScan: {other:?}"
4585                )))
4586            }
4587        }
4588        Ok(())
4589    }
4590
4591    /// `IndexRangeSeek` evaluation: a demand-driven bounded index scan
4592    /// (chunked refills through `IndexRangeCursor`, O(log n) re-seek per
4593    /// refill), cross-joined with every seed row -- same join shape as
4594    /// `stream_index_seek`'s `Fixed` arm, but the ids are pulled as
4595    /// consumed instead of collected up front, so a `LIMIT`ed consumer
4596    /// that stops early never pays for the rest of the range. The
4597    /// residual `Filter` the planner keeps above this node applies the
4598    /// exact predicate; this stream only narrows candidates.
4599    ///
4600    /// Multi-seed note: the chunk buffer grows to the full match set
4601    /// only when several seed rows each need the whole range (the
4602    /// cross-join semantics require it); the single-seed case -- every
4603    /// top-level `MATCH (n:L) WHERE n.p > x` -- stays incremental.
4604    #[allow(clippy::too_many_arguments)]
4605    fn stream_index_range_seek<'s>(
4606        &'s self,
4607        txn: Txn<'s>,
4608        var: &'s str,
4609        label: &'s str,
4610        prop: &'s str,
4611        lo: &'s Option<(PropertyValue, bool)>,
4612        hi: &'s Option<(PropertyValue, bool)>,
4613        seed: &'s [BindingRow],
4614        guard: &'s ExecutionGuard<'_>,
4615    ) -> RowStream<'s> {
4616        const CHUNK: usize = 512;
4617        let mut cursor: Option<Option<marsdb_graph::IndexRangeCursor>> = None;
4618        let mut ids: Vec<NodeId> = Vec::new();
4619        let mut exhausted = false;
4620        let mut seed_index = 0usize;
4621        let mut node_index = 0usize;
4622        let mut done = false;
4623        let stream = std::iter::from_fn(move || {
4624            if done || seed.is_empty() {
4625                return None;
4626            }
4627            loop {
4628                // Refill when the consumer has caught up with what's
4629                // fetched (only the first seed row drives fetching; later
4630                // seed rows replay the accumulated ids).
4631                if !exhausted && node_index >= ids.len() && seed_index == 0 {
4632                    let cur = match &mut cursor {
4633                        Some(c) => c,
4634                        None => {
4635                            let created = GraphStore::index_range_cursor_in_txn(
4636                                txn,
4637                                label,
4638                                prop,
4639                                lo.as_ref().map(|(v, incl)| (v, *incl)),
4640                                hi.as_ref().map(|(v, incl)| (v, *incl)),
4641                            );
4642                            match created {
4643                                Ok(c) => cursor.insert(c),
4644                                Err(error) => {
4645                                    done = true;
4646                                    return Some(Err(error.into()));
4647                                }
4648                            }
4649                        }
4650                    };
4651                    match cur {
4652                        None => exhausted = true,
4653                        Some(c) => match c.next_chunk(txn, CHUNK) {
4654                            Ok(chunk) => {
4655                                if chunk.len() < CHUNK {
4656                                    exhausted = true;
4657                                }
4658                                ids.extend(chunk);
4659                            }
4660                            Err(error) => {
4661                                done = true;
4662                                return Some(Err(error.into()));
4663                            }
4664                        },
4665                    }
4666                }
4667                if node_index >= ids.len() {
4668                    if exhausted {
4669                        seed_index += 1;
4670                        node_index = 0;
4671                        if seed_index >= seed.len() || ids.is_empty() {
4672                            return None;
4673                        }
4674                    } else {
4675                        continue;
4676                    }
4677                }
4678                if let Err(error) = guard.checkpoint() {
4679                    done = true;
4680                    return Some(Err(error));
4681                }
4682                let mut row = seed[seed_index].clone();
4683                row.insert(var.to_string(), Binding::Node(ids[node_index]));
4684                node_index += 1;
4685                return Some(Ok(row));
4686            }
4687        });
4688        Self::count_stream(Box::new(stream), guard)
4689    }
4690
4691    fn stream_index_seek<'s>(
4692        &'s self,
4693        txn: Txn<'s>,
4694        spec: IndexSeekSpec<'s>,
4695        seed: &'s [BindingRow],
4696        guard: &'s ExecutionGuard<'_>,
4697        row_limit: Option<usize>,
4698    ) -> RowStream<'s> {
4699        let budget_node_limit = guard.options.max_intermediate_rows.map(|max_rows| {
4700            max_rows
4701                .checked_div(seed.len().max(1))
4702                .unwrap_or(0)
4703                .saturating_add(1)
4704        });
4705        let storage_limit = match (row_limit, budget_node_limit) {
4706            (Some(a), Some(b)) => Some(a.min(b)),
4707            (Some(a), None) => Some(a),
4708            (None, Some(b)) => Some(b),
4709            (None, None) => None,
4710        };
4711        let lookup = move |value: &PropertyValue| -> Result<Vec<NodeId>, QueryError> {
4712            match storage_limit {
4713                Some(limit) => GraphStore::lookup_by_index_limited_in_txn(
4714                    txn, spec.label, spec.prop, value, limit,
4715                )
4716                .map_err(Into::into),
4717                None => GraphStore::lookup_by_index_in_txn(txn, spec.label, spec.prop, value)
4718                    .map_err(Into::into),
4719            }
4720        };
4721        match spec.value {
4722            // One lookup, reused across every seed row -- identical shape
4723            // to `stream_scan`'s own cross join, and to this function
4724            // before `IndexSeekValue` existed.
4725            IndexSeekValue::Fixed(value) => {
4726                let mut node_ids: Option<Vec<NodeId>> = None;
4727                let mut seed_index = 0usize;
4728                let mut node_index = 0usize;
4729                let mut done = false;
4730                let stream = std::iter::from_fn(move || {
4731                    if done || seed.is_empty() {
4732                        return None;
4733                    }
4734                    let ids = match &node_ids {
4735                        Some(ids) => ids,
4736                        None => match lookup(value) {
4737                            Ok(ids) => node_ids.insert(ids),
4738                            Err(error) => {
4739                                done = true;
4740                                return Some(Err(error));
4741                            }
4742                        },
4743                    };
4744                    if ids.is_empty() || seed_index >= seed.len() {
4745                        return None;
4746                    }
4747                    if let Err(error) = guard.checkpoint() {
4748                        done = true;
4749                        return Some(Err(error));
4750                    }
4751                    let mut row = seed[seed_index].clone();
4752                    row.insert(spec.var.to_string(), Binding::Node(ids[node_index]));
4753                    node_index += 1;
4754                    if node_index == ids.len() {
4755                        node_index = 0;
4756                        seed_index += 1;
4757                    }
4758                    Some(Ok(row))
4759                });
4760                Self::count_stream(Box::new(stream), guard)
4761            }
4762            // A fresh lookup per seed row -- `expr` (e.g. `row.field` from
4763            // an enclosing `UNWIND`) can evaluate to a different value for
4764            // each one, so last row's `node_ids` can't be reused for the
4765            // next.
4766            IndexSeekValue::RowExpr(expr) => {
4767                let mut node_ids: Vec<NodeId> = Vec::new();
4768                let mut seed_index = 0usize;
4769                let mut node_index = 0usize;
4770                let mut done = false;
4771                let stream = std::iter::from_fn(move || loop {
4772                    if done || seed_index >= seed.len() {
4773                        return None;
4774                    }
4775                    if node_index == 0 {
4776                        let evaluated =
4777                            match self.eval_return_expr(txn, expr, &seed[seed_index], guard) {
4778                                Ok(v) => v,
4779                                Err(error) => {
4780                                    done = true;
4781                                    return Some(Err(error));
4782                                }
4783                            };
4784                        let value = value_to_property_value(&evaluated);
4785                        // Real Cypher's three-valued logic: comparing
4786                        // against `null` is "unknown", not "find nodes
4787                        // whose stored value happens to be Null" -- this
4788                        // row contributes zero rows, same as the Filter
4789                        // fallback this replaces would reject it outright.
4790                        if matches!(value, PropertyValue::Null) {
4791                            seed_index += 1;
4792                            continue;
4793                        }
4794                        node_ids = match lookup(&value) {
4795                            Ok(ids) => ids,
4796                            Err(error) => {
4797                                done = true;
4798                                return Some(Err(error));
4799                            }
4800                        };
4801                        if node_ids.is_empty() {
4802                            seed_index += 1;
4803                            continue;
4804                        }
4805                    }
4806                    if let Err(error) = guard.checkpoint() {
4807                        done = true;
4808                        return Some(Err(error));
4809                    }
4810                    let mut row = seed[seed_index].clone();
4811                    row.insert(spec.var.to_string(), Binding::Node(node_ids[node_index]));
4812                    node_index += 1;
4813                    if node_index == node_ids.len() {
4814                        node_index = 0;
4815                        seed_index += 1;
4816                    }
4817                    return Some(Ok(row));
4818                });
4819                Self::count_stream(Box::new(stream), guard)
4820            }
4821        }
4822    }
4823
4824    fn expand_variable_row(
4825        &self,
4826        txn: Txn,
4827        row: BindingRow,
4828        spec: VarExpandSpec<'_>,
4829        guard: &ExecutionGuard<'_>,
4830    ) -> Result<Vec<BindingRow>, QueryError> {
4831        let start_id = match row.get(spec.from_var) {
4832            Some(Binding::Node(id)) => *id,
4833            Some(Binding::Value(PropertyValue::Null)) => return Ok(Vec::new()),
4834            _ => return Err(QueryError::UnboundVariable(spec.from_var.to_string())),
4835        };
4836        let mut out = Vec::new();
4837        if spec.min_hops == 0 {
4838            let mut new_row = row.clone();
4839            new_row.insert(spec.to_var.to_string(), Binding::Node(start_id));
4840            if let Some(path_segment_var) = spec.path_segment_var {
4841                new_row.insert(path_segment_var.to_string(), Binding::Path(Vec::new()));
4842            }
4843            if let Some(rel_list_var) = spec.rel_list_var {
4844                new_row.insert(rel_list_var.to_string(), Binding::List(Vec::new()));
4845            }
4846            new_row.insert(spec.exclude_edge_var.to_string(), Binding::Path(Vec::new()));
4847            out.push(new_row);
4848        }
4849        // `[:TYPE* {year: 1988}]` -- evaluated once here (constant across
4850        // the whole BFS, not per-candidate; the values can reference this
4851        // row's own already-bound variables, same as a fixed hop's inline
4852        // props already can) and checked against each candidate edge's
4853        // own stored properties during expansion below (TCK's Match4
4854        // `[5]`).
4855        let rel_props = spec
4856            .rel_props
4857            .iter()
4858            .map(|(key, expr)| {
4859                let value = self.eval_return_expr(txn, expr, &row, guard)?;
4860                Ok::<_, QueryError>((key.as_str(), value_to_property_value(&value)))
4861            })
4862            .collect::<Result<Vec<_>, _>>()?;
4863        let unbounded = spec.max_hops.is_none();
4864        let effective_max = spec.max_hops.unwrap_or(VAR_EXPAND_DEPTH_CAP);
4865        // Real Cypher's edge-isomorphism rule (no relationship repeated
4866        // within one MATCH pattern) applies across the *whole* pattern, not
4867        // just within this hop's own BFS -- seed the excluded set with
4868        // whatever edges earlier fixed hops of this same pattern already
4869        // bound, so this traversal can't walk back over one of them (see
4870        // `LogicalPlan::VarExpand`'s docs; found via TCK's Match5 `[27]`).
4871        // Complementary direction: an *earlier variable-length* hop's own
4872        // traversed-edge set (deposited under its own `exclude_edge_var`,
4873        // see `LogicalPlan::VarExpand`'s docs) -- union every such row's
4874        // `Binding::Path` edge ids in too (TCK's Match4 `[7]`).
4875        let seed_used_edges: HashSet<EdgeId> = spec
4876            .exclude_edge_vars
4877            .iter()
4878            .filter_map(|v| match row.get(v) {
4879                Some(Binding::Edge(id)) => Some(*id),
4880                _ => None,
4881            })
4882            .chain(spec.exclude_edge_sets.iter().flat_map(|v| {
4883                match row.get(v) {
4884                    Some(Binding::Path(segment)) => segment
4885                        .iter()
4886                        .filter_map(|p| match p {
4887                            PathBinding::Edge(id) => Some(*id),
4888                            PathBinding::Node(_) => None,
4889                        })
4890                        .collect::<Vec<_>>(),
4891                    _ => Vec::new(),
4892                }
4893            }))
4894            .collect();
4895        // The ordered `Edge, Node, Edge, Node, ...` sequence built up so
4896        // far, alongside the existing `used_edges` isomorphism set --
4897        // only actually consulted when `path_segment_var` is set (named-
4898        // path capture over this hop, see `LogicalPlan::VarExpand`'s own
4899        // docs), but always threaded through the BFS regardless (a plain
4900        // `Vec`, cheap to carry and clone even when unused).
4901        let mut frontier = vec![(start_id, seed_used_edges, Vec::<PathBinding>::new())];
4902        let mut depth = 0u32;
4903        while depth < effective_max && !frontier.is_empty() {
4904            depth += 1;
4905            let mut next_frontier = Vec::new();
4906            for (node, used_edges, segment) in frontier {
4907                for entry in neighbors_for_direction(txn, node, spec.direction, spec.rel_labels)? {
4908                    guard.relationship_expansion()?;
4909                    if used_edges.contains(&entry.edge_id) {
4910                        continue;
4911                    }
4912                    if !rel_props.is_empty() {
4913                        let edge = deleted_entity_access(GraphStore::get_edge_in_txn(
4914                            txn,
4915                            entry.edge_id,
4916                        )?)?;
4917                        let matches = rel_props
4918                            .iter()
4919                            .all(|(key, expected)| edge.props.get(*key) == Some(expected));
4920                        if !matches {
4921                            continue;
4922                        }
4923                    }
4924                    let mut next_used_edges = used_edges.clone();
4925                    next_used_edges.insert(entry.edge_id);
4926                    let mut next_segment = segment.clone();
4927                    next_segment.push(PathBinding::Edge(entry.edge_id));
4928                    next_segment.push(PathBinding::Node(entry.other));
4929                    next_frontier.push((entry.other, next_used_edges, next_segment.clone()));
4930                    guard.check_intermediate_rows(next_frontier.len())?;
4931                    if depth >= spec.min_hops {
4932                        let mut new_row = row.clone();
4933                        new_row.insert(spec.to_var.to_string(), Binding::Node(entry.other));
4934                        if let Some(path_segment_var) = spec.path_segment_var {
4935                            new_row.insert(
4936                                path_segment_var.to_string(),
4937                                Binding::Path(next_segment.clone()),
4938                            );
4939                        }
4940                        if let Some(rel_list_var) = spec.rel_list_var {
4941                            let edges = segment_edges_to_list(txn, &next_segment)?;
4942                            new_row.insert(rel_list_var.to_string(), edges);
4943                        }
4944                        new_row.insert(
4945                            spec.exclude_edge_var.to_string(),
4946                            Binding::Path(next_segment.clone()),
4947                        );
4948                        out.push(new_row);
4949                        guard.check_intermediate_rows(out.len())?;
4950                    }
4951                }
4952            }
4953            frontier = next_frontier;
4954            if depth == effective_max && unbounded && !frontier.is_empty() {
4955                return Err(QueryError::ResourceLimit(format!(
4956                    "variable-length traversal exceeded the safety depth cap ({VAR_EXPAND_DEPTH_CAP} \
4957                     hops) — likely a cyclic graph or unexpectedly large fanout; narrow the pattern or \
4958                     add an explicit upper bound (e.g. *0..10)"
4959                )));
4960            }
4961        }
4962        Ok(out)
4963    }
4964
4965    /// `LogicalPlan::MatchRelList`'s own docs -- deterministic, no search:
4966    /// `spec.rel_list_var`'s edges are already concrete, so there's
4967    /// exactly one possible walk to check, starting from `spec.from_var`'s
4968    /// already-bound node. Returns `Ok(None)` (row dropped, not an error)
4969    /// for every "doesn't match" case -- wrong hop count, a broken chain,
4970    /// an edge whose label isn't in `spec.rel_labels` -- same "no match
4971    /// survives" convention `Expand`/`VarExpand` already use for a filter
4972    /// that simply excludes a row.
4973    fn match_bound_rel_list_row(
4974        &self,
4975        row: BindingRow,
4976        spec: MatchRelListSpec<'_>,
4977    ) -> Result<Option<BindingRow>, QueryError> {
4978        let start_id = match row.get(spec.from_var) {
4979            Some(Binding::Node(id)) => *id,
4980            Some(Binding::Value(PropertyValue::Null)) => return Ok(None),
4981            _ => return Err(QueryError::UnboundVariable(spec.from_var.to_string())),
4982        };
4983        let edges: Vec<&Edge> = match row.get(spec.rel_list_var) {
4984            Some(Binding::List(items)) => items
4985                .iter()
4986                .map(|v| match v {
4987                    Value::Edge(e) => Ok(e),
4988                    other => Err(QueryError::Type(format!(
4989                        "'{}' must be a list of relationships, found {other:?} in it",
4990                        spec.rel_list_var
4991                    ))),
4992                })
4993                .collect::<Result<_, _>>()?,
4994            Some(Binding::Value(PropertyValue::Null)) => return Ok(None),
4995            _ => return Err(QueryError::UnboundVariable(spec.rel_list_var.to_string())),
4996        };
4997        let hops = edges.len() as u32;
4998        if hops < spec.min_hops || spec.max_hops.is_some_and(|max| hops > max) {
4999            return Ok(None);
5000        }
5001        if !spec.rel_labels.is_empty() && edges.iter().any(|e| !spec.rel_labels.contains(&e.label))
5002        {
5003            return Ok(None);
5004        }
5005        let mut current = start_id;
5006        for edge in &edges {
5007            let next = match spec.direction {
5008                ExpandDirection::Out if edge.src == current => edge.dst,
5009                ExpandDirection::In if edge.dst == current => edge.src,
5010                ExpandDirection::Either if edge.src == current => edge.dst,
5011                ExpandDirection::Either if edge.dst == current => edge.src,
5012                _ => return Ok(None),
5013            };
5014            current = next;
5015        }
5016        let mut new_row = row.clone();
5017        new_row.insert(spec.to_var.to_string(), Binding::Node(current));
5018        Ok(Some(new_row))
5019    }
5020
5021    /// `Option<bool>` — see `eval_with_expr`'s docs, same reasoning.
5022    /// `HasLabel`/`VarEq` never produce "unknown" (they operate on real
5023    /// bound node/edge identity, not a possibly-null property), so they
5024    /// always return `Some`.
5025    fn eval_expr(
5026        &self,
5027        txn: Txn,
5028        expr: &Expr,
5029        row: &BindingRow,
5030        guard: &ExecutionGuard<'_>,
5031    ) -> Result<Option<bool>, QueryError> {
5032        Ok(match expr {
5033            Expr::And(l, r) => and3(
5034                self.eval_expr(txn, l, row, guard)?,
5035                self.eval_expr(txn, r, row, guard)?,
5036            ),
5037            Expr::Or(l, r) => or3(
5038                self.eval_expr(txn, l, row, guard)?,
5039                self.eval_expr(txn, r, row, guard)?,
5040            ),
5041            Expr::Not(e) => self.eval_expr(txn, e, row, guard)?.map(|b| !b),
5042            Expr::Compare(pa, op, lit) => {
5043                let prop_value = self.lookup_prop(txn, pa, row)?;
5044                compare(&prop_value, *op, lit)
5045            }
5046            Expr::PropCompare(left, op, right) => {
5047                let a = self.lookup_prop(txn, left, row)?;
5048                let b = self.lookup_prop(txn, right, row)?;
5049                compare_property_pair_opt(&a, *op, &b)
5050            }
5051            // Always definite -- that's the whole point of IS NULL, so
5052            // this is the one `Expr` leaf that's always `Some`, same as
5053            // `HasLabel`/`VarEq` below.
5054            Expr::IsNull(pa) => Some(matches!(
5055                self.lookup_prop(txn, pa, row)?,
5056                None | Some(PropertyValue::Null)
5057            )),
5058            Expr::HasLabel(var, label) => {
5059                let binding = row
5060                    .get(var)
5061                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
5062                let Binding::Node(id) = binding else {
5063                    return Err(QueryError::UnboundVariable(var.clone()));
5064                };
5065                let node = self.get_node_cached(txn, *id)?;
5066                Some(node.is_some_and(|n| n.labels.iter().any(|l| l == label)))
5067            }
5068            Expr::VarEq(a, b) => {
5069                let ba = row
5070                    .get(a)
5071                    .ok_or_else(|| QueryError::UnboundVariable(a.clone()))?;
5072                let bb = row
5073                    .get(b)
5074                    .ok_or_else(|| QueryError::UnboundVariable(b.clone()))?;
5075                Some(match (ba, bb) {
5076                    (Binding::Node(x), Binding::Node(y)) => x == y,
5077                    (Binding::Edge(x), Binding::Edge(y)) => x == y,
5078                    // A null-padded `Binding::Value` (from an earlier
5079                    // OPTIONAL MATCH that didn't match) can't equal a
5080                    // real node/edge, and comparing across binding kinds
5081                    // (a node vs an edge) is never meaningful here — the
5082                    // planner only ever synthesizes VarEq between two
5083                    // occurrences of the same pattern variable, which are
5084                    // always the same kind when both are real.
5085                    _ => false,
5086                })
5087            }
5088            Expr::GeneralCompare(lhs, op, rhs) => {
5089                let lv = self.eval_return_expr(txn, lhs, row, guard)?;
5090                let rv = self.eval_return_expr(txn, rhs, row, guard)?;
5091                compare_values(&lv, *op, &rv)
5092            }
5093            Expr::GeneralIsNull(e) => Some(matches!(
5094                self.eval_return_expr(txn, e, row, guard)?,
5095                Value::Null
5096            )),
5097            Expr::GeneralBare(e) => self.eval_return_expr_bool3(txn, e, row, guard)?,
5098            // `WHERE (n)-[:REL]->()` etc (TCK's Pattern1) -- existential:
5099            // true iff at least one real match of `pattern` exists, with
5100            // every already-bound named endpoint (`n`, and `m` in `(n)-->
5101            // (m)` when `m` is also bound by an earlier MATCH) held fixed
5102            // to this row's own binding rather than searched freely.
5103            // `semantic::bind_pattern_predicate` already rejected any
5104            // named endpoint that ISN'T already bound (real Cypher's
5105            // UndefinedVariable), so every named var here is safe to seed.
5106            // Reuses the exact same `build_match_plan` "already-bound var
5107            // -> Seed, not a fresh scan" mechanism `eval_merge`'s own
5108            // "try as an ordinary MATCH first" half already relies on --
5109            // for a one-hop pattern this is a real connected-subgraph
5110            // search (Expand + Filter), not an isolated per-node check.
5111            // `Some(1)`-limited: existence is all that's needed, so
5112            // there's no reason to enumerate every match.
5113            Expr::Pattern(pattern) => {
5114                Some(self.eval_pattern_predicate_exists(txn, pattern, row, guard)?)
5115            }
5116            // `exists { (n)-->(m) WHERE ... }` (TCK's ExistentialSubquery1,
5117            // the "simple" form) -- same existential search as `Pattern`
5118            // above, just with its own inline `where?` threaded straight
5119            // into `build_match_plan`, same as an ordinary `MATCH ...
5120            // WHERE ...` (not evaluated as a separate post-filter step).
5121            Expr::Exists {
5122                pattern,
5123                where_clause,
5124            } => {
5125                let carried_vars: HashSet<String> = row.keys().cloned().collect();
5126                let wc: Option<Expr> = where_clause.as_deref().cloned();
5127                let plan = apply_index_seeks(build_match_plan(pattern, &wc, &carried_vars)?, txn)?;
5128                let found = self.eval_plan_with_limit(
5129                    txn,
5130                    &plan,
5131                    std::slice::from_ref(row),
5132                    guard,
5133                    Some(1),
5134                )?;
5135                Some(!found.is_empty())
5136            }
5137            // `exists { MATCH ... RETURN ... }` (TCK's
5138            // ExistentialSubquery2/3, the "full" form) -- runs the nested
5139            // statement correlated against `row` (`execute_match_seeded`)
5140            // and checks whether it produced at least one output row.
5141            Expr::ExistsSubquery(stmt) => Some(self.eval_exists_subquery(txn, stmt, row, guard)?),
5142            // See `Expr::EdgeNotInSet`'s own docs -- `edge_var` is always
5143            // a real `Binding::Edge` (a fixed hop's own filter var, the
5144            // only thing this gets generated for) and `edge_set_var` is
5145            // always the `Binding::Path` `expand_variable_row` deposits
5146            // for *every* variable-length hop, unconditionally (see
5147            // `LogicalPlan::VarExpand::exclude_edge_var`'s own docs) --
5148            // never anything else, so there's no null/wrong-kind case to
5149            // handle here the way `VarEq` above has to.
5150            Expr::EdgeNotInSet {
5151                edge_var,
5152                edge_set_var,
5153            } => {
5154                let Some(Binding::Edge(edge_id)) = row.get(edge_var) else {
5155                    return Err(QueryError::UnboundVariable(edge_var.clone()));
5156                };
5157                let Some(Binding::Path(segment)) = row.get(edge_set_var) else {
5158                    return Err(QueryError::UnboundVariable(edge_set_var.clone()));
5159                };
5160                Some(
5161                    !segment
5162                        .iter()
5163                        .any(|elem| matches!(elem, PathBinding::Edge(id) if id == edge_id)),
5164                )
5165            }
5166        })
5167    }
5168
5169    /// Prop name -> interned id, memoized per statement for read-only
5170    /// statements only -- see `prop_id_memo`'s docs for why write
5171    /// statements bypass the memo (mid-statement interning would make a
5172    /// cached `None` stale within the same statement).
5173    fn prop_id_for(&self, txn: Txn, name: &str) -> Result<Option<u32>, QueryError> {
5174        if let Some(cached) = self.prop_id_memo.borrow().get(name) {
5175            return Ok(*cached);
5176        }
5177        let id = GraphStore::lookup_prop_id_in_txn(txn, name)?;
5178        // A name -> Some(id) interning is immutable once made, so a hit
5179        // is safe to memoize in any statement. A `None` ("never
5180        // interned") can go stale *within a write statement* -- a later
5181        // `CREATE`/`SET` can intern that very name -- so `None` is only
5182        // memoized where nothing can intern: a read-only statement.
5183        if id.is_some() || self.read_only_stmt.get() {
5184            self.prop_id_memo.borrow_mut().insert(name.to_string(), id);
5185        }
5186        Ok(id)
5187    }
5188
5189    fn lookup_prop(
5190        &self,
5191        txn: Txn,
5192        pa: &PropAccess,
5193        row: &BindingRow,
5194    ) -> Result<Option<PropertyValue>, QueryError> {
5195        let binding = row
5196            .get(&pa.var)
5197            .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
5198        match binding {
5199            // A missing *property key* on an existing node/edge is a real,
5200            // legal "absent" (-> null downstream) -- but a missing
5201            // *node/edge record* means it was deleted earlier in this same
5202            // statement (`deleted_entity_access`'s docs), which is a real
5203            // error (`MATCH (n) DELETE n RETURN n.num` -- TCK's Return2
5204            // scenario [15]), not a silent null. These are two different
5205            // kinds of "missing" and must not be collapsed into one.
5206            //
5207            // Per-property read path (v2 step 1b): a node already
5208            // materialized in this statement's cache answers from the map;
5209            // otherwise this reads ONE directory entry from the stored
5210            // record -- no full decode, no name resolution, no cache
5211            // population (repeat per-prop reads are ~a point lookup each,
5212            // cheaper than materializing a whole record to answer one of
5213            // them). The nested Option from `get_node_prop_in_txn`
5214            // preserves the deleted-vs-absent split above.
5215            Binding::Node(id) => {
5216                // Safe for write statements too: every node-mutating
5217                // site evicts (`uncache_node`), so a surviving cache
5218                // entry is current by construction.
5219                if let Some(cached) = self.node_cache.borrow().get(id) {
5220                    return Ok(cached.props.get(&pa.prop).cloned());
5221                }
5222                match self.prop_id_for(txn, &pa.prop)? {
5223                    Some(prop_id) => Ok(deleted_entity_access(GraphStore::get_node_prop_in_txn(
5224                        txn, *id, prop_id,
5225                    )?)?),
5226                    // Name never interned anywhere: absent on every record
5227                    // by construction -- but a deleted node must still
5228                    // error, so existence is checked without any decode.
5229                    None => {
5230                        deleted_entity_access(
5231                            GraphStore::node_exists_in_txn(txn, *id)?.then_some(()),
5232                        )?;
5233                        Ok(None)
5234                    }
5235                }
5236            }
5237            Binding::Edge(id) => match self.prop_id_for(txn, &pa.prop)? {
5238                Some(prop_id) => Ok(deleted_entity_access(GraphStore::get_edge_prop_in_txn(
5239                    txn, *id, prop_id,
5240                )?)?),
5241                None => {
5242                    deleted_entity_access(GraphStore::edge_exists_in_txn(txn, *id)?.then_some(()))?;
5243                    Ok(None)
5244                }
5245            },
5246            // A WITH-projected scalar (or list/map) has no scalar `.prop`
5247            // to access via this path — e.g. `WITH message.id AS
5248            // messageId` then `messageId.foo` isn't meaningful. Treat as
5249            // absent rather than erroring, consistent with how a missing
5250            // property already behaves. `Binding::Map` specifically *does*
5251            // have real `.prop` access, just not through this method (its
5252            // values aren't always a scalar `PropertyValue`) — see
5253            // `lookup_prop_value`, which `ReturnExpr::Prop` actually calls.
5254            // A `Binding::Value` holding a `Date`/`Duration` also has real
5255            // `.prop` access (`d.year`, etc) — also handled there, not
5256            // here, for the same "not always a scalar `PropertyValue`"
5257            // reason (well, it always *is* one here, but `lookup_prop_value`
5258            // is where that access actually happens either way).
5259            Binding::Value(_) | Binding::List(_) | Binding::Map(_) => Ok(None),
5260            // Unlike the others, a path is a real type error, not just an
5261            // "absent" property -- real Cypher's `InvalidArgumentType`
5262            // (TCK's MatchWhere1 `[14]`: `MATCH r = (n)-[*]->() WHERE
5263            // r.name = 'apa'`). Property access never had a meaning for a
5264            // path to begin with (it's not a graph-object-shaped value).
5265            Binding::Path(_) => Err(QueryError::Type(format!(
5266                "'{}' is a path — property access requires a node, relationship, or map",
5267                pa.var
5268            ))),
5269        }
5270    }
5271
5272    /// `ReturnExpr::Prop`'s own lookup -- unlike `lookup_prop` (used by
5273    /// pattern-level `WHERE`, which only ever compares a real node/edge
5274    /// property against a `Literal`), a map's value can be any `Value`
5275    /// shape (nested list/map/node), not just a scalar `PropertyValue`,
5276    /// so this returns the wider type and handles `Binding::Map` itself
5277    /// rather than collapsing through `lookup_prop`. A `Binding::Value`
5278    /// holding a `Date`/`Duration` is handled here too, for the same
5279    /// reason -- `d.year`/`d.months`/etc are real component accessors
5280    /// (Temporal5's whole scenario shape, `WITH v.date AS d ... RETURN
5281    /// d.year`), not a stored property `lookup_prop` could ever find.
5282    ///
5283    /// Only a node, relationship, map, or temporal value has any `.prop`
5284    /// to access at all -- a plain scalar (`Bool`/`Int`/`Float`/`String`)
5285    /// or a `List` is a real type error here (real Cypher's own
5286    /// `InvalidArgumentType` is raised at *compile* time; this codebase's
5287    /// `Kind` system can't see through a WITH-projected value's real
5288    /// runtime shape to catch it any earlier -- see `infer_expr`'s own
5289    /// `Kind::Scalar` docs -- so it surfaces here instead), not a silent
5290    /// `null` (TCK's Graph6 [9] / Map1 [6]). `null` itself is exempt --
5291    /// real Cypher's null propagation rule, not a type error.
5292    fn lookup_prop_value(
5293        &self,
5294        txn: Txn,
5295        pa: &PropAccess,
5296        row: &BindingRow,
5297    ) -> Result<Value, QueryError> {
5298        match row.get(&pa.var) {
5299            Some(Binding::Map(m)) => Ok(m.get(&pa.prop).cloned().unwrap_or(Value::Null)),
5300            Some(Binding::Value(PropertyValue::Null)) => Ok(Value::Null),
5301            Some(Binding::Value(pv)) => match temporal_component(pv, &pa.prop) {
5302                Some(component) => Ok(Value::Property(component)),
5303                None if is_temporal_property_value(pv) => Ok(Value::Null),
5304                None => Err(QueryError::Type(format!(
5305                    "'{}' can't have properties accessed on it -- property access requires a \
5306                     node, relationship, map, or temporal value",
5307                    pa.var
5308                ))),
5309            },
5310            Some(Binding::List(_)) => Err(QueryError::Type(format!(
5311                "'{}' can't have properties accessed on it -- property access requires a node, \
5312                 relationship, map, or temporal value, not a list",
5313                pa.var
5314            ))),
5315            Some(_) => Ok(match self.lookup_prop(txn, pa, row)? {
5316                Some(PropertyValue::Null) | None => Value::Null,
5317                Some(pv) => property_value_to_value(pv),
5318            }),
5319            None => Err(QueryError::UnboundVariable(pa.var.clone())),
5320        }
5321    }
5322
5323    fn materialize_return(
5324        &self,
5325        txn: Txn,
5326        items: &[ReturnItem],
5327        rows: &[BindingRow],
5328        distinct: bool,
5329        guard: &ExecutionGuard<'_>,
5330    ) -> Result<QueryResult, QueryError> {
5331        let columns = items
5332            .iter()
5333            .enumerate()
5334            .map(|(i, item)| {
5335                item.alias
5336                    .clone()
5337                    .unwrap_or_else(|| default_column_name(&item.expr, i))
5338            })
5339            .collect();
5340        let mut out_rows = if !has_aggregate(items) {
5341            let mut out_rows = Vec::with_capacity(rows.len());
5342            for row in rows {
5343                let mut out_row = Vec::with_capacity(items.len());
5344                for item in items {
5345                    out_row.push(self.eval_return_expr(txn, &item.expr, row, guard)?);
5346                }
5347                out_rows.push(out_row);
5348            }
5349            out_rows
5350        } else {
5351            validate_return_items(items)?;
5352            let grouped = self.resolve_grouped_rows(txn, items, rows, guard)?;
5353            grouped
5354                .into_iter()
5355                .map(|bindings| {
5356                    bindings
5357                        .iter()
5358                        .map(|b| self.binding_to_value(txn, b))
5359                        .collect::<Result<Vec<_>, _>>()
5360                })
5361                .collect::<Result<Vec<_>, _>>()?
5362        };
5363        if distinct {
5364            out_rows = dedup_rows(out_rows)?;
5365        }
5366        Ok(QueryResult {
5367            columns,
5368            rows: out_rows,
5369            stats: QueryStats::default(),
5370        })
5371    }
5372
5373    /// An aggregating `RETURN`'s own `ORDER BY`, when at least one key
5374    /// doesn't verbatim/alias-match any item -- `RETURN me.age AS age,
5375    /// count(you.age) AS cnt ORDER BY age + count(you.age)` (TCK's
5376    /// ReturnOrderBy6). Folds those extra keys through the *same*
5377    /// grouping pass as `items` themselves, as synthetic unreturned extra
5378    /// items (reusing `resolve_grouped_rows`/`rewrite_composed_item`
5379    /// exactly as a composed RETURN item would, including an aggregate
5380    /// call that appears *only* in the ORDER BY key, nowhere in `items`
5381    /// -- real Cypher allows that too, it just needs to fold consistently
5382    /// with `items`' own implicit grouping, not literally reuse one of
5383    /// their accumulators), then uses their per-group values as
5384    /// additional sort keys before stripping them back off. Degrades to
5385    /// exactly the ordinary "sort by already-computed columns" behavior
5386    /// when every key does verbatim/alias-match (`extra_exprs` empty) --
5387    /// callers can route every aggregating-`RETURN`-with-`ORDER-BY` case
5388    /// through this one function rather than branching on whether extras
5389    /// are actually needed.
5390    ///
5391    /// `DISTINCT` isn't handled here -- deliberately: grouping already
5392    /// makes every output row unique by its own grouping-key columns (two
5393    /// groups can't have the same grouping key and still be different
5394    /// groups), so `RETURN DISTINCT` combined with aggregation is
5395    /// provably always a no-op downstream of this function regardless.
5396    fn materialize_aggregating_return_with_order(
5397        &self,
5398        txn: Txn,
5399        items: &[ReturnItem],
5400        rows: &[BindingRow],
5401        order_by: &[(ReturnExpr, SortDir)],
5402        skip_limit: (Option<i64>, Option<i64>),
5403        guard: &ExecutionGuard<'_>,
5404    ) -> Result<QueryResult, QueryError> {
5405        let (skip, limit) = skip_limit;
5406        enum OrderKeySource {
5407            RealColumn(usize),
5408            Extra(usize),
5409        }
5410        let mut extra_exprs: Vec<ReturnExpr> = Vec::new();
5411        let order_by_source: Vec<OrderKeySource> = order_by
5412            .iter()
5413            .map(|(expr, _)| {
5414                match items
5415                    .iter()
5416                    .enumerate()
5417                    .position(|(i, it)| item_matches_leaf(expr, i, it))
5418                {
5419                    Some(i) => OrderKeySource::RealColumn(i),
5420                    None => {
5421                        let idx = extra_exprs.len();
5422                        extra_exprs.push(expr.clone());
5423                        OrderKeySource::Extra(idx)
5424                    }
5425                }
5426            })
5427            .collect();
5428        let extended_items: Vec<ReturnItem> = items
5429            .iter()
5430            .cloned()
5431            .chain(
5432                extra_exprs
5433                    .into_iter()
5434                    .map(|expr| ReturnItem { expr, alias: None }),
5435            )
5436            .collect();
5437        validate_return_items(&extended_items)?;
5438        let grouped = self.resolve_grouped_rows(txn, &extended_items, rows, guard)?;
5439        let columns: Vec<String> = items
5440            .iter()
5441            .enumerate()
5442            .map(|(i, item)| {
5443                item.alias
5444                    .clone()
5445                    .unwrap_or_else(|| default_column_name(&item.expr, i))
5446            })
5447            .collect();
5448        let real_len = items.len();
5449        let mut keyed: Vec<(Vec<Value>, Vec<Value>)> = Vec::with_capacity(grouped.len());
5450        for bindings in grouped {
5451            let values: Vec<Value> = bindings
5452                .iter()
5453                .map(|b| self.binding_to_value(txn, b))
5454                .collect::<Result<Vec<_>, _>>()?;
5455            let (real, extra) = values.split_at(real_len);
5456            let keys: Vec<Value> = order_by_source
5457                .iter()
5458                .map(|src| match src {
5459                    OrderKeySource::RealColumn(i) => real[*i].clone(),
5460                    OrderKeySource::Extra(k) => extra[*k].clone(),
5461                })
5462                .collect();
5463            keyed.push((keys, real.to_vec()));
5464        }
5465        let rows = top_k_by(keyed, order_by, skip, limit)
5466            .into_iter()
5467            .map(|(_, row)| row)
5468            .collect();
5469        Ok(QueryResult {
5470            columns,
5471            rows,
5472            stats: QueryStats::default(),
5473        })
5474    }
5475
5476    /// `SKIP`/`LIMIT` accept any expression, not just a literal integer
5477    /// (`SKIP $n`, `SKIP toInteger(rand()*9)` -- TCK's `ReturnSkipLimit1
5478    /// [2]`/`[3]`) -- evaluated exactly once here, against an empty row,
5479    /// since no pattern variable can be in scope at a statement's own
5480    /// SKIP/LIMIT (an `UnboundVariable` error from `eval_return_expr`
5481    /// below is exactly the right outcome if one is referenced). Params
5482    /// are already resolved to concrete `Literal`s by this point (see
5483    /// `params::substitute_params`).
5484    fn resolve_skip_limit(
5485        &self,
5486        txn: Txn,
5487        expr: Option<&ReturnExpr>,
5488        clause: &str,
5489        guard: &ExecutionGuard<'_>,
5490    ) -> Result<Option<i64>, QueryError> {
5491        let Some(expr) = expr else {
5492            return Ok(None);
5493        };
5494        let value = self.eval_return_expr(txn, expr, &BindingRow::new(), guard)?;
5495        let n = match value {
5496            Value::Literal(Literal::Int(n)) | Value::Property(PropertyValue::Int(n)) => n,
5497            _ => {
5498                return Err(QueryError::Semantic(format!(
5499                    "{clause} must evaluate to an integer"
5500                )));
5501            }
5502        };
5503        if n < 0 {
5504            return Err(QueryError::Semantic(format!("{clause} can't be negative")));
5505        }
5506        Ok(Some(n))
5507    }
5508
5509    fn eval_return_expr(
5510        &self,
5511        txn: Txn,
5512        expr: &ReturnExpr,
5513        row: &BindingRow,
5514        guard: &ExecutionGuard<'_>,
5515    ) -> Result<Value, QueryError> {
5516        match expr {
5517            ReturnExpr::Var(var) => {
5518                let binding = row
5519                    .get(var)
5520                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
5521                self.binding_to_value(txn, binding)
5522            }
5523            ReturnExpr::Prop(pa) => self.lookup_prop_value(txn, pa, row),
5524            ReturnExpr::PropOf(base, prop) => {
5525                let v = self.eval_return_expr(txn, base, row, guard)?;
5526                property_of_value(&v, prop)
5527            }
5528            ReturnExpr::Lit(lit) => Ok(match lit {
5529                Literal::Null => Value::Null,
5530                other => Value::Literal(other.clone()),
5531            }),
5532            ReturnExpr::Call { name, args, .. } => {
5533                // Reaching here with an aggregate name means an aggregate
5534                // call slipped past `validate_return_items` (which only
5535                // allows one at a return item's top level) — grouping
5536                // itself never calls `eval_return_expr` on the aggregate
5537                // wrapper, only on each aggregate's own argument
5538                // subexpression (see `resolve_grouped_rows`), so this is
5539                // an internal-consistency error, not a normal user path.
5540                if is_aggregate_name(name) {
5541                    return Err(QueryError::Semantic(format!(
5542                        "aggregate function '{name}' can only be used as a return item's top-level expression"
5543                    )));
5544                }
5545                let lower = name.to_ascii_lowercase();
5546                if lower == "type" {
5547                    // Special-cased *before* the generic arg-evaluation
5548                    // below -- that would eagerly fail on a deleted
5549                    // relationship (`deleted_entity_access`), before
5550                    // `eval_type_call` ever gets a chance to fall back to
5551                    // its cached type. See `ExecutionGuard::
5552                    // deleted_edge_types`'s own docs.
5553                    return self.eval_type_call(txn, args.first(), row, guard);
5554                }
5555                let arg_values = args
5556                    .iter()
5557                    .map(|a| self.eval_return_expr(txn, a, row, guard))
5558                    .collect::<Result<Vec<_>, _>>()?;
5559                if lower == "startnode" || lower == "endnode" {
5560                    return self.start_or_end_node(txn, &lower, arg_values.first());
5561                }
5562                call_builtin(name, &arg_values, self.now_snapshot())
5563            }
5564            ReturnExpr::CountStar => Err(QueryError::Semantic(
5565                "count(*) can only be used as a return item's top-level expression".into(),
5566            )),
5567            ReturnExpr::Case { test, whens, else_ } => {
5568                let test_value = match test {
5569                    Some(t) => Some(self.eval_return_expr(txn, t, row, guard)?),
5570                    None => None,
5571                };
5572                for (when, then) in whens {
5573                    let when_value = self.eval_return_expr(txn, when, row, guard)?;
5574                    // Deliberately reuses the same Null == Null -> true
5575                    // convention as `compare()` below, not standard
5576                    // three-valued NULL logic — IS7's `CASE r WHEN null
5577                    // THEN false ELSE true END` depends on this exact
5578                    // semantics to detect an OPTIONAL MATCH non-match.
5579                    let matched = match &test_value {
5580                        Some(tv) => value_eq(tv, &when_value),
5581                        None => matches!(when_value, Value::Literal(Literal::Bool(true))),
5582                    };
5583                    if matched {
5584                        return self.eval_return_expr(txn, then, row, guard);
5585                    }
5586                }
5587                match else_ {
5588                    Some(e) => self.eval_return_expr(txn, e, row, guard),
5589                    None => Ok(Value::Null),
5590                }
5591            }
5592            ReturnExpr::Arith(l, op, r) => {
5593                let lv = self.eval_return_expr(txn, l, row, guard)?;
5594                let rv = self.eval_return_expr(txn, r, row, guard)?;
5595                apply_arith(*op, &lv, &rv)
5596            }
5597            ReturnExpr::Neg(e) => {
5598                let v = self.eval_return_expr(txn, e, row, guard)?;
5599                apply_neg(&v)
5600            }
5601            ReturnExpr::ListLit(items) => Ok(Value::List(
5602                items
5603                    .iter()
5604                    .map(|item| self.eval_return_expr(txn, item, row, guard))
5605                    .collect::<Result<Vec<_>, _>>()?,
5606            )),
5607            ReturnExpr::Index(base, index) => {
5608                let base_v = self.eval_return_expr(txn, base, row, guard)?;
5609                let index_v = self.eval_return_expr(txn, index, row, guard)?;
5610                apply_index(&base_v, &index_v)
5611            }
5612            ReturnExpr::Slice(base, start, end) => {
5613                let base_v = self.eval_return_expr(txn, base, row, guard)?;
5614                let start_v = start
5615                    .as_deref()
5616                    .map(|s| self.eval_return_expr(txn, s, row, guard))
5617                    .transpose()?;
5618                let end_v = end
5619                    .as_deref()
5620                    .map(|e| self.eval_return_expr(txn, e, row, guard))
5621                    .transpose()?;
5622                apply_slice(&base_v, start_v.as_ref(), end_v.as_ref())
5623            }
5624            ReturnExpr::ListComp {
5625                var,
5626                source,
5627                where_clause,
5628                project,
5629            } => {
5630                let source_v = self.eval_return_expr(txn, source, row, guard)?;
5631                let items = match source_v {
5632                    Value::List(items) => items,
5633                    Value::Null => return Ok(Value::Null),
5634                    other => {
5635                        return Err(QueryError::Type(format!(
5636                            "list comprehension source must be a list, got {other:?}"
5637                        )))
5638                    }
5639                };
5640                let mut result = Vec::with_capacity(items.len());
5641                for item in items {
5642                    // A fresh overlay per element -- `var` shadows any
5643                    // outer binding of the same name for the duration of
5644                    // this one element, same scoping UNWIND already uses.
5645                    let mut scoped_row = row.clone();
5646                    scoped_row.insert(var.clone(), value_to_binding_restore(&item));
5647                    let keep = match where_clause {
5648                        Some(w) => {
5649                            self.eval_return_expr_bool3(txn, w, &scoped_row, guard)? == Some(true)
5650                        }
5651                        None => true,
5652                    };
5653                    if !keep {
5654                        continue;
5655                    }
5656                    result.push(match project {
5657                        Some(p) => self.eval_return_expr(txn, p, &scoped_row, guard)?,
5658                        None => item,
5659                    });
5660                }
5661                Ok(Value::List(result))
5662            }
5663            ReturnExpr::Quantifier {
5664                kind,
5665                var,
5666                source,
5667                where_clause,
5668            } => {
5669                let source_v = self.eval_return_expr(txn, source, row, guard)?;
5670                let items = match source_v {
5671                    Value::List(items) => items,
5672                    Value::Null => return Ok(Value::Null),
5673                    other => {
5674                        return Err(QueryError::Type(format!(
5675                            "quantifier source must be a list, got {other:?}"
5676                        )))
5677                    }
5678                };
5679                let mut preds = Vec::with_capacity(items.len());
5680                for item in &items {
5681                    let mut scoped_row = row.clone();
5682                    scoped_row.insert(var.clone(), value_to_binding_restore(item));
5683                    preds.push(match where_clause {
5684                        Some(w) => self.eval_return_expr_bool3(txn, w, &scoped_row, guard)?,
5685                        None => item_truthy(item),
5686                    });
5687                }
5688                Ok(match eval_quantifier(*kind, &preds) {
5689                    Some(b) => Value::Literal(Literal::Bool(b)),
5690                    None => Value::Null,
5691                })
5692            }
5693            ReturnExpr::MapLit(entries) => {
5694                let mut map = BTreeMap::new();
5695                for (k, v) in entries {
5696                    map.insert(k.clone(), self.eval_return_expr(txn, v, row, guard)?);
5697                }
5698                Ok(Value::Map(map))
5699            }
5700            ReturnExpr::And(l, r) => Ok(bool3_to_value(and3(
5701                self.eval_return_expr_bool3(txn, l, row, guard)?,
5702                self.eval_return_expr_bool3(txn, r, row, guard)?,
5703            ))),
5704            ReturnExpr::Or(l, r) => Ok(bool3_to_value(or3(
5705                self.eval_return_expr_bool3(txn, l, row, guard)?,
5706                self.eval_return_expr_bool3(txn, r, row, guard)?,
5707            ))),
5708            ReturnExpr::Xor(l, r) => Ok(bool3_to_value(xor3(
5709                self.eval_return_expr_bool3(txn, l, row, guard)?,
5710                self.eval_return_expr_bool3(txn, r, row, guard)?,
5711            ))),
5712            ReturnExpr::Not(e) => Ok(bool3_to_value(
5713                self.eval_return_expr_bool3(txn, e, row, guard)?.map(|b| !b),
5714            )),
5715            ReturnExpr::Compare(l, op, r) => {
5716                let lv = self.eval_return_expr(txn, l, row, guard)?;
5717                let rv = self.eval_return_expr(txn, r, row, guard)?;
5718                Ok(bool3_to_value(compare_values(&lv, *op, &rv)))
5719            }
5720            ReturnExpr::IsNull(e) => {
5721                let v = self.eval_return_expr(txn, e, row, guard)?;
5722                Ok(Value::Literal(Literal::Bool(matches!(v, Value::Null))))
5723            }
5724            ReturnExpr::In(needle, haystack) => {
5725                let nv = self.eval_return_expr(txn, needle, row, guard)?;
5726                let hv = self.eval_return_expr(txn, haystack, row, guard)?;
5727                Ok(bool3_to_value(list_membership_ternary(&nv, &hv)?))
5728            }
5729            ReturnExpr::HasLabel(var, labels) => {
5730                let binding = row
5731                    .get(var)
5732                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
5733                match binding {
5734                    Binding::Node(id) => {
5735                        let node = deleted_entity_access(self.get_node_cached(txn, *id)?)?;
5736                        Ok(Value::Literal(Literal::Bool(
5737                            labels.iter().all(|l| node.labels.contains(l)),
5738                        )))
5739                    }
5740                    // `r:TYPE` -- a relationship has exactly one type, so
5741                    // this is just an equality check, not a set-membership
5742                    // one; a conjunctive `r:A:B` (only reachable from
5743                    // general expression position, never real Cypher's own
5744                    // pattern-level `WHERE` -- relationships can't carry
5745                    // more than one type) is trivially always false unless
5746                    // every listed name is the same one type (TCK's Graph5
5747                    // "Node and edge label expressions" [2]).
5748                    Binding::Edge(id) => {
5749                        let edge = deleted_entity_access(GraphStore::get_edge_in_txn(txn, *id)?)?;
5750                        Ok(Value::Literal(Literal::Bool(
5751                            labels.iter().all(|l| edge.label == *l),
5752                        )))
5753                    }
5754                    Binding::Value(PropertyValue::Null) => Ok(Value::Null),
5755                    other => Err(QueryError::Type(format!(
5756                        "'{var}' isn't a node or relationship — (n:Label) needs one, got {other:?}"
5757                    ))),
5758                }
5759            }
5760            ReturnExpr::PatternPredicate(_) => Err(QueryError::Semantic(
5761                "a pattern predicate (`(n)-->()` etc) can only be used inside WHERE".into(),
5762            )),
5763            ReturnExpr::PatternComprehension {
5764                path_var,
5765                pattern,
5766                where_clause,
5767                projection,
5768            } => self.eval_pattern_comprehension(
5769                txn,
5770                PatternComprehensionSpec {
5771                    path_var,
5772                    pattern,
5773                    where_clause,
5774                    projection,
5775                },
5776                row,
5777                guard,
5778            ),
5779            ReturnExpr::ExistsPattern { .. } | ReturnExpr::ExistsSubquery(_) => Err(
5780                QueryError::Semantic("an exists {} subquery can only be used inside WHERE".into()),
5781            ),
5782        }
5783    }
5784
5785    /// `[p = (n)-->() | p]` / `[(n)-[:T]->(b) | b.name]` -- enumerates
5786    /// every match of `pattern` against the graph (already-bound named
5787    /// endpoints in `row` held fixed, exactly like `Expr::Pattern`'s own
5788    /// existential search reuses `build_match_plan`'s "already-bound var
5789    /// -> Seed, not a fresh scan" mechanism) and projects `projection`
5790    /// against each match's own resulting row, collecting into a
5791    /// `Value::List`. No limit on `eval_plan_with_limit` here (unlike
5792    /// `Expr::Pattern`'s `Some(1)`) -- a comprehension needs every match,
5793    /// not just whether one exists.
5794    ///
5795    /// A named path (`path_var: Some`) reuses `execute_match`'s own
5796    /// `name_pattern_for_path`/`assemble_path` pair verbatim -- same
5797    /// "synthesize internal names for any unnamed hop, assemble the path
5798    /// from those, then strip the synthesized keys (and the reserved
5799    /// variable-length-hop segment key, if any) back out" approach a real
5800    /// `MATCH p = ...` clause already uses, including over a single
5801    /// variable-length hop (TCK's Pattern2 `[9]`) -- also reuses
5802    /// `validate_named_path_pattern`'s own restriction on anything wider
5803    /// (a variable-length hop mixed with another hop) for the same reason
5804    /// it already applies to `MATCH`.
5805    fn eval_pattern_comprehension(
5806        &self,
5807        txn: Txn,
5808        spec: PatternComprehensionSpec<'_>,
5809        row: &BindingRow,
5810        guard: &ExecutionGuard<'_>,
5811    ) -> Result<Value, QueryError> {
5812        let PatternComprehensionSpec {
5813            path_var,
5814            pattern,
5815            where_clause,
5816            projection,
5817        } = spec;
5818        if path_var.is_some() {
5819            validate_named_path_pattern(pattern)?;
5820        }
5821        let carried_vars: HashSet<String> = row.keys().cloned().collect();
5822        let (named_pattern, synthesized) = match path_var {
5823            Some(_) => name_pattern_for_path(pattern),
5824            None => (pattern.clone(), HashSet::new()),
5825        };
5826        let wc: Option<Expr> = where_clause.as_deref().cloned();
5827        let plan = apply_index_seeks(build_match_plan(&named_pattern, &wc, &carried_vars)?, txn)?;
5828        let rows = self.eval_plan_with_limit(txn, &plan, std::slice::from_ref(row), guard, None)?;
5829        let mut out = Vec::with_capacity(rows.len());
5830        for mut r in rows {
5831            if let Some(pv) = path_var {
5832                let path_binding = assemble_path(&named_pattern, &r);
5833                for key in &synthesized {
5834                    r.remove(key);
5835                }
5836                r.insert(pv.clone(), path_binding);
5837            }
5838            out.push(self.eval_return_expr(txn, projection, &r, guard)?);
5839        }
5840        Ok(Value::List(out))
5841    }
5842
5843    /// A `WHERE`-position `ReturnExpr` (list comprehension/quantifier
5844    /// filters) evaluated as three-valued logic instead of a plain
5845    /// `Value` -- delegates to `eval_return_expr` then folds the result
5846    /// down via `value_to_bool3`.
5847    fn eval_return_expr_bool3(
5848        &self,
5849        txn: Txn,
5850        expr: &ReturnExpr,
5851        row: &BindingRow,
5852        guard: &ExecutionGuard<'_>,
5853    ) -> Result<Option<bool>, QueryError> {
5854        value_to_bool3(&self.eval_return_expr(txn, expr, row, guard)?)
5855    }
5856
5857    /// Deletes every `targets` expression's value, across every row --
5858    /// shared by `materialize_delete` (`DELETE`/`DETACH DELETE` as a
5859    /// statement tail) and `execute_match`'s own `QueryClause::Delete`
5860    /// (`DELETE ... WITH ...` mid-pattern). Edges are deleted immediately
5861    /// (no ordering constraint), but nodes are only *collected* into
5862    /// `pending_nodes` and deleted in a second pass, after every target
5863    /// across every row has contributed its own edges -- not deleted
5864    /// inline the way `delete_binding`/`delete_value` used to. A single
5865    /// non-`DETACH` `DELETE` naming *several* targets that collectively
5866    /// cover all of a node's edges (e.g. `DELETE pathColls.key[0],
5867    /// pathColls.key[1]`, two paths sharing a node, each contributing one
5868    /// of its two incident edges) must succeed -- deleting inline would
5869    /// try to delete the first path's node while the second path's edge
5870    /// (not yet processed) was still attached, a real bug found via TCK's
5871    /// Delete5 `[7]` once `{key: collect(p)}`-shaped composed expressions
5872    /// could reach this code path at all (previously rejected outright at
5873    /// compile time, before general aggregate composition was supported).
5874    fn delete_targets(
5875        &self,
5876        txn: Txn,
5877        write_txn: &WriteTransaction,
5878        targets: &[ReturnExpr],
5879        rows: &[BindingRow],
5880        detach: bool,
5881        guard: &ExecutionGuard<'_>,
5882    ) -> Result<(), QueryError> {
5883        let mut deleted_edges = HashSet::new();
5884        let mut pending_nodes = HashSet::new();
5885        // All-bare-variable target lists (`DELETE r`, `DELETE r, a, b` --
5886        // by far the common case, and the only shape a predicate-driven
5887        // bulk delete produces) never evaluate anything between edge
5888        // deletions, so the edge ids can be collected across every row
5889        // first and deleted in one `delete_edges_in_txn` batch: one
5890        // `WriteCtx` and one label-name resolution per distinct type,
5891        // instead of a whole-edge fetch plus a fresh `WriteCtx` (and its
5892        // table opens) per edge. Observably identical to deleting
5893        // inline -- with no expression evaluation in the loop there is no
5894        // read that could distinguish "deleted already" from "deleted at
5895        // the end", and `guard`'s deleted-edge-type bookkeeping is only
5896        // consulted by later statements. Any computed target (`list[0]`,
5897        // `map.key`, ...) falls back to the per-edge path below, whose
5898        // immediate deletes are what let a later target's evaluation
5899        // correctly error via `deleted_entity_access` on touching an
5900        // already-deleted entity.
5901        if targets.iter().all(|t| matches!(t, ReturnExpr::Var(_))) {
5902            let mut edge_ids: Vec<EdgeId> = Vec::new();
5903            for row in rows {
5904                for target in targets {
5905                    let ReturnExpr::Var(name) = target else {
5906                        unreachable!("checked all-Var above");
5907                    };
5908                    let binding = row
5909                        .get(name)
5910                        .ok_or_else(|| QueryError::UnboundVariable(name.clone()))?;
5911                    collect_delete_binding(
5912                        binding,
5913                        &mut deleted_edges,
5914                        &mut edge_ids,
5915                        &mut pending_nodes,
5916                    )?;
5917                }
5918            }
5919            for (id, label) in GraphStore::delete_edges_in_txn(write_txn, &edge_ids)? {
5920                self.count(|s| s.relationships_deleted += 1);
5921                guard.record_deleted_edge_type(id, label);
5922            }
5923        } else {
5924            for row in rows {
5925                for target in targets {
5926                    // A bare variable (`DELETE r, a, b`, by far the common
5927                    // case) deletes by the raw id already sitting in the row's
5928                    // `Binding` -- no existence check, no property fetch.
5929                    // That's what lets `DELETE r, a, b` work when two rows of
5930                    // the same undirected match both reference the same `a`/
5931                    // `b`/`r` (real, from TCK's Delete4 `[1]`): the second
5932                    // row's own dedup lookup must succeed even though the
5933                    // first row already deleted them. Anything else (`list[0]`,
5934                    // `map.key`, a whole path variable's *elements* accessed
5935                    // computedly, ...) has no such raw shortcut and goes
5936                    // through real evaluation instead -- which correctly does
5937                    // still error via `deleted_entity_access` if it tries to
5938                    // read a property off something already gone, since that's
5939                    // a genuine access, not just a re-statement of identity.
5940                    if let ReturnExpr::Var(name) = target {
5941                        let binding = row
5942                            .get(name)
5943                            .ok_or_else(|| QueryError::UnboundVariable(name.clone()))?;
5944                        delete_binding(
5945                            self,
5946                            txn,
5947                            binding,
5948                            write_txn,
5949                            &mut deleted_edges,
5950                            &mut pending_nodes,
5951                            guard,
5952                        )?;
5953                    } else {
5954                        let value = self.eval_return_expr(txn, target, row, guard)?;
5955                        delete_value(
5956                            self,
5957                            &value,
5958                            write_txn,
5959                            &mut deleted_edges,
5960                            &mut pending_nodes,
5961                            guard,
5962                        )?;
5963                    }
5964                }
5965            }
5966        }
5967        for id in pending_nodes {
5968            self.uncache_node(id);
5969            if let Some(detached_edges) = GraphStore::delete_node_in_txn(write_txn, id, detach)? {
5970                self.count(|s| {
5971                    s.nodes_deleted += 1;
5972                    s.relationships_deleted += detached_edges;
5973                });
5974            }
5975        }
5976        Ok(())
5977    }
5978
5979    /// `ret`, when present, is evaluated *after* the physical delete runs,
5980    /// not before — real Cypher's own DELETE+RETURN TCK scenarios agree on
5981    /// this ordering: `MATCH (n) DELETE n RETURN n.num` must raise a
5982    /// `DeletedEntityAccess` error (TCK's Return2 scenarios [15]/[17]), not
5983    /// silently return the pre-delete value. `lookup_prop`/
5984    /// `binding_to_value` (via `deleted_entity_access`) already turn "the
5985    /// bound id's record is gone" into a proper `QueryError` rather than a
5986    /// silent null or a panic, which is exactly what makes deleting first
5987    /// safe here — every other real DELETE+RETURN shape (`count(*)`,
5988    /// `sum(num)` off a WITH-projected scalar, a literal, a null OPTIONAL
5989    /// MATCH binding) never touches the just-deleted entity's live record
5990    /// at all, so this ordering changes nothing for them.
5991    fn materialize_delete(
5992        &self,
5993        txn: Txn,
5994        targets: &[ReturnExpr],
5995        rows: &[BindingRow],
5996        detach: bool,
5997        ret: &Option<ReturnTail>,
5998        guard: &ExecutionGuard<'_>,
5999    ) -> Result<QueryResult, QueryError> {
6000        let write_txn = require_write_txn(txn);
6001        self.delete_targets(txn, write_txn, targets, rows, detach, guard)?;
6002        let result = match ret {
6003            Some(rt) => self.materialize_return(txn, &rt.items, rows, rt.distinct, guard)?,
6004            None => QueryResult {
6005                columns: vec![],
6006                rows: vec![],
6007                stats: QueryStats::default(),
6008            },
6009        };
6010        Ok(result)
6011    }
6012
6013    fn materialize_set(
6014        &self,
6015        txn: Txn,
6016        items: &[SetItem],
6017        rows: &[BindingRow],
6018        ret: &Option<ReturnTail>,
6019        guard: &ExecutionGuard<'_>,
6020    ) -> Result<QueryResult, QueryError> {
6021        let write_txn = require_write_txn(txn);
6022        for row in rows {
6023            for item in items {
6024                self.apply_set_item(txn, write_txn, row, item, guard)?;
6025            }
6026        }
6027        match ret {
6028            Some(rt) => self.materialize_return(txn, &rt.items, rows, rt.distinct, guard),
6029            None => Ok(QueryResult {
6030                columns: vec![],
6031                rows: vec![],
6032                stats: QueryStats::default(),
6033            }),
6034        }
6035    }
6036
6037    fn materialize_remove(
6038        &self,
6039        txn: Txn,
6040        items: &[RemoveItem],
6041        rows: &[BindingRow],
6042        ret: &Option<ReturnTail>,
6043        guard: &ExecutionGuard<'_>,
6044    ) -> Result<QueryResult, QueryError> {
6045        let write_txn = require_write_txn(txn);
6046        for row in rows {
6047            for item in items {
6048                apply_remove_item(self, write_txn, row, item)?;
6049            }
6050        }
6051        match ret {
6052            Some(rt) => self.materialize_return(txn, &rt.items, rows, rt.distinct, guard),
6053            None => Ok(QueryResult {
6054                columns: vec![],
6055                rows: vec![],
6056                stats: QueryStats::default(),
6057            }),
6058        }
6059    }
6060
6061    /// `<match_stmt> UNION [ALL] <match_stmt> ...` — every part shares the
6062    /// same `txn` (one snapshot for a read-only union, one write
6063    /// transaction otherwise — see `is_read_only`'s own `Union` handling)
6064    /// but no bindings: each part is `execute_match`'d completely
6065    /// independently, matching real Cypher's own scoping. Column names
6066    /// must match exactly across every part (real Cypher's
6067    /// `DifferentColumnsInUnion` — checked here, once each part's real
6068    /// `QueryResult.columns` is in hand, rather than statically, since
6069    /// nothing else in this codebase infers a `RETURN` list's column
6070    /// names without evaluating it). `all: false` (plain `UNION`) dedups
6071    /// the combined rows via the same `dedup_rows` `RETURN DISTINCT`
6072    /// already uses; `all: true` keeps every row.
6073    fn materialize_union(
6074        &self,
6075        txn: Txn,
6076        parts: &[Statement],
6077        all: bool,
6078        guard: &ExecutionGuard<'_>,
6079    ) -> Result<QueryResult, QueryError> {
6080        let mut combined: Option<QueryResult> = None;
6081        for part in parts {
6082            let Statement::Match {
6083                clauses,
6084                tail,
6085                order_by,
6086                skip,
6087                limit,
6088            } = part
6089            else {
6090                unreachable!(
6091                    "union_stmt parts are always Statement::Match -- see parser::parse_union_stmt"
6092                )
6093            };
6094            let skip = self.resolve_skip_limit(txn, skip.as_deref(), "SKIP", guard)?;
6095            let limit = self.resolve_skip_limit(txn, limit.as_deref(), "LIMIT", guard)?;
6096            let result = self.execute_match(
6097                txn,
6098                clauses,
6099                tail,
6100                ResultModifiers {
6101                    order_by,
6102                    skip,
6103                    limit,
6104                },
6105                guard,
6106            )?;
6107            combined = Some(match combined {
6108                None => result,
6109                Some(mut acc) => {
6110                    if acc.columns != result.columns {
6111                        return Err(QueryError::Semantic(format!(
6112                            "UNION requires every part to return the same columns -- got {:?} \
6113                             and {:?}",
6114                            acc.columns, result.columns
6115                        )));
6116                    }
6117                    acc.rows.extend(result.rows);
6118                    acc
6119                }
6120            });
6121            guard.check_intermediate_rows(combined.as_ref().map(|r| r.rows.len()).unwrap_or(0))?;
6122        }
6123        let mut result = combined.expect("union_stmt grammar guarantees at least 2 parts");
6124        if !all {
6125            result.rows = dedup_rows(result.rows)?;
6126        }
6127        Ok(result)
6128    }
6129
6130    fn apply_set_item(
6131        &self,
6132        txn: Txn,
6133        write_txn: &WriteTransaction,
6134        row: &BindingRow,
6135        item: &SetItem,
6136        guard: &ExecutionGuard<'_>,
6137    ) -> Result<(), QueryError> {
6138        match item {
6139            SetItem::Prop(pa, expr) => {
6140                let binding = row
6141                    .get(&pa.var)
6142                    .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
6143                // `SET` on a null binding is a documented no-op, same as
6144                // `DELETE`/`REMOVE` on one -- an `OPTIONAL MATCH` that found
6145                // nothing pads its variables with null (found via TCK's
6146                // Set1/Set3 "Ignore null when setting property/label"
6147                // scenarios).
6148                if matches!(binding, Binding::Value(PropertyValue::Null)) {
6149                    return Ok(());
6150                }
6151                let node_id = if let Binding::Node(id) = binding {
6152                    Some(*id)
6153                } else {
6154                    None
6155                };
6156                let edge_id = if let Binding::Edge(id) = binding {
6157                    Some(*id)
6158                } else {
6159                    None
6160                };
6161                if node_id.is_none() && edge_id.is_none() {
6162                    return Err(QueryError::UnboundVariable(format!(
6163                    "'{}' is a WITH-projected scalar, not a node/edge — SET needs a graph binding",
6164                    pa.var
6165                )));
6166                }
6167                let value = self.eval_return_expr(txn, expr, row, guard)?;
6168                // `SET n.prop = null` *removes* the property in real Cypher
6169                // (found via TCK's Set2 "Set a Property to Null" scenarios,
6170                // which this codebase previously couldn't parse at all --
6171                // `SET` had no trailing RETURN to observe the result with, so
6172                // this bug was never exercised until that gap closed).
6173                // Storing a literal `PropertyValue::Null` instead is
6174                // observably different: `n.prop` still shows up as a
6175                // (nulled-out) key when a caller enumerates a node's own
6176                // props (e.g. this RETURN's own node-to-string rendering),
6177                // where a real missing property wouldn't. The RHS being
6178                // `null` is now a *runtime* fact (it's any `ReturnExpr`, not
6179                // just the `Literal::Null` token), not something checkable
6180                // from the AST alone -- `SET n.prop = coalesce(x, null)`
6181                // must remove the property too if `x` turns out null.
6182                if let Some(id) = node_id {
6183                    self.uncache_node(id);
6184                }
6185                if matches!(value, Value::Null) {
6186                    if let Some(id) = node_id {
6187                        GraphStore::remove_node_prop_in_txn(write_txn, id, &pa.prop)?;
6188                        self.count(|s| s.properties_set += 1);
6189                    }
6190                    if let Some(id) = edge_id {
6191                        GraphStore::remove_edge_prop_in_txn(write_txn, id, &pa.prop)?;
6192                        self.count(|s| s.properties_set += 1);
6193                    }
6194                } else {
6195                    let pv = value_to_storable_property(&value).ok_or_else(|| {
6196                    QueryError::Type(format!(
6197                        "property '{}' can't be stored -- MarsDB's node/edge properties are limited \
6198                         to null/bool/int/float/string/date/duration; a list/map/node/edge/path value \
6199                         (got {value:?}) isn't storable",
6200                        pa.prop
6201                    ))
6202                })?;
6203                    if let Some(id) = node_id {
6204                        GraphStore::set_node_prop_in_txn(write_txn, id, &pa.prop, pv.clone())?;
6205                        self.count(|s| s.properties_set += 1);
6206                    }
6207                    if let Some(id) = edge_id {
6208                        GraphStore::set_edge_prop_in_txn(write_txn, id, &pa.prop, pv)?;
6209                        self.count(|s| s.properties_set += 1);
6210                    }
6211                }
6212            }
6213            SetItem::Labels(var, labels) => {
6214                let binding = row
6215                    .get(var)
6216                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
6217                match binding {
6218                    Binding::Node(id) => {
6219                        self.uncache_node(*id);
6220                        for label in labels {
6221                            GraphStore::add_node_label_in_txn(write_txn, *id, label)?;
6222                            self.count(|s| s.labels_added += 1);
6223                        }
6224                    }
6225                    // Same null-is-a-no-op rule as the property arm above.
6226                    Binding::Value(PropertyValue::Null) => {}
6227                    _ => {
6228                        return Err(QueryError::UnboundVariable(format!(
6229                            "'{var}' isn't a node — SET can only add labels to a node"
6230                        )))
6231                    }
6232                }
6233            }
6234            SetItem::MapAssign { var, value, merge } => {
6235                let binding = row
6236                    .get(var)
6237                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
6238                // Same null-is-a-no-op rule as the property arm above.
6239                if matches!(binding, Binding::Value(PropertyValue::Null)) {
6240                    return Ok(());
6241                }
6242                let node_id = if let Binding::Node(id) = binding {
6243                    Some(*id)
6244                } else {
6245                    None
6246                };
6247                let edge_id = if let Binding::Edge(id) = binding {
6248                    Some(*id)
6249                } else {
6250                    None
6251                };
6252                if node_id.is_none() && edge_id.is_none() {
6253                    return Err(QueryError::UnboundVariable(format!(
6254                        "'{var}' is a WITH-projected scalar, not a node/edge — SET needs a graph binding"
6255                    )));
6256                }
6257                if let Some(id) = node_id {
6258                    self.uncache_node(id);
6259                }
6260                let map_value = self.eval_return_expr(txn, value, row, guard)?;
6261                // A map literal is the common case, but real Cypher also
6262                // allows `SET r = a`/`SET r += a` where `a` is itself a
6263                // bound node/relationship -- copies its properties, same
6264                // as a map built from them would (TCK's Merge6 [6]/
6265                // Merge7 [4], "Copying properties from node").
6266                let entries = match map_value {
6267                    Value::Map(entries) => entries,
6268                    Value::Node(n) => n
6269                        .props
6270                        .into_iter()
6271                        .map(|(k, v)| (k, property_value_to_value(v)))
6272                        .collect(),
6273                    Value::Edge(e) => e
6274                        .props
6275                        .into_iter()
6276                        .map(|(k, v)| (k, property_value_to_value(v)))
6277                        .collect(),
6278                    other => {
6279                        return Err(QueryError::Type(format!(
6280                            "SET {var} = ...{} needs a map, node, or relationship, got {other:?}",
6281                            if *merge { " (+=)" } else { "" }
6282                        )))
6283                    }
6284                };
6285                // `SET n = {...}` (`merge: false`) replaces every existing
6286                // property -- delete whatever's already there first, not
6287                // just overwrite the map's own keys, or a key n already
6288                // had that the map doesn't mention would wrongly survive
6289                // (TCK's Set4 [2]/[3]/[4]).
6290                if !merge {
6291                    let existing_keys: Vec<String> = if let Some(id) = node_id {
6292                        deleted_entity_access(GraphStore::get_node_in_txn(txn, id)?)?
6293                            .props
6294                            .into_keys()
6295                            .collect()
6296                    } else {
6297                        deleted_entity_access(GraphStore::get_edge_in_txn(
6298                            txn,
6299                            edge_id.expect("node_id or edge_id is Some, checked above"),
6300                        )?)?
6301                        .props
6302                        .into_keys()
6303                        .collect()
6304                    };
6305                    for key in existing_keys {
6306                        if let Some(id) = node_id {
6307                            GraphStore::remove_node_prop_in_txn(write_txn, id, &key)?;
6308                            self.count(|s| s.properties_set += 1);
6309                        }
6310                        if let Some(id) = edge_id {
6311                            GraphStore::remove_edge_prop_in_txn(write_txn, id, &key)?;
6312                            self.count(|s| s.properties_set += 1);
6313                        }
6314                    }
6315                }
6316                // Either way, apply the map's own entries -- a `null`
6317                // value removes that one key (real Cypher's rule, same
6318                // "null means remove" convention `SetItem::Prop` already
6319                // has -- TCK's Set5 [4]), anything else sets it.
6320                for (key, entry_value) in entries {
6321                    if matches!(entry_value, Value::Null) {
6322                        if let Some(id) = node_id {
6323                            GraphStore::remove_node_prop_in_txn(write_txn, id, &key)?;
6324                            self.count(|s| s.properties_set += 1);
6325                        }
6326                        if let Some(id) = edge_id {
6327                            GraphStore::remove_edge_prop_in_txn(write_txn, id, &key)?;
6328                            self.count(|s| s.properties_set += 1);
6329                        }
6330                        continue;
6331                    }
6332                    let pv = value_to_storable_property(&entry_value).ok_or_else(|| {
6333                        QueryError::Type(format!(
6334                            "property '{key}' can't be stored -- MarsDB's node/edge properties are \
6335                             limited to null/bool/int/float/string/date/duration/list; a map/node/\
6336                             edge/path value (got {entry_value:?}) isn't storable"
6337                        ))
6338                    })?;
6339                    if let Some(id) = node_id {
6340                        GraphStore::set_node_prop_in_txn(write_txn, id, &key, pv.clone())?;
6341                        self.count(|s| s.properties_set += 1);
6342                    }
6343                    if let Some(id) = edge_id {
6344                        GraphStore::set_edge_prop_in_txn(write_txn, id, &key, pv)?;
6345                        self.count(|s| s.properties_set += 1);
6346                    }
6347                }
6348            }
6349        }
6350        Ok(())
6351    }
6352}
6353
6354/// `materialize_delete`'s bare-variable fast path -- deletes straight off
6355/// the row's raw `Binding` (just an id), no existence check and no
6356/// property fetch, so re-referencing an already-deleted-this-statement
6357/// entity by identity (a later row of the same multi-row `DELETE`) is a
6358/// silent dedup no-op, not an error. Mirrors `delete_value`'s shape
6359/// (including the path/null/type-error handling) but over `Binding`/
6360/// `PathBinding` (raw ids) instead of `Value`/`PathElem` (fully
6361/// materialized records).
6362/// Deletes edge `id`, first stashing its (immutable, so safe to cache)
6363/// type into `guard` -- see `ExecutionGuard::deleted_edge_types`'s own
6364/// docs for why. The lookup can't fail with a real error here: `id` was
6365/// just read out of a live `Binding::Edge`/`PathBinding::Edge` this same
6366/// transaction, so its record is still there to fetch (deletion hasn't
6367/// happened yet -- that's the very next line).
6368fn record_and_delete_edge(
6369    executor: &Executor<'_>,
6370    txn: Txn,
6371    write_txn: &WriteTransaction,
6372    id: EdgeId,
6373    guard: &ExecutionGuard<'_>,
6374) -> Result<(), QueryError> {
6375    if let Some(edge) = GraphStore::get_edge_in_txn(txn, id)? {
6376        guard.record_deleted_edge_type(id, edge.label);
6377    }
6378    if GraphStore::delete_edge_in_txn(write_txn, id)? {
6379        executor.count(|s| s.relationships_deleted += 1);
6380    }
6381    Ok(())
6382}
6383
6384/// `delete_binding`'s collect-only twin for the batched all-bare-variable
6385/// path in `delete_targets`: identical target-shape rules (nodes pended,
6386/// path edges before path nodes, null a no-op, scalar/list/map a type
6387/// error), but edge ids go into `edge_ids` (deduped through
6388/// `deleted_edges`, preserving first-encounter order) for one
6389/// `delete_edges_in_txn` call instead of being deleted one `WriteCtx`
6390/// apiece.
6391fn collect_delete_binding(
6392    binding: &Binding,
6393    deleted_edges: &mut HashSet<EdgeId>,
6394    edge_ids: &mut Vec<EdgeId>,
6395    pending_nodes: &mut HashSet<NodeId>,
6396) -> Result<(), QueryError> {
6397    match binding {
6398        Binding::Node(id) => {
6399            pending_nodes.insert(*id);
6400        }
6401        Binding::Edge(id) => {
6402            if deleted_edges.insert(*id) {
6403                edge_ids.push(*id);
6404            }
6405        }
6406        Binding::Path(elems) => {
6407            for elem in elems {
6408                if let PathBinding::Edge(id) = elem {
6409                    if deleted_edges.insert(*id) {
6410                        edge_ids.push(*id);
6411                    }
6412                }
6413            }
6414            for elem in elems {
6415                if let PathBinding::Node(id) = elem {
6416                    pending_nodes.insert(*id);
6417                }
6418            }
6419        }
6420        // A null binding is a real, legal DELETE target -- an `OPTIONAL
6421        // MATCH` that didn't match pads its variables with null, and
6422        // deleting that is a documented no-op, not an error.
6423        Binding::Value(PropertyValue::Null) => {}
6424        Binding::Value(_) | Binding::List(_) | Binding::Map(_) => {
6425            return Err(QueryError::Type(
6426                "DELETE needs a node, relationship, or path, not a scalar/list/map".into(),
6427            ))
6428        }
6429    }
6430    Ok(())
6431}
6432
6433fn delete_binding(
6434    executor: &Executor<'_>,
6435    txn: Txn,
6436    binding: &Binding,
6437    write_txn: &WriteTransaction,
6438    deleted_edges: &mut HashSet<EdgeId>,
6439    pending_nodes: &mut HashSet<NodeId>,
6440    guard: &ExecutionGuard<'_>,
6441) -> Result<(), QueryError> {
6442    match binding {
6443        Binding::Node(id) => {
6444            pending_nodes.insert(*id);
6445        }
6446        Binding::Edge(id) => {
6447            if deleted_edges.insert(*id) {
6448                record_and_delete_edge(executor, txn, write_txn, *id, guard)?;
6449            }
6450        }
6451        Binding::Path(elems) => {
6452            for elem in elems {
6453                if let PathBinding::Edge(id) = elem {
6454                    if deleted_edges.insert(*id) {
6455                        record_and_delete_edge(executor, txn, write_txn, *id, guard)?;
6456                    }
6457                }
6458            }
6459            for elem in elems {
6460                if let PathBinding::Node(id) = elem {
6461                    pending_nodes.insert(*id);
6462                }
6463            }
6464        }
6465        // A null binding is a real, legal DELETE target -- an `OPTIONAL
6466        // MATCH` that didn't match pads its variables with null, and
6467        // deleting that is a documented no-op, not an error.
6468        Binding::Value(PropertyValue::Null) => {}
6469        Binding::Value(_) | Binding::List(_) | Binding::Map(_) => {
6470            return Err(QueryError::Type(
6471                "DELETE needs a node, relationship, or path, not a scalar/list/map".into(),
6472            ))
6473        }
6474    }
6475    Ok(())
6476}
6477
6478/// Deletes whatever `value` evaluated to -- a node, a relationship, every
6479/// node/edge in a path, or nothing at all for `null` (a documented no-op:
6480/// an `OPTIONAL MATCH` that didn't match pads its variables with null, and
6481/// deleting that is specified as silent, not an error). Anything else (a
6482/// list, a map, a bare scalar, ...) is a real `QueryError::Type` --
6483/// `DELETE`'s target must resolve to a graph element, unlike `SET`'s RHS.
6484/// Edges are deleted immediately; nodes are only collected into
6485/// `pending_nodes` -- `delete_targets` (the only caller) deletes them in
6486/// its own second pass, after every target across every row has had a
6487/// chance to delete its own edges first (see its own docs for why).
6488fn delete_value(
6489    executor: &Executor<'_>,
6490    value: &Value,
6491    write_txn: &WriteTransaction,
6492    deleted_edges: &mut HashSet<EdgeId>,
6493    pending_nodes: &mut HashSet<NodeId>,
6494    guard: &ExecutionGuard<'_>,
6495) -> Result<(), QueryError> {
6496    match value {
6497        Value::Node(n) => {
6498            pending_nodes.insert(n.id);
6499        }
6500        Value::Edge(e) => {
6501            if deleted_edges.insert(e.id) {
6502                guard.record_deleted_edge_type(e.id, e.label.clone());
6503                if GraphStore::delete_edge_in_txn(write_txn, e.id)? {
6504                    executor.count(|s| s.relationships_deleted += 1);
6505                }
6506            }
6507        }
6508        Value::Path(elems) => {
6509            for elem in elems {
6510                if let PathElem::Edge(e) = elem {
6511                    if deleted_edges.insert(e.id) {
6512                        guard.record_deleted_edge_type(e.id, e.label.clone());
6513                        if GraphStore::delete_edge_in_txn(write_txn, e.id)? {
6514                            executor.count(|s| s.relationships_deleted += 1);
6515                        }
6516                    }
6517                }
6518            }
6519            for elem in elems {
6520                if let PathElem::Node(n) = elem {
6521                    pending_nodes.insert(n.id);
6522                }
6523            }
6524        }
6525        Value::Null => {}
6526        other => {
6527            return Err(QueryError::Type(format!(
6528                "DELETE needs a node, relationship, or path, got {other:?}"
6529            )))
6530        }
6531    }
6532    Ok(())
6533}
6534
6535fn apply_remove_item(
6536    executor: &Executor<'_>,
6537    write_txn: &WriteTransaction,
6538    row: &BindingRow,
6539    item: &RemoveItem,
6540) -> Result<(), QueryError> {
6541    match item {
6542        RemoveItem::Prop(pa) => {
6543            let binding = row
6544                .get(&pa.var)
6545                .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
6546            match binding {
6547                Binding::Node(id) => {
6548                    executor.uncache_node(*id);
6549                    GraphStore::remove_node_prop_in_txn(write_txn, *id, &pa.prop)?;
6550                    executor.count(|s| s.properties_set += 1);
6551                }
6552                Binding::Edge(id) => {
6553                    GraphStore::remove_edge_prop_in_txn(write_txn, *id, &pa.prop)?;
6554                    executor.count(|s| s.properties_set += 1);
6555                }
6556                // Same null-is-a-no-op rule DELETE already follows (found
6557                // via TCK's Remove1 "Ignore null when removing property"
6558                // scenarios).
6559                Binding::Value(PropertyValue::Null) => {}
6560                Binding::Value(_) | Binding::List(_) | Binding::Map(_) | Binding::Path(_) => {
6561                    return Err(QueryError::UnboundVariable(format!(
6562                        "'{}' is a WITH-projected scalar, not a node/edge — REMOVE needs a graph binding",
6563                        pa.var
6564                    )))
6565                }
6566            }
6567        }
6568        RemoveItem::Labels(var, labels) => {
6569            let binding = row
6570                .get(var)
6571                .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
6572            match binding {
6573                Binding::Node(id) => {
6574                    executor.uncache_node(*id);
6575                    for label in labels {
6576                        GraphStore::remove_node_label_in_txn(write_txn, *id, label)?;
6577                        executor.count(|s| s.labels_removed += 1);
6578                    }
6579                }
6580                // Same null-is-a-no-op rule as the property arm above
6581                // (found via TCK's Remove2 "Ignore null when removing a
6582                // node label" scenario).
6583                Binding::Value(PropertyValue::Null) => {}
6584                _ => {
6585                    return Err(QueryError::UnboundVariable(format!(
6586                        "'{var}' isn't a node — REMOVE can only remove labels from a node"
6587                    )))
6588                }
6589            }
6590        }
6591    }
6592    Ok(())
6593}
6594
6595/// Whether `tail`'s ultimate RETURN (if it has one at all -- either
6596/// `Tail::Return` itself, or a mutating tail's trailing `ReturnTail`) is a
6597/// `RETURN DISTINCT`. Used by `execute_match`'s LIMIT pre-truncate and
6598/// scan-limit-pushdown shortcuts, both of which must NOT fire for a
6599/// DISTINCT return -- dedup can drop rows, so capping the raw input at
6600/// `limit` before it runs could return fewer than `limit` distinct rows
6601/// even when more exist.
6602fn tail_is_distinct_return(tail: &Option<Tail>) -> bool {
6603    match tail {
6604        Some(Tail::Return(_, distinct)) | Some(Tail::ReturnStar(distinct)) => *distinct,
6605        Some(Tail::Delete(_, ret))
6606        | Some(Tail::DetachDelete(_, ret))
6607        | Some(Tail::Set(_, ret))
6608        | Some(Tail::Remove(_, ret))
6609        | Some(Tail::Create(_, ret)) => ret.as_ref().is_some_and(|rt| rt.distinct),
6610        None => false,
6611    }
6612}
6613
6614/// A statement never mutates anything iff it's a `MATCH ... RETURN` with no
6615/// `DELETE`/`DETACH DELETE`/`SET` tail *and* no `MERGE` clause anywhere in
6616/// it (`MERGE (n) RETURN n` has a `Tail::Return`, but still writes whenever
6617/// it has to create — checking `tail` alone here would be a real bug, not
6618/// just an incomplete check: it would send a MERGE-that-creates through a
6619/// `ReadTransaction`, which has no `.insert`). `Statement::Create` and
6620/// every other `Tail` variant always write. Confirmed by tracing every
6621/// function reachable from pattern/WHERE/WITH evaluation: none of them
6622/// ever call a table-mutating `*_in_txn` method for a `Tail::Return`
6623/// statement with no `MERGE` clause (a label-filtered scan looks up an
6624/// existing label id, it never allocates one — allocation only happens in
6625/// `create_node_in_txn`/`create_edge_in_txn`). `Executor::execute` uses
6626/// this to decide whether to open a `ReadTransaction` (no contention with
6627/// concurrent readers or a concurrent writer) or a `WriteTransaction`.
6628/// Returns whether executing `stmt` can mutate the graph. Public so callers
6629/// which execute generated or otherwise untrusted Cypher can enforce a
6630/// read-only policy using the same classification as the executor.
6631pub fn is_read_only(stmt: &Statement) -> bool {
6632    if let Statement::Union { parts, .. } = stmt {
6633        return parts.iter().all(is_read_only);
6634    }
6635    let Statement::Match {
6636        tail: Some(Tail::Return(_, _)) | Some(Tail::ReturnStar(_)),
6637        clauses,
6638        ..
6639    } = stmt
6640    else {
6641        return false;
6642    };
6643    !clauses.iter().any(|c| {
6644        matches!(
6645            c,
6646            QueryClause::Merge(_)
6647                | QueryClause::Set(_)
6648                | QueryClause::Delete { .. }
6649                | QueryClause::Remove(_)
6650                | QueryClause::Create(_)
6651                // A procedure is opaque to MarsDB -- it might write, so
6652                // any statement calling one is conservatively treated as
6653                // non-read-only too, same reasoning `Statement::
6654                // StandaloneCall` already gets for free (it isn't a
6655                // `Statement::Match` at all, so it never matches this
6656                // function's own read-only pattern above).
6657                | QueryClause::Call(_)
6658        )
6659    })
6660}
6661
6662/// Recovers the real `&WriteTransaction` from a `Txn` for `execute_match`
6663/// tail/clause arms (`DELETE`/`SET`, both the terminal-tail and
6664/// `QueryClause::Set`'s own mid-statement form) that need `.insert`/
6665/// `.remove`, not just `Txn`'s read-only `get`/`iter`. Panics if given
6666/// `Txn::Read` — which can't happen: any of these make `is_read_only`
6667/// return `false`, so `Executor::execute` always opens a
6668/// `WriteTransaction` (and thus `Txn::Write`) before reaching this path.
6669fn require_write_txn(txn: Txn<'_>) -> &WriteTransaction {
6670    let Txn::Write(write_txn) = txn else {
6671        unreachable!(
6672            "materialize_delete/materialize_set/QueryClause::Set only reached via the \
6673             write-dispatch path in Executor::execute — is_read_only(stmt) is false for any \
6674             statement with one of these, so execute always opens a WriteTransaction for them"
6675        )
6676    };
6677    write_txn
6678}
6679
6680fn default_column_name(expr: &ReturnExpr, idx: usize) -> String {
6681    match expr {
6682        ReturnExpr::Var(v) => v.clone(),
6683        ReturnExpr::Prop(pa) => format!("{}.{}", pa.var, pa.prop),
6684        ReturnExpr::Lit(_) => format!("col{idx}"),
6685        ReturnExpr::Call { name, .. } => format!("{name}(...)"),
6686        ReturnExpr::CountStar => "count(*)".to_string(),
6687        ReturnExpr::Case { .. } => format!("case{idx}"),
6688        ReturnExpr::Arith(..) | ReturnExpr::Neg(..) => format!("col{idx}"),
6689        ReturnExpr::ListLit(..)
6690        | ReturnExpr::Index(..)
6691        | ReturnExpr::PropOf(..)
6692        | ReturnExpr::Slice(..)
6693        | ReturnExpr::ListComp { .. }
6694        | ReturnExpr::Quantifier { .. }
6695        | ReturnExpr::MapLit(..)
6696        | ReturnExpr::And(..)
6697        | ReturnExpr::Or(..)
6698        | ReturnExpr::Xor(..)
6699        | ReturnExpr::Not(..)
6700        | ReturnExpr::Compare(..)
6701        | ReturnExpr::IsNull(..)
6702        | ReturnExpr::In(..)
6703        | ReturnExpr::HasLabel(..)
6704        | ReturnExpr::PatternPredicate(..)
6705        | ReturnExpr::PatternComprehension { .. }
6706        | ReturnExpr::ExistsPattern { .. }
6707        | ReturnExpr::ExistsSubquery(_) => format!("col{idx}"),
6708    }
6709}
6710
6711/// The name a `WITH`/`RETURN` item is known by afterward — its alias, or
6712/// a name derived from the expression (its bare var name, `col{i}`, etc).
6713/// `pub(crate)` so `explain.rs` can compute the same post-`WITH`
6714/// `carried_vars` set EXPLAIN needs without executing any rows.
6715pub(crate) fn with_item_output_name((i, item): (usize, &ReturnItem)) -> String {
6716    item.alias
6717        .clone()
6718        .unwrap_or_else(|| default_column_name(&item.expr, i))
6719}
6720
6721/// True iff `expr` contains an aggregate call anywhere inside it, at any
6722/// depth — used to reject an aggregate nested inside another aggregate's
6723/// argument, or inside a non-aggregate expression's `CASE`/`Call`
6724/// arguments (an aggregate must be a return item's *entire* top-level
6725/// expression — see `validate_return_items`).
6726/// Collects every aggregate-bearing subexpression in `expr` (a `CountStar`
6727/// or an aggregate-named `Call`), in a fixed pre-order -- the same
6728/// traversal `contains_aggregate` uses, just gathering references instead
6729/// of stopping at the first `true`. Doesn't recurse *into* a found node's
6730/// own arguments (an aggregate's argument is folded per-row as a whole,
6731/// not decomposed further -- see `resolve_grouped_rows`). The resulting
6732/// order is what makes a composed item's per-row folding
6733/// (`resolve_grouped_rows`) and its per-group finishing
6734/// (`Executor::rewrite_composed_item`) agree on which accumulator is
6735/// which, without needing to name or otherwise identify individual
6736/// aggregate calls within one item's expression tree.
6737fn collect_agg_nodes<'a>(expr: &'a ReturnExpr, out: &mut Vec<&'a ReturnExpr>) {
6738    match expr {
6739        ReturnExpr::CountStar => out.push(expr),
6740        ReturnExpr::Call { name, args, .. } => {
6741            if is_aggregate_name(name) {
6742                out.push(expr);
6743            } else {
6744                for arg in args {
6745                    collect_agg_nodes(arg, out);
6746                }
6747            }
6748        }
6749        ReturnExpr::Case { test, whens, else_ } => {
6750            if let Some(t) = test.as_deref() {
6751                collect_agg_nodes(t, out);
6752            }
6753            for (w, t) in whens {
6754                collect_agg_nodes(w, out);
6755                collect_agg_nodes(t, out);
6756            }
6757            if let Some(e) = else_.as_deref() {
6758                collect_agg_nodes(e, out);
6759            }
6760        }
6761        ReturnExpr::Arith(l, _, r) => {
6762            collect_agg_nodes(l, out);
6763            collect_agg_nodes(r, out);
6764        }
6765        ReturnExpr::Neg(e) => collect_agg_nodes(e, out),
6766        ReturnExpr::ListLit(items) => {
6767            for item in items {
6768                collect_agg_nodes(item, out);
6769            }
6770        }
6771        ReturnExpr::Index(base, index) => {
6772            collect_agg_nodes(base, out);
6773            collect_agg_nodes(index, out);
6774        }
6775        ReturnExpr::PropOf(base, _) => collect_agg_nodes(base, out),
6776        ReturnExpr::Slice(base, start, end) => {
6777            collect_agg_nodes(base, out);
6778            if let Some(s) = start.as_deref() {
6779                collect_agg_nodes(s, out);
6780            }
6781            if let Some(e) = end.as_deref() {
6782                collect_agg_nodes(e, out);
6783            }
6784        }
6785        // Same `where_clause`-not-checked scope limitation as
6786        // `contains_aggregate`'s matching arm.
6787        ReturnExpr::ListComp {
6788            source, project, ..
6789        } => {
6790            collect_agg_nodes(source, out);
6791            if let Some(p) = project.as_deref() {
6792                collect_agg_nodes(p, out);
6793            }
6794        }
6795        ReturnExpr::Quantifier { source, .. } => collect_agg_nodes(source, out),
6796        ReturnExpr::MapLit(entries) => {
6797            for (_, v) in entries {
6798                collect_agg_nodes(v, out);
6799            }
6800        }
6801        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
6802            collect_agg_nodes(l, out);
6803            collect_agg_nodes(r, out);
6804        }
6805        ReturnExpr::Not(e) => collect_agg_nodes(e, out),
6806        ReturnExpr::Compare(l, _, r) => {
6807            collect_agg_nodes(l, out);
6808            collect_agg_nodes(r, out);
6809        }
6810        ReturnExpr::IsNull(e) => collect_agg_nodes(e, out),
6811        ReturnExpr::In(needle, haystack) => {
6812            collect_agg_nodes(needle, out);
6813            collect_agg_nodes(haystack, out);
6814        }
6815        ReturnExpr::Var(_)
6816        | ReturnExpr::Prop(_)
6817        | ReturnExpr::Lit(_)
6818        | ReturnExpr::HasLabel(..)
6819        | ReturnExpr::PatternPredicate(..)
6820        | ReturnExpr::PatternComprehension { .. }
6821        | ReturnExpr::ExistsPattern { .. }
6822        | ReturnExpr::ExistsSubquery(_) => {}
6823    }
6824}
6825
6826pub(crate) fn contains_aggregate(expr: &ReturnExpr) -> bool {
6827    match expr {
6828        ReturnExpr::CountStar => true,
6829        ReturnExpr::Call { name, args, .. } => {
6830            is_aggregate_name(name) || args.iter().any(contains_aggregate)
6831        }
6832        ReturnExpr::Case { test, whens, else_ } => {
6833            test.as_deref().is_some_and(contains_aggregate)
6834                || whens
6835                    .iter()
6836                    .any(|(w, t)| contains_aggregate(w) || contains_aggregate(t))
6837                || else_.as_deref().is_some_and(contains_aggregate)
6838        }
6839        ReturnExpr::Arith(l, _, r) => contains_aggregate(l) || contains_aggregate(r),
6840        ReturnExpr::Neg(e) => contains_aggregate(e),
6841        ReturnExpr::ListLit(items) => items.iter().any(contains_aggregate),
6842        ReturnExpr::Index(base, index) => contains_aggregate(base) || contains_aggregate(index),
6843        ReturnExpr::PropOf(base, _) => contains_aggregate(base),
6844        ReturnExpr::Slice(base, start, end) => {
6845            contains_aggregate(base)
6846                || start.as_deref().is_some_and(contains_aggregate)
6847                || end.as_deref().is_some_and(contains_aggregate)
6848        }
6849        // `where_clause` isn't checked -- same scope limitation as
6850        // `UnwindClause`'s own filter, which never routes through this
6851        // check either; the source/project halves are the ones a real
6852        // TCK scenario nests an aggregate in (`size([x IN collect(r) ...])`).
6853        ReturnExpr::ListComp {
6854            source, project, ..
6855        } => contains_aggregate(source) || project.as_deref().is_some_and(contains_aggregate),
6856        ReturnExpr::Quantifier { source, .. } => contains_aggregate(source),
6857        ReturnExpr::MapLit(entries) => entries.iter().any(|(_, v)| contains_aggregate(v)),
6858        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
6859            contains_aggregate(l) || contains_aggregate(r)
6860        }
6861        ReturnExpr::Not(e) => contains_aggregate(e),
6862        ReturnExpr::Compare(l, _, r) => contains_aggregate(l) || contains_aggregate(r),
6863        ReturnExpr::IsNull(e) => contains_aggregate(e),
6864        ReturnExpr::In(needle, haystack) => {
6865            contains_aggregate(needle) || contains_aggregate(haystack)
6866        }
6867        ReturnExpr::Var(_)
6868        | ReturnExpr::Prop(_)
6869        | ReturnExpr::Lit(_)
6870        | ReturnExpr::HasLabel(..)
6871        | ReturnExpr::PatternPredicate(..)
6872        // A pattern comprehension's projection runs against its own
6873        // per-match row, not the outer query's group -- an aggregate
6874        // inside it wouldn't mean "aggregate across the outer group,"
6875        // it'd need its own separate grouping concept this codebase
6876        // doesn't have, so (like `PatternPredicate`) it's opaque here
6877        // rather than searched into.
6878        | ReturnExpr::PatternComprehension { .. }
6879        | ReturnExpr::ExistsPattern { .. }
6880        | ReturnExpr::ExistsSubquery(_) => false,
6881    }
6882}
6883
6884/// True iff any item's top-level expression is an aggregate call —
6885/// `materialize_with`/`materialize_return` dispatch to the grouping path
6886/// iff this is true, otherwise the existing row-at-a-time path runs
6887/// completely unchanged (zero perf/behavior impact on non-aggregating
6888/// queries).
6889/// `try_fast_expand_expand_count`'s direction support: single concrete
6890/// direction only — `Either` needs the two-call-plus-dedupe treatment the
6891/// generic path does, out of the fast path's scope.
6892fn fast_direction(dir: ExpandDirection) -> Option<Direction> {
6893    match dir {
6894        ExpandDirection::Out => Some(Direction::Out),
6895        ExpandDirection::In => Some(Direction::In),
6896        ExpandDirection::Either => None,
6897    }
6898}
6899
6900/// Single-type (`Some`) or untyped (`None`) relationship filter — the
6901/// multi-type `[:A|B]` list needs per-type iteration, out of scope.
6902/// Outer `None` = unsupported shape, inner `Option` = the filter itself.
6903#[allow(clippy::option_option)]
6904fn fast_label(labels: &[String]) -> Option<Option<&str>> {
6905    match labels {
6906        [] => Some(None),
6907        [one] => Some(Some(one.as_str())),
6908        _ => None,
6909    }
6910}
6911
6912/// Does this (sub)plan contain any expansion or externally-seeded input?
6913/// The fast path evaluates its leaf through the generic stream, but only
6914/// when the leaf is a pure scan/seek/filter chain.
6915fn plan_contains_expansion(plan: &LogicalPlan) -> bool {
6916    match plan {
6917        LogicalPlan::Expand { .. }
6918        | LogicalPlan::VarExpand { .. }
6919        | LogicalPlan::MatchRelList { .. }
6920        | LogicalPlan::EdgeTypeScan { .. }
6921        | LogicalPlan::Seed { .. } => true,
6922        LogicalPlan::Filter { input, .. } => plan_contains_expansion(input),
6923        LogicalPlan::IndexRangeSeek { .. }
6924        | LogicalPlan::AllNodesScan { .. }
6925        | LogicalPlan::NodeByLabelScan { .. }
6926        | LogicalPlan::IndexSeek { .. } => false,
6927    }
6928}
6929
6930pub(crate) fn has_aggregate(items: &[ReturnItem]) -> bool {
6931    // `contains_aggregate`, not a narrower "is the item's whole top-level
6932    // expression itself an aggregate call" check -- an aggregate nested
6933    // inside a wrapping expression (`1 + count(x)`, real Cypher composition
6934    // -- see `resolve_grouped_rows`) still needs to route to the grouping
6935    // path, both to actually compute it and so `validate_return_items` gets
6936    // a chance to reject an invalid composition with a clear error. A
6937    // narrower top-level-only check here would let such a query silently
6938    // take the ordinary per-row path instead (iterating `rows` directly,
6939    // which is empty for an empty MATCH), producing the wrong row count
6940    // instead of the right (or correctly rejected) one.
6941    items.iter().any(|item| contains_aggregate(&item.expr))
6942}
6943
6944/// True iff `expr` contains a call to `rand()` anywhere inside it, at any
6945/// depth -- same traversal shape as `contains_aggregate`, used only to
6946/// reject `rand()` as (part of) an aggregate's own argument (see
6947/// `validate_return_items`); `rand()` elsewhere in a query is completely
6948/// fine.
6949fn contains_rand_call(expr: &ReturnExpr) -> bool {
6950    match expr {
6951        ReturnExpr::Call { name, args, .. } => {
6952            name.eq_ignore_ascii_case("rand") || args.iter().any(contains_rand_call)
6953        }
6954        ReturnExpr::Case { test, whens, else_ } => {
6955            test.as_deref().is_some_and(contains_rand_call)
6956                || whens
6957                    .iter()
6958                    .any(|(w, t)| contains_rand_call(w) || contains_rand_call(t))
6959                || else_.as_deref().is_some_and(contains_rand_call)
6960        }
6961        ReturnExpr::Arith(l, _, r) => contains_rand_call(l) || contains_rand_call(r),
6962        ReturnExpr::Neg(e) => contains_rand_call(e),
6963        ReturnExpr::ListLit(items) => items.iter().any(contains_rand_call),
6964        ReturnExpr::Index(base, index) => contains_rand_call(base) || contains_rand_call(index),
6965        ReturnExpr::PropOf(base, _) => contains_rand_call(base),
6966        ReturnExpr::Slice(base, start, end) => {
6967            contains_rand_call(base)
6968                || start.as_deref().is_some_and(contains_rand_call)
6969                || end.as_deref().is_some_and(contains_rand_call)
6970        }
6971        ReturnExpr::ListComp {
6972            source, project, ..
6973        } => contains_rand_call(source) || project.as_deref().is_some_and(contains_rand_call),
6974        ReturnExpr::Quantifier { source, .. } => contains_rand_call(source),
6975        ReturnExpr::MapLit(entries) => entries.iter().any(|(_, v)| contains_rand_call(v)),
6976        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
6977            contains_rand_call(l) || contains_rand_call(r)
6978        }
6979        ReturnExpr::Not(e) => contains_rand_call(e),
6980        ReturnExpr::Compare(l, _, r) => contains_rand_call(l) || contains_rand_call(r),
6981        ReturnExpr::IsNull(e) => contains_rand_call(e),
6982        ReturnExpr::In(needle, haystack) => {
6983            contains_rand_call(needle) || contains_rand_call(haystack)
6984        }
6985        ReturnExpr::CountStar
6986        | ReturnExpr::Var(_)
6987        | ReturnExpr::Prop(_)
6988        | ReturnExpr::Lit(_)
6989        | ReturnExpr::HasLabel(..)
6990        | ReturnExpr::PatternPredicate(..)
6991        // Same opaque treatment as `contains_aggregate`'s own arm above --
6992        // a pattern comprehension's projection is checked once it's
6993        // actually evaluated per match, not searched into ahead of time.
6994        | ReturnExpr::PatternComprehension { .. }
6995        | ReturnExpr::ExistsPattern { .. }
6996        | ReturnExpr::ExistsSubquery(_) => false,
6997    }
6998}
6999
7000/// `RETURN *`/`RETURN DISTINCT *` resolved into the equivalent concrete
7001/// item list -- one bare-`Var` item per currently-bound name, sorted
7002/// alphabetically (real Cypher's own `RETURN *` column order, confirmed
7003/// against the TCK's own multi-variable scenarios, not introduction
7004/// order). Shared by `semantic.rs` (`scope.keys()`) and this file's own
7005/// `execute_match` (`carried_vars`) -- each already has its own accurate
7006/// bound-name set on hand at the point `Tail::ReturnStar` is reached, so
7007/// resolving it there (rather than via a separate whole-AST-mutation
7008/// pass before execution) needs no `&mut Statement` ripple through
7009/// `Executor::execute`'s public signature. Real Cypher's own
7010/// `NoVariablesInScope` compile-time error when nothing is bound at all
7011/// (TCK's Return7 `[2]`, `MATCH () RETURN *`). `WITH *` doesn't share this
7012/// restriction -- an empty `WITH *` is a legal, if useless, "carry forward
7013/// nothing" no-op (TCK's Create3 `[2]`/`[3]`: `MATCH () CREATE () WITH *
7014/// CREATE ()`, every token anonymous) -- see `with_star_items` below.
7015pub(crate) fn return_star_items(
7016    names: impl Iterator<Item = String>,
7017) -> Result<Vec<ReturnItem>, QueryError> {
7018    let names: Vec<String> = names.collect();
7019    if names.is_empty() {
7020        return Err(QueryError::Semantic(
7021            "RETURN * needs at least one variable in scope".into(),
7022        ));
7023    }
7024    Ok(star_items(names))
7025}
7026
7027/// `WITH *`'s own version of `return_star_items` -- same alphabetical
7028/// `Var`-per-name expansion, but tolerates an empty name set instead of
7029/// erroring (see that function's docs for why the two differ).
7030pub(crate) fn with_star_items(names: impl Iterator<Item = String>) -> Vec<ReturnItem> {
7031    star_items(names.collect())
7032}
7033
7034fn star_items(mut names: Vec<String>) -> Vec<ReturnItem> {
7035    names.sort();
7036    names
7037        .into_iter()
7038        .map(|name| ReturnItem {
7039            expr: ReturnExpr::Var(name),
7040            alias: None,
7041        })
7042        .collect()
7043}
7044
7045/// Validates a RETURN/WITH item list before any row is processed. Two
7046/// checks, both real Cypher compile-time errors:
7047///
7048/// - Every aggregate call (found anywhere -- not just a return item's
7049///   whole top-level expression, since `RETURN a, count(a) + 3`-style
7050///   composition is real Cypher, TCK's Return6 `[2]` etc) has the right
7051///   number of arguments, doesn't nest another aggregate inside its own
7052///   argument (`NestedAggregation`), and isn't given a non-deterministic
7053///   argument like `rand()` (`NonConstantExpression`).
7054/// - Once *any* item aggregates, every other item's own non-aggregate
7055///   leaf (a bare `Var`/`Prop` used outside any aggregate call) must
7056///   match some *other* item's whole top-level expression verbatim
7057///   (`AmbiguousAggregationExpression`, TCK's Return6 `[20]`/`[21]`) --
7058///   real Cypher's rule that a value used alongside an aggregate must
7059///   itself be an explicit grouping key, not just something that happens
7060///   to be in scope. A literal/param is always fine (same value on every
7061///   row, nothing to group by). This is checked by recursing into every
7062///   item whose expression contains an aggregate anywhere, stopping at
7063///   each aggregate-bearing subexpression itself (its own argument
7064///   doesn't need to be grouping-key-safe -- it's folded per row).
7065pub(crate) fn validate_return_items(items: &[ReturnItem]) -> Result<(), QueryError> {
7066    for item in items {
7067        if contains_aggregate(&item.expr) {
7068            validate_composed_expr(&item.expr, items)?;
7069        }
7070    }
7071    Ok(())
7072}
7073
7074/// Whether `expr` (a leaf found inside some *other* composed expression)
7075/// refers to `item` -- either structurally (`item.expr == *expr`) or, for
7076/// a bare `Var`, by `item`'s own output *alias* (`RETURN me.age AS age
7077/// ... ORDER BY age + count(...)`, TCK's ReturnOrderBy6 `[2]`: `age`
7078/// alone doesn't structurally equal `me.age`, but it's still exactly
7079/// item `age`'s value). Shared by `validate_composed_expr`'s compile-time
7080/// check and `Executor::rewrite_composed_item`'s matching runtime lookup
7081/// -- both need to agree on what counts as "the same grouping key,"
7082/// including this by-alias case, or one would accept what the other
7083/// can't actually evaluate.
7084pub(crate) fn item_matches_leaf(expr: &ReturnExpr, index: usize, item: &ReturnItem) -> bool {
7085    item.expr == *expr
7086        || matches!(expr, ReturnExpr::Var(name) if *name == with_item_output_name((index, item)))
7087}
7088
7089pub(crate) fn validate_composed_expr(
7090    expr: &ReturnExpr,
7091    items: &[ReturnItem],
7092) -> Result<(), QueryError> {
7093    if matches!(expr, ReturnExpr::CountStar) {
7094        return Ok(());
7095    }
7096    if let ReturnExpr::Call { name, args, .. } = expr {
7097        if is_aggregate_name(name) {
7098            // `percentileCont`/`percentileDisc` take a second argument
7099            // (the percentile) alongside the value being aggregated —
7100            // every other aggregate takes exactly one.
7101            let expected_args = if is_percentile_name(name) { 2 } else { 1 };
7102            if args.len() != expected_args {
7103                return Err(QueryError::Semantic(if expected_args == 2 {
7104                    format!("{name}() takes exactly two arguments (the value, then the percentile)")
7105                } else {
7106                    format!(
7107                        "{name}() takes exactly one argument (use count(*) for a row count with no argument)"
7108                    )
7109                }));
7110            }
7111            for arg in args {
7112                if contains_aggregate(arg) {
7113                    return Err(QueryError::Semantic(format!(
7114                        "aggregate function '{name}' can't take another aggregate as an argument"
7115                    )));
7116                }
7117                // `count(rand())` etc -- an aggregate's argument must be
7118                // deterministic per row for grouping/re-execution to have
7119                // well-defined semantics, which `rand()` (a fresh value on
7120                // every call, see its own docs) fundamentally breaks. Real
7121                // Cypher rejects this at compile time (TCK's Return6
7122                // [15], `NonConstantExpression`), not just "whatever value
7123                // it happens to produce."
7124                if contains_rand_call(arg) {
7125                    return Err(QueryError::Semantic(format!(
7126                        "aggregate function '{name}' can't take a non-deterministic expression \
7127                         (e.g. rand()) as an argument"
7128                    )));
7129                }
7130            }
7131            return Ok(());
7132        }
7133    }
7134    if matches!(expr, ReturnExpr::Var(_) | ReturnExpr::Prop(_)) {
7135        let is_grouping_key = items
7136            .iter()
7137            .enumerate()
7138            .any(|(i, it)| item_matches_leaf(expr, i, it) && !contains_aggregate(&it.expr));
7139        return if is_grouping_key {
7140            Ok(())
7141        } else {
7142            Err(QueryError::Semantic(format!(
7143                "{expr:?} is used alongside an aggregate function but isn't itself one of this \
7144                 RETURN/WITH's own items -- once any item aggregates, every other value used \
7145                 with it must be listed as its own explicit grouping key"
7146            )))
7147        };
7148    }
7149    // `Lit`/`HasLabel`/`PatternPredicate`/`PatternComprehension` need no
7150    // check here: a literal is the same value on every row (nothing to
7151    // group by), and the other three are opaque leaves for this same
7152    // reason `contains_aggregate`/`collect_agg_nodes` treat them that way
7153    // (see their own docs) -- not reachable with real content to check
7154    // since none can themselves contain an aggregate.
7155    match expr {
7156        ReturnExpr::Case { test, whens, else_ } => {
7157            if let Some(t) = test.as_deref() {
7158                validate_composed_expr(t, items)?;
7159            }
7160            for (w, t) in whens {
7161                validate_composed_expr(w, items)?;
7162                validate_composed_expr(t, items)?;
7163            }
7164            if let Some(e) = else_.as_deref() {
7165                validate_composed_expr(e, items)?;
7166            }
7167        }
7168        ReturnExpr::Call { args, .. } => {
7169            for arg in args {
7170                validate_composed_expr(arg, items)?;
7171            }
7172        }
7173        ReturnExpr::Arith(l, _, r) => {
7174            validate_composed_expr(l, items)?;
7175            validate_composed_expr(r, items)?;
7176        }
7177        ReturnExpr::Neg(e) => validate_composed_expr(e, items)?,
7178        ReturnExpr::ListLit(list_items) => {
7179            for item in list_items {
7180                validate_composed_expr(item, items)?;
7181            }
7182        }
7183        ReturnExpr::Index(base, index) => {
7184            validate_composed_expr(base, items)?;
7185            validate_composed_expr(index, items)?;
7186        }
7187        ReturnExpr::PropOf(base, _) => validate_composed_expr(base, items)?,
7188        ReturnExpr::Slice(base, start, end) => {
7189            validate_composed_expr(base, items)?;
7190            if let Some(s) = start.as_deref() {
7191                validate_composed_expr(s, items)?;
7192            }
7193            if let Some(e) = end.as_deref() {
7194                validate_composed_expr(e, items)?;
7195            }
7196        }
7197        // `source` may itself be a (possibly composed) aggregate --
7198        // `[x IN collect(p) | head(nodes(x))]` aggregates once per group
7199        // to build the list, then the comprehension iterates its result
7200        // normally (TCK's List12 [4]/[5], real and required) -- recursed
7201        // into below via the generic `Call`/`Arith`/etc. machinery, same
7202        // as any other composed leaf. `project`, in contrast, runs once
7203        // *per element* of that already-built list -- an aggregate
7204        // there has no defined semantics at all (real Cypher flatly
7205        // rejects it, TCK's List12 [7], "Fail when using aggregation in
7206        // list comprehension") and `resolve_grouped_rows` has no
7207        // "fold once per group, then run per element" fold shape for it
7208        // anyway, so it's checked directly here rather than falling
7209        // through to the generic recursion below, which would otherwise
7210        // validate (and `rewrite_composed_item` would then evaluate) a
7211        // nested aggregate as if it were an ordinary composed leaf.
7212        ReturnExpr::ListComp {
7213            source,
7214            project,
7215            where_clause,
7216            ..
7217        } => {
7218            if project.as_deref().is_some_and(contains_aggregate) {
7219                return Err(QueryError::Semantic(
7220                    "an aggregate function can't be used inside a list comprehension's projection"
7221                        .into(),
7222                ));
7223            }
7224            validate_composed_expr(source, items)?;
7225            // `where_clause` isn't checked -- same scope limitation as
7226            // `contains_aggregate`'s own matching arm.
7227            let _ = where_clause;
7228        }
7229        ReturnExpr::Quantifier { source, .. } => validate_composed_expr(source, items)?,
7230        ReturnExpr::MapLit(entries) => {
7231            for (_, v) in entries {
7232                validate_composed_expr(v, items)?;
7233            }
7234        }
7235        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
7236            validate_composed_expr(l, items)?;
7237            validate_composed_expr(r, items)?;
7238        }
7239        ReturnExpr::Not(e) => validate_composed_expr(e, items)?,
7240        ReturnExpr::Compare(l, _, r) => {
7241            validate_composed_expr(l, items)?;
7242            validate_composed_expr(r, items)?;
7243        }
7244        ReturnExpr::IsNull(e) => validate_composed_expr(e, items)?,
7245        ReturnExpr::In(needle, haystack) => {
7246            validate_composed_expr(needle, items)?;
7247            validate_composed_expr(haystack, items)?;
7248        }
7249        ReturnExpr::CountStar
7250        | ReturnExpr::Var(_)
7251        | ReturnExpr::Prop(_)
7252        | ReturnExpr::Lit(_)
7253        | ReturnExpr::HasLabel(..)
7254        | ReturnExpr::PatternPredicate(..)
7255        | ReturnExpr::PatternComprehension { .. }
7256        | ReturnExpr::ExistsPattern { .. }
7257        | ReturnExpr::ExistsSubquery(_) => {}
7258    }
7259    Ok(())
7260}
7261
7262/// Same rules as `validate_composed_expr` (reused directly, first), plus
7263/// one more real Cypher only enforces for an ORDER BY key specifically,
7264/// not for a RETURN/WITH item's own composed expression: every
7265/// aggregate-bearing subexpression found anywhere in it must itself
7266/// verbatim/alias-match some existing RETURN/WITH item (TCK's
7267/// WithOrderBy4 `[14]`, "Fail on sorting by a non-projected aggregation
7268/// on an expression" -- `ORDER BY sum(x)` when the WITH only computes
7269/// `min(x)`, a *different* aggregate over the same argument, is a real
7270/// compile-time error, not "just fold it separately"). A RETURN/WITH
7271/// item's own composed expression has no such restriction -- `RETURN a,
7272/// count(a) + sum(b)` folds both `count(a)` and `sum(b)` fresh as part of
7273/// evaluating that one item, with nothing else either needs to match.
7274pub(crate) fn validate_order_by_composed_expr(
7275    expr: &ReturnExpr,
7276    items: &[ReturnItem],
7277) -> Result<(), QueryError> {
7278    validate_composed_expr(expr, items)?;
7279    let mut agg_nodes = Vec::new();
7280    collect_agg_nodes(expr, &mut agg_nodes);
7281    for node in agg_nodes {
7282        let matches_item = items
7283            .iter()
7284            .enumerate()
7285            .any(|(i, it)| item_matches_leaf(node, i, it));
7286        if !matches_item {
7287            return Err(QueryError::Semantic(
7288                "ORDER BY aggregate does not match any RETURN/WITH item".into(),
7289            ));
7290        }
7291    }
7292    Ok(())
7293}
7294
7295/// Grouping-key hashing — deliberately at the `Binding` level (`NodeId`/
7296/// `EdgeId`/`PropertyValue`), not `Value`: cheaper (no `GraphStore` fetch
7297/// just to compute) and the correct semantics (two `Binding::Node`s are
7298/// the same group iff the same node **identity**, not equal-by-struct-
7299/// contents). `Binding::List`'s elements are `Value`s already, so those
7300/// delegate to `value_hash_key` directly.
7301fn binding_hash_key(b: &Binding) -> Result<HashKey, QueryError> {
7302    Ok(match b {
7303        Binding::Node(id) => HashKey::Node(*id),
7304        Binding::Edge(id) => HashKey::Edge(*id),
7305        Binding::Value(pv) => property_value_hash_key(pv),
7306        Binding::List(items) => HashKey::List(
7307            items
7308                .iter()
7309                .map(value_hash_key)
7310                .collect::<Result<Vec<_>, _>>()?,
7311        ),
7312        // A path's identity is its exact node/edge sequence, in order --
7313        // same graph-identity-by-id convention as the `Node`/`Edge` arms
7314        // above, just walked element-by-element (found via TCK's
7315        // Pattern2 [8]: `WITH [p = (n)-->() | p] AS ps, count(b) AS c`
7316        // makes `ps` -- a list of paths -- an implicit GROUP BY key,
7317        // real Cypher's own rule that every non-aggregate WITH/RETURN
7318        // item groups by).
7319        Binding::Path(elems) => HashKey::List(
7320            elems
7321                .iter()
7322                .map(|e| match e {
7323                    PathBinding::Node(id) => HashKey::Node(*id),
7324                    PathBinding::Edge(id) => HashKey::Edge(*id),
7325                })
7326                .collect(),
7327        ),
7328        // Same canonical-sorted-entries encoding as `value_hash_key`'s
7329        // matching `Value::Map` arm (a `BTreeMap` already iterates in
7330        // sorted key order).
7331        Binding::Map(m) => HashKey::List(
7332            m.iter()
7333                .map(|(k, v)| -> Result<HashKey, QueryError> {
7334                    Ok(HashKey::List(vec![
7335                        HashKey::Str(k.clone()),
7336                        value_hash_key(v)?,
7337                    ]))
7338                })
7339                .collect::<Result<Vec<_>, _>>()?,
7340        ),
7341    })
7342}
7343
7344/// Projects one of `ProcedureProvider::call`'s raw output rows (positional,
7345/// `sig.outputs.len()` values in that order) down to whatever `yield_items`
7346/// actually asked for -- `YIELD *` keeps every output under its own name;
7347/// an explicit item list picks out just those (by the procedure's own
7348/// declared name, not any rename yet) and pairs each with its `AS` alias
7349/// if it had one, same output order the `YIELD` itself was written in
7350/// (TCK's Call5 `[3]`: order is irrelevant to the *result*, but this still
7351/// preserves whatever order was written, which `materialize_return`-style
7352/// column ordering downstream expects to already be correct).
7353fn project_call_row(
7354    sig: &ProcedureSignature,
7355    proc_row: &[Value],
7356    yield_items: &CallYield,
7357) -> Result<Vec<Value>, QueryError> {
7358    match yield_items {
7359        CallYield::Star => Ok(proc_row.to_vec()),
7360        CallYield::Items(items, _) => items
7361            .iter()
7362            .map(|(name, _)| {
7363                let idx = sig.outputs.iter().position(|o| o == name).ok_or_else(|| {
7364                    QueryError::Semantic(format!(
7365                        "'{name}' isn't a declared output of this procedure"
7366                    ))
7367                })?;
7368                Ok(proc_row[idx].clone())
7369            })
7370            .collect(),
7371    }
7372}
7373
7374/// Coarse compile-time-shaped argument-type check (TCK's Call2
7375/// `[5]`/`[6]`: passing a `BOOLEAN` where `INTEGER` is declared must
7376/// error, even against an empty mock table that would otherwise just
7377/// silently return zero rows). `Value::Null` always matches regardless of
7378/// declared type -- every signature this codebase's own callers declare
7379/// is nullable (`INTEGER?` etc, TCK's Call4), and there's no dedicated
7380/// non-null marker to check against anyway. An unrecognized type name is
7381/// tolerated (accepts anything) rather than rejected -- this is a coarse
7382/// sanity check for the handful of type names TCK's own procedures
7383/// actually declare (`INTEGER`/`FLOAT`/`NUMBER`/`STRING`/`BOOLEAN`), not a
7384/// full type system.
7385fn value_matches_declared_type(value: &Value, declared: &str) -> bool {
7386    if matches!(value, Value::Null) {
7387        return true;
7388    }
7389    let is_int = matches!(
7390        value,
7391        Value::Literal(Literal::Int(_)) | Value::Property(PropertyValue::Int(_))
7392    );
7393    let is_float = matches!(
7394        value,
7395        Value::Literal(Literal::Float(_)) | Value::Property(PropertyValue::Float(_))
7396    );
7397    match declared.trim_end_matches('?').to_ascii_uppercase().as_str() {
7398        "INTEGER" => is_int,
7399        "FLOAT" | "NUMBER" => is_int || is_float,
7400        "STRING" => matches!(
7401            value,
7402            Value::Literal(Literal::String(_)) | Value::Property(PropertyValue::String(_))
7403        ),
7404        "BOOLEAN" => matches!(
7405            value,
7406            Value::Literal(Literal::Bool(_)) | Value::Property(PropertyValue::Bool(_))
7407        ),
7408        _ => true,
7409    }
7410}
7411
7412/// Converts a finished `AggAcc::finish()` result to the `Binding` it's
7413/// carried as through a `WITH` boundary — `collect()`'s `Value::List`
7414/// needs `Binding::List`, not `Binding::Value(PropertyValue::List(_))`:
7415/// `Binding::List` carries full `Value` elements (a `Node`/`Edge`'s real
7416/// id, restorable graph identity), while `PropertyValue::List` is the
7417/// flatter, storage-format shape (scalar elements only) -- collapsing a
7418/// `collect()` of nodes down to that would lose the ability to keep
7419/// traversing from them after the `WITH`. Everything else collapses to
7420/// `Binding::Value` same as any other computed WITH item.
7421fn value_to_binding(v: Value) -> Binding {
7422    match v {
7423        Value::List(items) => Binding::List(items),
7424        Value::Map(m) => Binding::Map(m),
7425        other => Binding::Value(value_to_property_value(&other)),
7426    }
7427}
7428
7429/// `UNWIND`'s counterpart to `value_to_binding` — restores graph identity
7430/// from a `collect()`'d element instead of collapsing it. `Value::Node`/
7431/// `Edge` carry their full `id`, so this isn't lossy the way carrying only
7432/// a display value would be: a `MATCH` after the `UNWIND` can keep
7433/// traversing from the restored `Binding::Node`/`Edge`, exactly as if it
7434/// had been bound by a fresh scan/expand. See `Binding::List`'s docs,
7435/// which anticipated this exact restoration.
7436fn value_to_binding_restore(v: &Value) -> Binding {
7437    match v {
7438        Value::Node(n) => Binding::Node(n.id),
7439        Value::Edge(e) => Binding::Edge(e.id),
7440        Value::Property(pv) => Binding::Value(pv.clone()),
7441        Value::Literal(lit) => Binding::Value(literal_to_value(lit)),
7442        Value::List(items) => Binding::List(items.clone()),
7443        Value::Map(m) => Binding::Map(m.clone()),
7444        Value::Path(elems) => Binding::Path(elems.iter().map(path_elem_to_binding).collect()),
7445        Value::Null => Binding::Value(PropertyValue::Null),
7446    }
7447}
7448
7449fn path_elem_to_binding(elem: &PathElem) -> PathBinding {
7450    match elem {
7451        PathElem::Node(n) => PathBinding::Node(n.id),
7452        PathElem::Edge(e) => PathBinding::Edge(e.id),
7453    }
7454}
7455
7456/// When a path is being captured, every hop's rel/node needs a trackable
7457/// binding even if the user left it anonymous — `Expand` only inserts a
7458/// `rel_var` into the row `if let Some(rv) = rel_var`, silently dropping
7459/// anonymous rels, which is fine for ordinary matching but loses exactly
7460/// the information path assembly needs. Returns a clone of `pattern` with
7461/// every position named (synthesizing `__path_elemN` for anything
7462/// anonymous), plus the set of names that were synthesized so
7463/// `execute_match` can strip them from the row again after `assemble_path`
7464/// runs — they were never something the user could reference. Only this
7465/// renamed clone is used for plan-building/OPTIONAL-MATCH null-padding
7466/// bookkeeping *within this one clause*; `carried_vars` (what's exposed to
7467/// later clauses) is still computed from the original `part.pattern`
7468/// elsewhere, so synthesized names never leak past this function's caller.
7469fn name_pattern_for_path(pattern: &Pattern) -> (Pattern, HashSet<String>) {
7470    fn fresh(counter: &mut usize, synthesized: &mut HashSet<String>) -> String {
7471        *counter += 1;
7472        let name = format!("__path_elem{counter}");
7473        synthesized.insert(name.clone());
7474        name
7475    }
7476    let mut counter = 0usize;
7477    let mut synthesized = HashSet::new();
7478    let mut start = pattern.start.clone();
7479    if start.var.is_none() {
7480        start.var = Some(fresh(&mut counter, &mut synthesized));
7481    }
7482    let hops = pattern
7483        .hops
7484        .iter()
7485        .map(|(rel, node)| {
7486            let mut rel = rel.clone();
7487            if rel.hop_range.is_some() {
7488                // A variable-length hop's own internally-traversed edges
7489                // are exposed via a fresh synthesized binding name (same
7490                // `fresh()` mechanism as every other anonymous token
7491                // here, so multiple variable-length hops in one pattern
7492                // each get their own, no collision -- TCK's Match6
7493                // `[17]`), read by `planner::build_match_plan` (its
7494                // `VarExpand`'s `path_segment_var`) and `assemble_path`.
7495                // The user's own real rel-list variable, if this hop had
7496                // one (`p = (a)-[r*1..3]->(b)`, TCK's Match9 `[9]`), is
7497                // preserved separately in `rel_list_var` rather than lost
7498                // to this overwrite -- `var` itself is always this hop's
7499                // internal path-segment bookkeeping name from here on.
7500                rel.rel_list_var = rel.var.take();
7501                rel.var = Some(fresh(&mut counter, &mut synthesized));
7502                rel.capture_path_segment = true;
7503            } else if rel.var.is_none() {
7504                rel.var = Some(fresh(&mut counter, &mut synthesized));
7505            }
7506            let mut node = node.clone();
7507            if node.var.is_none() {
7508                node.var = Some(fresh(&mut counter, &mut synthesized));
7509            }
7510            (rel, node)
7511        })
7512        .collect();
7513    (Pattern { start, hops }, synthesized)
7514}
7515
7516/// Assembles a `Binding::Path` from `pattern`'s (fully-named, via
7517/// `name_pattern_for_path`) start/hop variables, in pattern order. Falls
7518/// back to `Binding::Value(Null)` — never errors — if any position isn't a
7519/// real node/edge binding, which only happens when this row came from
7520/// `OPTIONAL MATCH` null-padding (every position `name_pattern_for_path`
7521/// named is guaranteed present in the row either way, as a real binding or
7522/// as `Binding::Value(Null)`, so "missing key" isn't a case this needs to
7523/// handle) — same "no match survives as Null, not a dropped row" outcome
7524/// `OPTIONAL MATCH` already gives every other variable.
7525fn assemble_path(pattern: &Pattern, row: &BindingRow) -> Binding {
7526    let Some(start_id) = path_node_id(pattern.start.var.as_deref(), row) else {
7527        return Binding::Value(PropertyValue::Null);
7528    };
7529    let mut elems = vec![PathBinding::Node(start_id)];
7530    for (rel, node) in &pattern.hops {
7531        if rel.capture_path_segment {
7532            // A variable-length hop's own segment, deposited by
7533            // `expand_variable_row` under this hop's own synthesized
7534            // `rel.var` -- already the exact alternating Edge/Node/.../
7535            // Node sequence this hop contributes, ending at `node`'s own
7536            // binding (so no separate `path_node_id(node.var, ...)` read
7537            // is needed after this).
7538            let Some(Binding::Path(segment)) = rel.var.as_deref().and_then(|v| row.get(v)) else {
7539                return Binding::Value(PropertyValue::Null);
7540            };
7541            elems.extend(segment.iter().cloned());
7542            continue;
7543        }
7544        let Some(edge_id) = path_edge_id(rel.var.as_deref(), row) else {
7545            return Binding::Value(PropertyValue::Null);
7546        };
7547        let Some(node_id) = path_node_id(node.var.as_deref(), row) else {
7548            return Binding::Value(PropertyValue::Null);
7549        };
7550        elems.push(PathBinding::Edge(edge_id));
7551        elems.push(PathBinding::Node(node_id));
7552    }
7553    Binding::Path(elems)
7554}
7555
7556/// `[r:TYPE*1..3]`'s own `r` -- real Cypher binds the traversed
7557/// relationships as a *list*, fully materialized (not just ids the way
7558/// `path_segment_var`'s cheaper `Binding::Path` segment stays), since
7559/// `Binding::List` -- like every other post-projection value shape --
7560/// only ever holds already-resolved `Value`s (TCK's Match4 `[1]`/`[6]`).
7561fn segment_edges_to_list(txn: Txn, segment: &[PathBinding]) -> Result<Binding, QueryError> {
7562    let edges = segment
7563        .iter()
7564        .filter_map(|elem| match elem {
7565            PathBinding::Edge(id) => Some(*id),
7566            PathBinding::Node(_) => None,
7567        })
7568        .map(|id| {
7569            let edge = deleted_entity_access(GraphStore::get_edge_in_txn(txn, id)?)?;
7570            Ok(Value::Edge(edge))
7571        })
7572        .collect::<Result<Vec<_>, QueryError>>()?;
7573    Ok(Binding::List(edges))
7574}
7575
7576fn path_node_id(var: Option<&str>, row: &BindingRow) -> Option<NodeId> {
7577    match var.and_then(|v| row.get(v)) {
7578        Some(Binding::Node(id)) => Some(*id),
7579        _ => None,
7580    }
7581}
7582
7583fn path_edge_id(var: Option<&str>, row: &BindingRow) -> Option<EdgeId> {
7584    match var.and_then(|v| row.get(v)) {
7585        Some(Binding::Edge(id)) => Some(*id),
7586        _ => None,
7587    }
7588}
7589
7590fn require_bound_node(row: &BindingRow, var: &str) -> Result<NodeId, QueryError> {
7591    match row.get(var) {
7592        Some(Binding::Node(id)) => Ok(*id),
7593        _ => Err(QueryError::UnboundVariable(format!(
7594            "'{var}' must already be bound to a node before shortestPath() — match it in a preceding MATCH"
7595        ))),
7596    }
7597}
7598
7599/// Walks `parent` (populated by `shortest_path_between`'s BFS) backward
7600/// from `end` to `start`, then reverses — `parent` only ever needs to
7601/// answer "how did BFS first reach this node," not support any other
7602/// traversal, so a plain `HashMap` (not a `LogicalPlan`/adjacency
7603/// structure) is enough.
7604fn reconstruct_path(
7605    parent: &HashMap<NodeId, (NodeId, EdgeId)>,
7606    start: NodeId,
7607    end: NodeId,
7608) -> Vec<PathBinding> {
7609    let mut hops = Vec::new();
7610    let mut current = end;
7611    while current != start {
7612        let (prev, edge_id) = parent[&current];
7613        hops.push((edge_id, current));
7614        current = prev;
7615    }
7616    hops.reverse();
7617    let mut elems = vec![PathBinding::Node(start)];
7618    for (edge_id, node) in hops {
7619        elems.push(PathBinding::Edge(edge_id));
7620        elems.push(PathBinding::Node(node));
7621    }
7622    elems
7623}
7624
7625/// Coerces a materialized `Value` down to a `PropertyValue` for storing in
7626/// `Binding::Value` — used by `item_binding` for a computed (non-bare-var)
7627/// WITH/RETURN item. `Value::Node`/`Edge` can't occur here in practice (no
7628/// non-aggregate `ReturnExpr` form produces one except `Var`, which takes
7629/// the bare-variable path instead), and a bare `collect()` result is
7630/// routed to `Binding::List` before reaching here (see `has_aggregate`) --
7631/// both still fall back to `Null` rather than needing a fallible signature
7632/// for an unreachable case. `Value::List` genuinely *can* reach here now,
7633/// though (`WITH n.numbers + [4] AS x` -- a real computed list expression,
7634/// not a bare `collect()`, once list-valued properties round-trip through
7635/// `lookup_prop_value` as real `Value::List`s) -- recurses per-element,
7636/// same as `value_to_storable_property`'s own list handling.
7637fn value_to_property_value(v: &Value) -> PropertyValue {
7638    match v {
7639        Value::Null => PropertyValue::Null,
7640        Value::Property(pv) => pv.clone(),
7641        Value::Literal(lit) => literal_to_value(lit),
7642        Value::List(items) => {
7643            PropertyValue::List(items.iter().map(value_to_property_value).collect())
7644        }
7645        Value::Node(_) | Value::Edge(_) | Value::Map(_) | Value::Path(_) => PropertyValue::Null,
7646    }
7647}
7648
7649/// `eval_props_to_values`'s stricter cousin of `value_to_property_value`
7650/// above -- a CREATE/SET prop value that evaluates to a node/edge/path/map
7651/// is a real, reportable error (`None` here), not a silent `Null`.
7652/// `value_to_property_value`'s silent-`Null` fallback is correct at *its*
7653/// call sites (a WITH-projected scalar, where those shapes genuinely can't
7654/// occur — see its own doc comment) but was never meant for CREATE/SET's
7655/// prop value, where writing one of those is a real, everyday mistake
7656/// (`CREATE (n {tags: some_node})`) that should say so, not silently store
7657/// `null`. `Value::List` *is* storable (`PropertyValue::List`, real
7658/// Cypher/Neo4j's own "homogeneous array property" shape) -- recurses
7659/// per-element, so a list containing something unstorable (a nested list
7660/// isn't rejected here, since no TCK scenario tests that restriction and
7661/// nothing about `PropertyValue::List`'s own storage format requires it,
7662/// but a node/edge/path/map element still correctly fails the whole list).
7663fn value_to_storable_property(v: &Value) -> Option<PropertyValue> {
7664    match v {
7665        Value::Null => Some(PropertyValue::Null),
7666        Value::Property(pv) => Some(pv.clone()),
7667        Value::Literal(lit) => Some(literal_to_value(lit)),
7668        Value::List(items) => Some(PropertyValue::List(
7669            items
7670                .iter()
7671                .map(value_to_storable_property)
7672                .collect::<Option<Vec<_>>>()?,
7673        )),
7674        Value::Node(_) | Value::Edge(_) | Value::Map(_) | Value::Path(_) => None,
7675    }
7676}
7677
7678/// `value_to_storable_property`'s inverse -- turns a raw stored/bound
7679/// `PropertyValue` back into a real `Value`, the read-time counterpart
7680/// every property-access site (`lookup_prop_value`, `binding_to_value`,
7681/// `eval_projected_expr`'s node/edge prop arms) needs. A scalar wraps as
7682/// `Value::Property` exactly as before; `PropertyValue::List` becomes a
7683/// genuine `Value::List` (not `Value::Property(PropertyValue::List(_))`)
7684/// so every existing list operation (`size()`, `tail()`, indexing, `IN`,
7685/// `UNWIND`, ...) -- all of which pattern-match on `Value::List`
7686/// specifically -- works transparently on a property-sourced list the
7687/// same as a list literal/`collect()` result, with no special-casing
7688/// needed anywhere else. `PropertyValue::Null` collapses to `Value::Null`,
7689/// matching every other property-read site's existing null convention.
7690fn property_value_to_value(pv: PropertyValue) -> Value {
7691    match pv {
7692        PropertyValue::Null => Value::Null,
7693        PropertyValue::List(items) => {
7694            Value::List(items.into_iter().map(property_value_to_value).collect())
7695        }
7696        other => Value::Property(other),
7697    }
7698}
7699
7700/// A bound `NodeId`/`EdgeId` whose record is no longer in the store means
7701/// exactly one thing within a single statement's transaction: it was
7702/// deleted earlier in this same statement (e.g. `MATCH (n) DELETE n RETURN
7703/// n.num` -- real Cypher's `DeletedEntityAccess` error, TCK's Return2
7704/// scenarios [15]/[16]/[17]). Nothing else can cause a `None` here --
7705/// there's no concurrent deletion mid-statement, and a `Binding::Node`/
7706/// `Edge` only ever gets constructed from an id a prior MATCH/CREATE/MERGE
7707/// in this same transaction actually found or made. Centralized here
7708/// (rather than each of `binding_to_value`/`resolve_path_elems`/
7709/// `lookup_prop` re-deriving the message) so the wording stays one place.
7710fn deleted_entity_access<T>(record: Option<T>) -> Result<T, QueryError> {
7711    record.ok_or_else(|| {
7712        QueryError::UnboundVariable(
7713            "refers to a node/relationship that no longer exists — it was deleted earlier in this statement".into(),
7714        )
7715    })
7716}
7717
7718pub(crate) fn literal_to_value(lit: &Literal) -> PropertyValue {
7719    match lit {
7720        Literal::Int(i) => PropertyValue::Int(*i),
7721        Literal::Float(f) => PropertyValue::Float(*f),
7722        Literal::String(s) => PropertyValue::String(s.clone()),
7723        Literal::Bool(b) => PropertyValue::Bool(*b),
7724        Literal::Null => PropertyValue::Null,
7725        Literal::Param(name) => {
7726            unreachable!("param ${name} must be substituted before execution — see params::substitute_params")
7727        }
7728    }
7729}
7730
7731fn tag_merge_created(mut row: BindingRow, created: bool) -> BindingRow {
7732    row.insert(
7733        MERGE_CREATED_KEY.to_string(),
7734        Binding::Value(PropertyValue::Bool(created)),
7735    );
7736    row
7737}
7738
7739/// `Either` (undirected `-[r:TYPE]-`) has no single storage-level call —
7740/// query both directions and dedupe by `edge_id` (a self-loop would
7741/// otherwise appear twice, once from each direction's adjacency table).
7742/// Multiple `rel_labels` (`[:A|B]`) has no single storage-level call
7743/// either — `GraphStore::neighbors_in_txn` only ever filters by one label
7744/// at a time, so this makes one call per type (per direction) and
7745/// dedupes by `edge_id` across all of them, same technique as `Either`
7746/// above (an edge whose type is in `rel_labels` is only ever returned by
7747/// exactly one of those per-type calls, so the only real duplication risk
7748/// is the same direction-crossing one `Either` already handles). Empty
7749/// `rel_labels` means untyped — matches any relationship, same as
7750/// `neighbors_in_txn`'s own `None` behavior.
7751fn neighbors_for_direction(
7752    txn: Txn,
7753    node: NodeId,
7754    direction: ExpandDirection,
7755    rel_labels: &[String],
7756) -> Result<Vec<AdjEntry>, QueryError> {
7757    let dirs: &[Direction] = match direction {
7758        ExpandDirection::Out => &[Direction::Out],
7759        ExpandDirection::In => &[Direction::In],
7760        ExpandDirection::Either => &[Direction::Out, Direction::In],
7761    };
7762    let mut out = Vec::new();
7763    let mut seen: HashSet<EdgeId> = HashSet::new();
7764    let label_filters: Vec<Option<&str>> = if rel_labels.is_empty() {
7765        vec![None]
7766    } else {
7767        rel_labels.iter().map(|l| Some(l.as_str())).collect()
7768    };
7769    for label in label_filters {
7770        for &dir in dirs {
7771            for entry in GraphStore::neighbors_in_txn(txn, node, dir, label)? {
7772                if seen.insert(entry.edge_id) {
7773                    out.push(entry);
7774                }
7775            }
7776        }
7777    }
7778    Ok(out)
7779}
7780
7781/// `<expr>.prop` where `<expr>` isn't a bare row variable (`ReturnExpr::
7782/// PropOf`, e.g. `startNode(r).id`, `head(nodes(p)).name`, `{a: 1}.a`) --
7783/// unlike `lookup_prop_value`'s `Prop(PropAccess)` arm, there's no row/txn
7784/// lookup to do here, `v` already *is* the fully-evaluated base value, so
7785/// this reads straight off it. Same node/edge/map/temporal-value-or-error
7786/// shape as `lookup_prop_value`, minus the "unbound variable" case (there's
7787/// no variable name to report -- a `PropOf` base that evaluates to
7788/// `Value::Null` propagates `Null` here the same way a bound-but-null row
7789/// variable's own `.prop` access already does).
7790fn property_of_value(v: &Value, prop: &str) -> Result<Value, QueryError> {
7791    match v {
7792        Value::Node(n) => Ok(n
7793            .props
7794            .get(prop)
7795            .cloned()
7796            .map(property_value_to_value)
7797            .unwrap_or(Value::Null)),
7798        Value::Edge(e) => Ok(e
7799            .props
7800            .get(prop)
7801            .cloned()
7802            .map(property_value_to_value)
7803            .unwrap_or(Value::Null)),
7804        Value::Map(m) => Ok(m.get(prop).cloned().unwrap_or(Value::Null)),
7805        Value::Null => Ok(Value::Null),
7806        Value::Property(PropertyValue::Null) => Ok(Value::Null),
7807        Value::Property(pv) => match temporal_component(pv, prop) {
7808            Some(component) => Ok(Value::Property(component)),
7809            None if is_temporal_property_value(pv) => Ok(Value::Null),
7810            None => Err(QueryError::Type(
7811                "property access requires a node, relationship, map, or temporal value".into(),
7812            )),
7813        },
7814        Value::List(_) | Value::Path(_) => Err(QueryError::Type(
7815            "property access requires a node, relationship, map, or temporal value, not a list \
7816             or path"
7817                .into(),
7818        )),
7819        Value::Literal(_) => Err(QueryError::Type(
7820            "property access requires a node, relationship, map, or temporal value".into(),
7821        )),
7822    }
7823}