Skip to main content

marsdb_query/
executor.rs

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