Skip to main content

marsdb_query/
executor.rs

1use std::cell::{Cell, RefCell};
2use std::collections::{BTreeMap, HashMap, HashSet};
3use std::sync::{
4    atomic::{AtomicBool, Ordering as AtomicOrdering},
5    Arc,
6};
7use std::time::{Duration, Instant};
8
9use marsdb_graph::{
10    AdjEntry, Direction, Edge, EdgeId, GraphStore, NodeId, PropertyValue, Txn, TzId as GraphTzId,
11    WriteTransaction,
12};
13
14use crate::aggregate::{property_value_hash_key, value_hash_key, AggAcc, HashKey};
15use crate::ast::{
16    is_aggregate_name, is_percentile_name, ArithOp, CallClause, CallYield, CompareOp, Expr,
17    Literal, MergeClause, NodePattern, Pattern, PropAccess, QuantifierKind, QueryClause, QueryPart,
18    RelDirection, RemoveItem, ReturnExpr, ReturnItem, ReturnTail, SetItem, SortDir, Statement,
19    Tail, UnwindClause, WithClause, WithExpr,
20};
21use crate::error::QueryError;
22use crate::ir::{ExpandDirection, LogicalPlan};
23use crate::parse_helpers::validate_named_path_pattern;
24use crate::planner::{apply_index_seeks, build_match_plan, pattern_all_vars, pattern_new_vars};
25use crate::procedure::{ProcedureProvider, ProcedureSignature};
26use crate::result::QueryResult;
27use crate::temporal;
28use crate::value::{PathElem, Value};
29
30/// Hidden key used to correlate `OPTIONAL MATCH` results back to the outer
31/// row that seeded them — never visible to user Cypher (not a valid
32/// identifier prefix a parsed pattern could ever produce).
33const OPTIONAL_SEED_IDX_KEY: &str = "__seed_idx";
34
35/// Hidden key tagging whether a `MERGE`d row came from the create-path or
36/// the match-path, consumed (and stripped) by `apply_merge_set` before the
37/// row becomes visible to the rest of the query.
38const MERGE_CREATED_KEY: &str = "__merge_created";
39
40/// Cooperative cancellation handle for a running query. Clone it before
41/// execution and call [`cancel`](Self::cancel) from another thread.
42#[derive(Debug, Clone, Default)]
43pub struct CancellationToken(Arc<AtomicBool>);
44
45impl CancellationToken {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    pub fn cancel(&self) {
51        self.0.store(true, AtomicOrdering::Release);
52    }
53
54    pub fn is_cancelled(&self) -> bool {
55        self.0.load(AtomicOrdering::Acquire)
56    }
57}
58
59/// Coarse, stable outcome category for telemetry. Error messages and query
60/// text are deliberately excluded to avoid leaking user data through an
61/// observer by default.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ExecutionOutcome {
64    Success,
65    /// The query text itself never parsed — see `QueryError::Syntax`.
66    SyntaxError,
67    /// The query parsed but is structurally invalid, independent of any
68    /// data/parameters — see `QueryError::Semantic`.
69    SemanticError,
70    /// A real value (from stored data or a `$parameter`) turned out to be
71    /// the wrong shape for what the query does with it — see
72    /// `QueryError::Type`.
73    TypeError,
74    GraphError,
75    UnboundVariable,
76    MissingParameter,
77    Cancelled,
78    Timeout,
79    ResourceLimit,
80}
81
82impl ExecutionOutcome {
83    pub fn from_error(error: &QueryError) -> Self {
84        match error {
85            QueryError::Syntax(_) => Self::SyntaxError,
86            QueryError::Semantic(_) => Self::SemanticError,
87            QueryError::Type(_) => Self::TypeError,
88            QueryError::Graph(_) => Self::GraphError,
89            QueryError::UnboundVariable(_) => Self::UnboundVariable,
90            QueryError::MissingParam(_) => Self::MissingParameter,
91            QueryError::Cancelled => Self::Cancelled,
92            QueryError::Timeout => Self::Timeout,
93            QueryError::ResourceLimit(_) => Self::ResourceLimit,
94        }
95    }
96}
97
98#[derive(Debug, Clone)]
99pub struct ExecutionEvent {
100    pub elapsed: Duration,
101    /// Unknown when parsing failed before a statement was available.
102    pub statement_read_only: Option<bool>,
103    pub result_rows: Option<usize>,
104    pub relationship_expansions: u64,
105    pub outcome: ExecutionOutcome,
106}
107
108/// Dependency-free callback adapter for sending execution events to an
109/// application's logger, metrics collector, or tracing system.
110#[derive(Clone)]
111pub struct ExecutionObserver(Arc<dyn Fn(&ExecutionEvent) + Send + Sync>);
112
113impl ExecutionObserver {
114    pub fn new(callback: impl Fn(&ExecutionEvent) + Send + Sync + 'static) -> Self {
115        Self(Arc::new(callback))
116    }
117
118    pub fn observe(&self, event: &ExecutionEvent) {
119        // Observability must never turn a committed query into a reported
120        // failure (or unwind through FFI callers), so observer panics are
121        // contained at this boundary.
122        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (self.0)(event)));
123    }
124}
125
126impl std::fmt::Debug for ExecutionObserver {
127    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        formatter.write_str("ExecutionObserver(..)")
129    }
130}
131
132/// Per-statement safety limits and optional telemetry. Limit fields default
133/// to `None`, preserving unlimited behavior for trusted embedded callers.
134#[derive(Debug, Clone, Default)]
135pub struct ExecutionOptions {
136    pub max_intermediate_rows: Option<usize>,
137    pub max_result_rows: Option<usize>,
138    pub max_relationship_expansions: Option<u64>,
139    pub timeout: Option<Duration>,
140    pub cancellation_token: Option<CancellationToken>,
141    pub observer: Option<ExecutionObserver>,
142    /// `None` (the default) means `CALL` always fails with "procedure not
143    /// found" -- MarsDB ships no built-in procedures itself, see
144    /// `procedure::ProcedureProvider`'s own docs.
145    pub procedures: Option<crate::procedure::Procedures>,
146    /// The statement's own `$name` parameters, verbatim -- every other
147    /// `$param` position is already resolved to a concrete `Literal`
148    /// before `Executor` ever sees the statement (`substitute_params`,
149    /// run during `marsdb::prepare_statement`, well before this point),
150    /// but a *standalone* `CALL proc` written with no parens at all (TCK's
151    /// Call1 `[2]`/`[11]`, Call2 `[3]`) resolves each declared input from
152    /// a same-named `$param` -- which declared names even exist isn't
153    /// knowable until the procedure's signature is looked up here, at
154    /// execution time (the registry itself, `procedures` above, isn't
155    /// available any earlier either), so this is the one place `Executor`
156    /// still needs the raw map instead of already-substituted AST nodes.
157    pub params: HashMap<String, PropertyValue>,
158}
159
160struct ExecutionGuard<'a> {
161    options: &'a ExecutionOptions,
162    deadline: Option<Instant>,
163    relationship_expansions: Cell<u64>,
164    /// A relationship's *type* is immutable for its whole lifetime, so
165    /// `type(r)` is one of the few things real Cypher still lets a
166    /// statement read off `r` after `DELETE r` deleted it earlier in the
167    /// same statement -- unlike properties/labels (mutable, and a genuine
168    /// `DeletedEntityAccess` error, TCK's Return2 `[15]`-`[17]`), it
169    /// needs no live record at all, just whatever type it had at match
170    /// time. `delete_targets`/`delete_binding`/`delete_value` populate
171    /// this right before actually deleting each edge; `type()`'s own
172    /// evaluation (`Executor::eval_type_call`) falls back to it only when
173    /// the ordinary live lookup fails. `RefCell`, not `&mut` -- `guard`
174    /// is threaded everywhere as a shared reference, same interior-
175    /// mutability precedent `relationship_expansions` above already sets.
176    deleted_edge_types: RefCell<HashMap<EdgeId, String>>,
177}
178
179impl<'a> ExecutionGuard<'a> {
180    fn new(options: &'a ExecutionOptions) -> Self {
181        Self {
182            options,
183            deadline: options
184                .timeout
185                .and_then(|timeout| Instant::now().checked_add(timeout)),
186            relationship_expansions: Cell::new(0),
187            deleted_edge_types: RefCell::new(HashMap::new()),
188        }
189    }
190
191    fn record_deleted_edge_type(&self, id: EdgeId, label: String) {
192        self.deleted_edge_types.borrow_mut().insert(id, label);
193    }
194
195    fn deleted_edge_type(&self, id: EdgeId) -> Option<String> {
196        self.deleted_edge_types.borrow().get(&id).cloned()
197    }
198
199    fn procedure_provider(&self) -> Option<&dyn ProcedureProvider> {
200        self.options.procedures.as_ref().map(|p| p.0.as_ref())
201    }
202
203    fn checkpoint(&self) -> Result<(), QueryError> {
204        if self
205            .options
206            .cancellation_token
207            .as_ref()
208            .is_some_and(CancellationToken::is_cancelled)
209        {
210            return Err(QueryError::Cancelled);
211        }
212        if self
213            .deadline
214            .is_some_and(|deadline| Instant::now() >= deadline)
215        {
216            return Err(QueryError::Timeout);
217        }
218        Ok(())
219    }
220
221    fn check_intermediate_rows(&self, rows: usize) -> Result<(), QueryError> {
222        self.checkpoint()?;
223        if self
224            .options
225            .max_intermediate_rows
226            .is_some_and(|limit| rows > limit)
227        {
228            return Err(QueryError::ResourceLimit(format!(
229                "intermediate row count {rows} exceeds configured maximum {}",
230                self.options.max_intermediate_rows.unwrap()
231            )));
232        }
233        Ok(())
234    }
235
236    fn check_result_rows(&self, rows: usize) -> Result<(), QueryError> {
237        self.checkpoint()?;
238        if self
239            .options
240            .max_result_rows
241            .is_some_and(|limit| rows > limit)
242        {
243            return Err(QueryError::ResourceLimit(format!(
244                "result row count {rows} exceeds configured maximum {}",
245                self.options.max_result_rows.unwrap()
246            )));
247        }
248        Ok(())
249    }
250
251    fn relationship_expansion(&self) -> Result<(), QueryError> {
252        self.checkpoint()?;
253        let count = self
254            .relationship_expansions
255            .get()
256            .checked_add(1)
257            .ok_or_else(|| {
258                QueryError::ResourceLimit("relationship expansion counter overflow".into())
259            })?;
260        self.relationship_expansions.set(count);
261        if self
262            .options
263            .max_relationship_expansions
264            .is_some_and(|limit| count > limit)
265        {
266            return Err(QueryError::ResourceLimit(format!(
267                "relationship expansion count {count} exceeds configured maximum {}",
268                self.options.max_relationship_expansions.unwrap()
269            )));
270        }
271        Ok(())
272    }
273}
274
275#[derive(Debug, Clone)]
276enum Binding {
277    Node(NodeId),
278    Edge(EdgeId),
279    /// A scalar carried through a `WITH` projection (e.g. `WITH message.id
280    /// AS messageId`) — no graph identity, just a value along for the ride
281    /// to the next `QueryPart`/the final `Tail`.
282    Value(PropertyValue),
283    /// A `collect()` result carried through a `WITH` projection. Separate
284    /// from `Binding::Value` because `PropertyValue` (storage-layer) has no
285    /// list variant — lists are a query-layer-only concept, never
286    /// persisted — so a materialized `collect()` has nowhere else to live
287    /// between one `QueryPart` and the next. Elements are already-resolved
288    /// `Value`s, not `Binding`s — `UNWIND` restores graph identity on the
289    /// way back out via `value_to_binding_restore`, a separate step from
290    /// how this is stored here.
291    List(Vec<Value>),
292    /// A map literal (`{a: 1, b: 2}`) carried through a `WITH` projection
293    /// — same reasoning as `List`: `PropertyValue` has no map variant, so
294    /// this is the only place a materialized map has to live between one
295    /// `QueryPart` and the next.
296    Map(BTreeMap<String, Value>),
297    /// A named path (`p = (a)-->(b)`) or `shortestPath()` result — see
298    /// `assemble_path`/`eval_shortest_path`. `PathBinding` (not `Binding`
299    /// again) because a path element only ever needs graph identity
300    /// (`NodeId`/`EdgeId`), never any of `Binding`'s other cases — using
301    /// `Binding` itself here would make "a path containing a path" a type
302    /// state nothing ever produces or handles.
303    Path(Vec<PathBinding>),
304}
305
306/// One element of a `Binding::Path`, alternating node/edge/node/.../node
307/// — the row-carried counterpart to `Value::Path`'s `PathElem` (which
308/// carries full `Node`/`Edge` records instead of just their ids, the same
309/// "keep identity in the row, resolve to a full record only when
310/// materializing for display" split every other `Binding`/`Value` pair
311/// already uses).
312#[derive(Debug, Clone)]
313enum PathBinding {
314    Node(NodeId),
315    Edge(EdgeId),
316}
317
318struct ShortestPathSpec<'a> {
319    direction: ExpandDirection,
320    rel_labels: &'a [String],
321    min_hops: u32,
322    max_hops: Option<u32>,
323}
324
325struct VarExpandSpec<'a> {
326    from_var: &'a str,
327    to_var: &'a str,
328    rel_labels: &'a [String],
329    direction: ExpandDirection,
330    min_hops: u32,
331    max_hops: Option<u32>,
332    /// Rel-vars bound by earlier fixed hops of the same pattern — see
333    /// `LogicalPlan::VarExpand`'s own docs.
334    exclude_edge_vars: &'a [String],
335    /// See `LogicalPlan::VarExpand::exclude_edge_sets`'s own docs.
336    exclude_edge_sets: &'a [String],
337    /// See `LogicalPlan::VarExpand::exclude_edge_var`'s own docs.
338    exclude_edge_var: &'a str,
339    /// See `LogicalPlan::VarExpand::path_segment_var`'s own docs.
340    path_segment_var: Option<&'a str>,
341    /// See `LogicalPlan::VarExpand::rel_list_var`'s own docs.
342    rel_list_var: Option<&'a str>,
343    /// See `LogicalPlan::VarExpand::rel_props`'s own docs.
344    rel_props: &'a [(String, ReturnExpr)],
345}
346
347struct MatchRelListSpec<'a> {
348    from_var: &'a str,
349    to_var: &'a str,
350    rel_list_var: &'a str,
351    rel_labels: &'a [String],
352    direction: ExpandDirection,
353    min_hops: u32,
354    max_hops: Option<u32>,
355}
356
357struct PatternComprehensionSpec<'a> {
358    path_var: &'a Option<String>,
359    pattern: &'a Pattern,
360    where_clause: &'a Option<Box<Expr>>,
361    projection: &'a ReturnExpr,
362}
363
364struct IndexSeekSpec<'a> {
365    var: &'a str,
366    label: &'a str,
367    prop: &'a str,
368    value: &'a PropertyValue,
369}
370
371/// Read-only context `Executor::rewrite_composed_item` needs to resolve a
372/// composed aggregate item's non-aggregate leaves -- see its own docs.
373struct GroupFinishCtx<'a> {
374    items: &'a [ReturnItem],
375    key_bindings: &'a [Option<Binding>],
376}
377
378/// `ORDER BY`/`SKIP`/`LIMIT` bundled into one argument for
379/// `execute_match` (clippy's `too_many_arguments`, capped at 7) --
380/// mirrors `Statement::Match`'s own trailing fields, always applied in
381/// this order regardless of which fields are actually present (`SKIP`
382/// after `ORDER BY`, `LIMIT` after `SKIP`).
383struct ResultModifiers<'a> {
384    order_by: &'a Option<Vec<(ReturnExpr, SortDir)>>,
385    skip: Option<i64>,
386    limit: Option<i64>,
387}
388
389type BindingRow = HashMap<String, Binding>;
390type RowStream<'a> = Box<dyn Iterator<Item = Result<BindingRow, QueryError>> + 'a>;
391
392/// Safety cap on unbounded variable-length traversal (`[:TYPE*0..]`) depth.
393/// Hitting it errors rather than silently truncating — see `VarExpand`
394/// evaluation. Expansion uses relationship uniqueness per path: a node may
395/// be revisited and two distinct paths to the same node remain distinct, but
396/// a relationship cannot occur twice in one path.
397const VAR_EXPAND_DEPTH_CAP: u32 = 30;
398
399pub struct Executor<'a> {
400    store: &'a GraphStore,
401    /// Lazily captured on first use, then reused for every no-arg
402    /// `date()`/`localtime()`/`time()`/`localdatetime()`/`datetime()`
403    /// call for the rest of this `Executor`'s lifetime (one per
404    /// statement execution, see `Executor::new`'s callers) -- real
405    /// Cypher's guarantee that every such call *within one query*
406    /// returns the same value (see `temporal::NowSnapshot`'s docs).
407    now: Cell<Option<temporal::NowSnapshot>>,
408}
409
410impl<'a> Executor<'a> {
411    pub fn new(store: &'a GraphStore) -> Self {
412        Self {
413            store,
414            now: Cell::new(None),
415        }
416    }
417
418    fn now_snapshot(&self) -> temporal::NowSnapshot {
419        if let Some(n) = self.now.get() {
420            return n;
421        }
422        let n = temporal::capture_now();
423        self.now.set(Some(n));
424        n
425    }
426
427    /// Dispatches on whether `stmt` ever mutates anything. A read-only
428    /// statement (`MATCH ... RETURN`, `is_read_only` below) runs inside a
429    /// `ReadTransaction` — a consistent snapshot that doesn't contend for
430    /// redb's single-writer lock, so concurrent readers run in parallel
431    /// instead of queueing behind each other. Everything else runs inside
432    /// a `WriteTransaction`, committed or aborted as a whole — the
433    /// crash-safety boundary from the plan (one statement = one commit).
434    /// Every graph access below this point must go through the `*_in_txn`
435    /// GraphStore methods, never the standalone `self.store.*` methods,
436    /// which open (and would deadlock trying to re-open) their own
437    /// transaction.
438    pub fn execute(&self, stmt: &Statement) -> Result<QueryResult, QueryError> {
439        self.execute_with_options(stmt, &ExecutionOptions::default())
440    }
441
442    pub fn execute_with_options(
443        &self,
444        stmt: &Statement,
445        options: &ExecutionOptions,
446    ) -> Result<QueryResult, QueryError> {
447        let started = Instant::now();
448        let guard = ExecutionGuard::new(options);
449        let result = self.execute_with_guard(stmt, &guard);
450        Self::notify_observer(options, stmt, started, &guard, &result);
451        result
452    }
453
454    fn execute_with_guard(
455        &self,
456        stmt: &Statement,
457        guard: &ExecutionGuard<'_>,
458    ) -> Result<QueryResult, QueryError> {
459        crate::semantic::validate_statement(stmt)?;
460        guard.checkpoint()?;
461        if let Statement::Explain(inner) = stmt {
462            // Never opens a WriteTransaction, regardless of what `inner`
463            // itself would otherwise mutate -- EXPLAIN describes a plan,
464            // it never runs one.
465            return self.execute_explain(inner);
466        }
467        if is_read_only(stmt) {
468            let read_txn = self.store.begin_read()?;
469            // No explicit commit/abort — a ReadTransaction is a pure
470            // snapshot view with nothing to roll back; it releases on drop.
471            return match stmt {
472                Statement::Union { parts, all } => {
473                    self.materialize_union(Txn::Read(&read_txn), parts, *all, guard)
474                }
475                Statement::Match {
476                    clauses,
477                    tail,
478                    order_by,
479                    skip,
480                    limit,
481                } => {
482                    let skip = self.resolve_skip_limit(
483                        Txn::Read(&read_txn),
484                        skip.as_deref(),
485                        "SKIP",
486                        guard,
487                    )?;
488                    let limit = self.resolve_skip_limit(
489                        Txn::Read(&read_txn),
490                        limit.as_deref(),
491                        "LIMIT",
492                        guard,
493                    )?;
494                    self.execute_match(
495                        Txn::Read(&read_txn),
496                        clauses,
497                        tail,
498                        ResultModifiers {
499                            order_by,
500                            skip,
501                            limit,
502                        },
503                        guard,
504                    )
505                }
506                _ => unreachable!("is_read_only only returns true for Statement::Match/Union"),
507            };
508        }
509        let write_txn = self.store.begin_write()?;
510        let outcome = self.execute_in_write_transaction_validated(stmt, &write_txn, guard);
511        match outcome {
512            Ok(result) => {
513                GraphStore::commit(write_txn)?;
514                Ok(result)
515            }
516            Err(e) => {
517                // Best-effort rollback; the original error is what matters.
518                let _ = GraphStore::abort(write_txn);
519                Err(e)
520            }
521        }
522    }
523
524    /// Execute without committing against a caller-owned write transaction.
525    /// The caller must commit or abort the transaction. This is the low-level
526    /// primitive used by `marsdb::Transaction` for atomic multi-statement
527    /// units of work.
528    pub fn execute_in_write_transaction(
529        &self,
530        stmt: &Statement,
531        write_txn: &WriteTransaction,
532    ) -> Result<QueryResult, QueryError> {
533        self.execute_in_write_transaction_with_options(
534            stmt,
535            write_txn,
536            &ExecutionOptions::default(),
537        )
538    }
539
540    pub fn execute_in_write_transaction_with_options(
541        &self,
542        stmt: &Statement,
543        write_txn: &WriteTransaction,
544        options: &ExecutionOptions,
545    ) -> Result<QueryResult, QueryError> {
546        let started = Instant::now();
547        let guard = ExecutionGuard::new(options);
548        let result = self.execute_in_write_transaction_with_guard(stmt, write_txn, &guard);
549        Self::notify_observer(options, stmt, started, &guard, &result);
550        result
551    }
552
553    fn execute_in_write_transaction_with_guard(
554        &self,
555        stmt: &Statement,
556        write_txn: &WriteTransaction,
557        guard: &ExecutionGuard<'_>,
558    ) -> Result<QueryResult, QueryError> {
559        crate::semantic::validate_statement(stmt)?;
560        guard.checkpoint()?;
561        if let Statement::Explain(inner) = stmt {
562            // Same "never mutates" contract as the top-level path -- opens
563            // its own ReadTransaction rather than touching the caller's
564            // already-open `write_txn`, even when this runs inside an
565            // explicit multi-statement transaction.
566            return self.execute_explain(inner);
567        }
568        self.execute_in_write_transaction_validated(stmt, write_txn, guard)
569    }
570
571    /// `EXPLAIN <statement>` — always opens its own `ReadTransaction`
572    /// (never the caller's write transaction, never a fresh write
573    /// transaction of its own) so describing a plan can never itself
574    /// mutate anything, no matter what `inner` would otherwise do.
575    fn execute_explain(&self, inner: &Statement) -> Result<QueryResult, QueryError> {
576        let read_txn = self.store.begin_read()?;
577        let lines = crate::explain::explain_statement(inner, Txn::Read(&read_txn))?;
578        Ok(QueryResult {
579            columns: vec!["plan".to_string()],
580            rows: lines
581                .into_iter()
582                .map(|line| vec![Value::Literal(Literal::String(line))])
583                .collect(),
584        })
585    }
586
587    fn notify_observer(
588        options: &ExecutionOptions,
589        stmt: &Statement,
590        started: Instant,
591        guard: &ExecutionGuard<'_>,
592        result: &Result<QueryResult, QueryError>,
593    ) {
594        let Some(observer) = &options.observer else {
595            return;
596        };
597        let (result_rows, outcome) = match result {
598            Ok(result) => (Some(result.rows.len()), ExecutionOutcome::Success),
599            Err(error) => (None, ExecutionOutcome::from_error(error)),
600        };
601        observer.observe(&ExecutionEvent {
602            elapsed: started.elapsed(),
603            statement_read_only: Some(is_read_only(stmt)),
604            result_rows,
605            relationship_expansions: guard.relationship_expansions.get(),
606            outcome,
607        });
608    }
609
610    fn execute_in_write_transaction_validated(
611        &self,
612        stmt: &Statement,
613        write_txn: &WriteTransaction,
614        guard: &ExecutionGuard<'_>,
615    ) -> Result<QueryResult, QueryError> {
616        match stmt {
617            Statement::Create(patterns) => {
618                guard.checkpoint()?;
619                self.execute_create(write_txn, patterns, guard)
620            }
621            Statement::CreateIndex {
622                label,
623                prop,
624                unique,
625            } => {
626                guard.checkpoint()?;
627                GraphStore::create_index_in_txn(write_txn, label, prop, *unique)?;
628                Ok(QueryResult {
629                    columns: vec![],
630                    rows: vec![],
631                })
632            }
633            Statement::Match {
634                clauses,
635                tail,
636                order_by,
637                skip,
638                limit,
639            } => {
640                let skip =
641                    self.resolve_skip_limit(Txn::Write(write_txn), skip.as_deref(), "SKIP", guard)?;
642                let limit = self.resolve_skip_limit(
643                    Txn::Write(write_txn),
644                    limit.as_deref(),
645                    "LIMIT",
646                    guard,
647                )?;
648                self.execute_match(
649                    Txn::Write(write_txn),
650                    clauses,
651                    tail,
652                    ResultModifiers {
653                        order_by,
654                        skip,
655                        limit,
656                    },
657                    guard,
658                )
659            }
660            Statement::Explain(inner) => {
661                // Only reachable if a future caller invokes this directly,
662                // bypassing `execute_in_write_transaction_with_guard`'s own
663                // interception above -- kept as a real (not `unreachable!`)
664                // fallback so that stays true even if this function's
665                // caller set ever changes, rather than becoming a latent
666                // panic.
667                self.execute_explain(inner)
668            }
669            Statement::Union { parts, all } => {
670                self.materialize_union(Txn::Write(write_txn), parts, *all, guard)
671            }
672            Statement::StandaloneCall(call) => {
673                self.eval_standalone_call(Txn::Write(write_txn), call, guard)
674            }
675        }
676    }
677
678    /// `CALL proc(args) [YIELD ...]` with nothing else in the statement
679    /// (TCK's Call1 `[1]`/`[2]`/`[5]`, Call2 `[2]`/`[3]`) -- unlike the
680    /// in-query form, this *is* the whole query: no outer rows to run the
681    /// call once per, and no YIELD at all means "auto-yield every output"
682    /// (`CallYield::Star`) rather than "discard everything."
683    /// `QueryClause::Call`'s own in-query handling -- calls the procedure
684    /// once per input row (TCK's Call1 `[3]`/`[4]`: even a `WHERE`-less,
685    /// output-less call still runs once per already-matched row, same as
686    /// any other reading clause). `None` (no `YIELD` at all) discards
687    /// every output and keeps `row` unchanged -- see `CallClause::
688    /// yield_items`'s own docs for why that's not the same as `Star`
689    /// (which never actually reaches here, `queryCallSt`'s grammar has no
690    /// `YIELD *` alternative). `Items` fans each input row out into one
691    /// output row per matching procedure result row (same cross-join
692    /// shape `eval_unwind` already gives its own per-row fan-out), each
693    /// carrying `row`'s own bindings forward plus the newly yielded ones,
694    /// filtered by `yieldItems`' own optional trailing `WHERE`.
695    fn eval_call_clause(
696        &self,
697        txn: Txn,
698        call: &CallClause,
699        current_rows: &[BindingRow],
700        guard: &ExecutionGuard<'_>,
701    ) -> Result<Vec<BindingRow>, QueryError> {
702        let mut out = Vec::new();
703        for row in current_rows {
704            guard.checkpoint()?;
705            let (sig, proc_rows) = self.call_procedure(txn, call, row, guard)?;
706            let Some(yield_items) = &call.yield_items else {
707                out.push(row.clone());
708                continue;
709            };
710            let names: Vec<String> = match yield_items {
711                CallYield::Star => sig.outputs.clone(),
712                CallYield::Items(items, _) => items
713                    .iter()
714                    .map(|(name, alias)| alias.clone().unwrap_or_else(|| name.clone()))
715                    .collect(),
716            };
717            for proc_row in &proc_rows {
718                let projected = project_call_row(&sig, proc_row, yield_items)?;
719                let mut new_row = row.clone();
720                for (name, value) in names.iter().zip(&projected) {
721                    new_row.insert(name.clone(), value_to_binding_restore(value));
722                }
723                if let CallYield::Items(_, Some(where_expr)) = yield_items {
724                    if self.eval_expr(txn, where_expr, &new_row, guard)? != Some(true) {
725                        continue;
726                    }
727                }
728                out.push(new_row);
729                guard.check_intermediate_rows(out.len())?;
730            }
731        }
732        Ok(out)
733    }
734
735    fn eval_standalone_call(
736        &self,
737        txn: Txn,
738        call: &CallClause,
739        guard: &ExecutionGuard<'_>,
740    ) -> Result<QueryResult, QueryError> {
741        let empty_row = BindingRow::new();
742        let (sig, proc_rows) = self.call_procedure(txn, call, &empty_row, guard)?;
743        let yield_items = call.yield_items.clone().unwrap_or(CallYield::Star);
744        let columns: Vec<String> = match &yield_items {
745            CallYield::Star => sig.outputs.clone(),
746            CallYield::Items(items, _) => items
747                .iter()
748                .map(|(name, alias)| alias.clone().unwrap_or_else(|| name.clone()))
749                .collect(),
750        };
751        let mut rows = Vec::with_capacity(proc_rows.len());
752        for proc_row in &proc_rows {
753            rows.push(project_call_row(&sig, proc_row, &yield_items)?);
754        }
755        if let CallYield::Items(_, Some(where_expr)) = &yield_items {
756            let mut filtered = Vec::with_capacity(rows.len());
757            for row_values in &rows {
758                let mut binding_row = BindingRow::new();
759                for (col, v) in columns.iter().zip(row_values) {
760                    binding_row.insert(col.clone(), value_to_binding_restore(v));
761                }
762                if self.eval_expr(txn, where_expr, &binding_row, guard)? == Some(true) {
763                    filtered.push(row_values.clone());
764                }
765            }
766            rows = filtered;
767        }
768        Ok(QueryResult { columns, rows })
769    }
770
771    /// Shared by `eval_standalone_call` and `QueryClause::Call`'s own
772    /// in-query handling -- looks up `call.name`'s signature, resolves and
773    /// type-checks its arguments against `row`'s already-bound variables
774    /// (explicit args) or `guard.options.params` (the implicit-argument
775    /// form, `call.args: None`), then invokes the provider. Returns the
776    /// signature alongside the raw output rows since both callers need it
777    /// again afterward (`sig.outputs`' names, for `YIELD *`/column
778    /// naming).
779    fn call_procedure(
780        &self,
781        txn: Txn,
782        call: &CallClause,
783        row: &BindingRow,
784        guard: &ExecutionGuard<'_>,
785    ) -> Result<(ProcedureSignature, Vec<Vec<Value>>), QueryError> {
786        let provider = guard.procedure_provider().ok_or_else(|| {
787            QueryError::Semantic(format!(
788                "procedure '{}' not found -- no procedure provider is configured",
789                call.name
790            ))
791        })?;
792        let sig = provider
793            .signature(&call.name)
794            .ok_or_else(|| QueryError::Semantic(format!("procedure '{}' not found", call.name)))?;
795        let args = self.eval_call_args(txn, call, &sig, row, guard)?;
796        let rows = provider.call(&call.name, &args)?;
797        Ok((sig, rows))
798    }
799
800    fn eval_call_args(
801        &self,
802        txn: Txn,
803        call: &CallClause,
804        sig: &ProcedureSignature,
805        row: &BindingRow,
806        guard: &ExecutionGuard<'_>,
807    ) -> Result<Vec<Value>, QueryError> {
808        let values: Vec<Value> = match &call.args {
809            Some(args) => {
810                if args.len() != sig.inputs.len() {
811                    return Err(QueryError::Semantic(format!(
812                        "'{}' expects {} argument(s), got {}",
813                        call.name,
814                        sig.inputs.len(),
815                        args.len()
816                    )));
817                }
818                args.iter()
819                    .map(|a| self.eval_return_expr(txn, a, row, guard))
820                    .collect::<Result<_, _>>()?
821            }
822            // The implicit-argument form (`CALL proc`, no parens) --
823            // each declared input resolves from a same-named `$param`
824            // (TCK's Call1 `[11]`, Call2 `[3]`); missing is a
825            // `MissingParam`, same error real Cypher's own
826            // `ParameterMissing`/`MissingParameter` reports.
827            None => sig
828                .inputs
829                .iter()
830                .map(|input_name| {
831                    guard
832                        .options
833                        .params
834                        .get(input_name)
835                        .cloned()
836                        .map(property_value_to_value)
837                        .ok_or_else(|| QueryError::MissingParam(input_name.clone()))
838                })
839                .collect::<Result<_, _>>()?,
840        };
841        for (value, (input_name, declared_type)) in
842            values.iter().zip(sig.inputs.iter().zip(&sig.input_types))
843        {
844            if !value_matches_declared_type(value, declared_type) {
845                return Err(QueryError::Type(format!(
846                    "'{}' argument '{input_name}' expects {declared_type}, got {value:?}",
847                    call.name
848                )));
849            }
850        }
851        Ok(values)
852    }
853
854    fn execute_create(
855        &self,
856        write_txn: &WriteTransaction,
857        patterns: &[Pattern],
858        guard: &ExecutionGuard<'_>,
859    ) -> Result<QueryResult, QueryError> {
860        // A standalone CREATE is a MATCH...CREATE tail run against a
861        // single empty row -- `resolve_or_create_node` below never finds
862        // any variable already bound in an empty `BindingRow`, so every
863        // node token is fresh, exactly like standalone CREATE always was.
864        // No trailing RETURN is possible on a standalone `CREATE` statement
865        // (that's the `MATCH ... CREATE ... RETURN` tail's job instead), so
866        // the resulting bindings are just discarded here.
867        self.materialize_create(write_txn, patterns, &[BindingRow::new()], guard)?;
868        Ok(QueryResult {
869            columns: vec![],
870            rows: vec![],
871        })
872    }
873
874    /// Runs CREATE patterns once per row in `rows`, returning each row's
875    /// bindings extended with whatever the CREATE patterns bound (newly
876    /// created node/edge ids, or the reused id for an already-bound
877    /// variable) -- this is what lets a trailing `RETURN` after a `MATCH
878    /// ... CREATE` tail (e.g. `MATCH (a) CREATE (a)-[:R]->(b) RETURN b`)
879    /// see the newly created `b`. Shared by a standalone `CREATE` statement
880    /// (`execute_create`, a single empty row, return value discarded -- no
881    /// RETURN is possible there) and a `MATCH ... CREATE` tail
882    /// (`execute_match`, rows carry bindings from the preceding
883    /// MATCH/WITH). The only real difference between the two is what
884    /// `resolve_or_create_node` finds already bound in a row -- nothing for
885    /// standalone CREATE, real nodes for a MATCH...CREATE tail, which is
886    /// what lets the tail form add an edge between two nodes that already
887    /// exist.
888    fn materialize_create(
889        &self,
890        write_txn: &WriteTransaction,
891        patterns: &[Pattern],
892        rows: &[BindingRow],
893        guard: &ExecutionGuard<'_>,
894    ) -> Result<Vec<BindingRow>, QueryError> {
895        let mut out = Vec::with_capacity(rows.len());
896        for row in rows {
897            // A variable bound earlier in this same CREATE (an earlier hop,
898            // or an earlier comma-separated pattern) must be visible to
899            // later tokens naming it again -- e.g. a self-loop `(a)-[:R]->(a)`
900            // -- so track newly-created bindings in a local, per-row copy
901            // instead of just consulting the original incoming `row`.
902            let mut row = row.clone();
903            for pattern in patterns {
904                let mut prev_id =
905                    self.resolve_or_create_node(write_txn, &pattern.start, &row, guard)?;
906                if let Some(var) = &pattern.start.var {
907                    row.insert(var.clone(), Binding::Node(prev_id));
908                }
909                for (rel, node) in &pattern.hops {
910                    if rel.hop_range.is_some() {
911                        return Err(QueryError::Semantic(
912                            "CREATE doesn't support variable-length relationship patterns (e.g. [:TYPE*1..3])".into(),
913                        ));
914                    }
915                    let node_id = self.resolve_or_create_node(write_txn, node, &row, guard)?;
916                    if let Some(var) = &node.var {
917                        row.insert(var.clone(), Binding::Node(node_id));
918                    }
919
920                    let rel_label = rel.rel_types.first().cloned().expect(
921                        "CREATE relationship has exactly one type -- checked by \
922                         semantic::bind_create_pattern",
923                    );
924                    let rel_props =
925                        self.eval_props_to_values(Txn::Write(write_txn), &rel.props, &row, guard)?;
926                    let (src, dst) = match rel.direction {
927                        RelDirection::Right => (prev_id, node_id),
928                        RelDirection::Left => (node_id, prev_id),
929                        RelDirection::Either => {
930                            return Err(QueryError::Semantic(
931                                "CREATE requires a directed relationship (-> or <-), not an undirected pattern".into(),
932                            ))
933                        }
934                    };
935                    let edge_id =
936                        GraphStore::create_edge_in_txn(write_txn, &rel_label, src, dst, rel_props)?;
937                    if let Some(var) = &rel.var {
938                        row.insert(var.clone(), Binding::Edge(edge_id));
939                    }
940                    prev_id = node_id;
941                }
942            }
943            out.push(row);
944        }
945        Ok(out)
946    }
947
948    /// A node pattern token reuses an existing binding iff it names a
949    /// variable already bound in `row` (from a preceding MATCH/WITH) --
950    /// restating labels/props on that token is rejected at compile time
951    /// (`semantic::check_create_node_not_already_bound`), since silently
952    /// dropping user-written labels/props would be a correctness trap.
953    /// Anything else (no variable, or a variable not yet bound in this
954    /// row) creates a brand-new node, exactly like standalone CREATE
955    /// always has for every node token.
956    fn resolve_or_create_node(
957        &self,
958        write_txn: &WriteTransaction,
959        node: &NodePattern,
960        row: &BindingRow,
961        guard: &ExecutionGuard<'_>,
962    ) -> Result<NodeId, QueryError> {
963        if let Some(var) = &node.var {
964            if let Some(binding) = row.get(var) {
965                let Binding::Node(id) = binding else {
966                    return Err(QueryError::Type(format!(
967                        "'{var}' is not a node — can't use it as a CREATE pattern endpoint"
968                    )));
969                };
970                // Reusing an already-bound var with new labels/props is
971                // rejected at compile time (`semantic::check_create_node_
972                // not_already_bound`) -- unreachable here in practice.
973                return Ok(*id);
974            }
975        }
976        let labels: Vec<&str> = node.labels.iter().map(String::as_str).collect();
977        let props = self.eval_props_to_values(Txn::Write(write_txn), &node.props, row, guard)?;
978        Ok(GraphStore::create_node_in_txn(write_txn, &labels, props)?)
979    }
980
981    /// Evaluates a CREATE pattern's `{...}` prop map -- each value is any
982    /// `ReturnExpr` (`self.eval_return_expr`), not just a literal, which
983    /// is what lets `CREATE (:Val {d: date({year: 1984, ...})})` work
984    /// (see `cypher.pest`'s `map_expr` docs). `row` is whatever's already
985    /// bound so far in this same CREATE (earlier hops, earlier
986    /// comma-separated patterns) -- a prop expression referencing one of
987    /// those (unusual, but not disallowed) resolves the same as anywhere
988    /// else `eval_return_expr` runs.
989    fn eval_props_to_values(
990        &self,
991        txn: Txn,
992        props: &[(String, ReturnExpr)],
993        row: &BindingRow,
994        guard: &ExecutionGuard<'_>,
995    ) -> Result<BTreeMap<String, PropertyValue>, QueryError> {
996        props
997            .iter()
998            .filter_map(|(k, expr)| {
999                let value = match self.eval_return_expr(txn, expr, row, guard) {
1000                    Ok(v) => v,
1001                    Err(e) => return Some(Err(e)),
1002                };
1003                // `CREATE (n {prop: null})` never actually stores `prop`
1004                // at all in real Cypher -- the same "setting to null
1005                // removes/never-creates the property" rule
1006                // `apply_set_item`'s own `SET n.prop = null` handling
1007                // already has (see its docs), just never applied here
1008                // too. Observable via `keys(n)`/property enumeration
1009                // (TCK's Graph8 [8]) -- a stored `PropertyValue::Null`
1010                // still shows up as a key, where a real missing property
1011                // wouldn't.
1012                if matches!(value, Value::Null) {
1013                    return None;
1014                }
1015                let pv = match value_to_storable_property(&value).ok_or_else(|| {
1016                    QueryError::Type(format!(
1017                        "property '{k}' can't be stored -- MarsDB's node/edge properties are limited to null/\
1018                         bool/int/float/string/date/duration; a list/map/node/edge/path value (got {value:?}) \
1019                         isn't storable, matching PropertyValue's real, deliberately fixed set of variants (see \
1020                         its doc comment)"
1021                    ))
1022                }) {
1023                    Ok(pv) => pv,
1024                    Err(e) => return Some(Err(e)),
1025                };
1026                Some(Ok((k.clone(), pv)))
1027            })
1028            .collect()
1029    }
1030
1031    /// Runs `MERGE` once per row in `rows` (`clause.pattern.hops.len() <=
1032    /// 1`, enforced at parse time — whole-pattern atomicity across
1033    /// multiple simultaneously-unbound hops isn't attempted in v1: which
1034    /// hop's "not found" should trigger creation of what, in what order,
1035    /// gets genuinely hard to reason about correctly for longer chains).
1036    fn eval_merge(
1037        &self,
1038        write_txn: &WriteTransaction,
1039        clause: &MergeClause,
1040        rows: &[BindingRow],
1041        guard: &ExecutionGuard<'_>,
1042    ) -> Result<Vec<BindingRow>, QueryError> {
1043        let mut out = Vec::new();
1044        for row in rows {
1045            guard.checkpoint()?;
1046            out.extend(self.merge_one_row(write_txn, clause, row, guard)?);
1047            guard.check_intermediate_rows(out.len())?;
1048        }
1049        self.apply_merge_set(write_txn, clause, &mut out, guard)?;
1050        Ok(out)
1051    }
1052
1053    /// Whether any property expression across `clause.pattern` (the
1054    /// start node, and every hop's relationship + node) evaluates to
1055    /// null for this row -- see `merge_one_row`'s call site for why
1056    /// that's always a real error, never a value MERGE can act on.
1057    fn merge_pattern_has_null_property(
1058        &self,
1059        txn: Txn,
1060        clause: &MergeClause,
1061        row: &BindingRow,
1062        guard: &ExecutionGuard<'_>,
1063    ) -> Result<bool, QueryError> {
1064        let any_null = |props: &[(String, ReturnExpr)]| -> Result<bool, QueryError> {
1065            for (_, expr) in props {
1066                if matches!(self.eval_return_expr(txn, expr, row, guard)?, Value::Null) {
1067                    return Ok(true);
1068                }
1069            }
1070            Ok(false)
1071        };
1072        if any_null(&clause.pattern.start.props)? {
1073            return Ok(true);
1074        }
1075        for (rel, node) in &clause.pattern.hops {
1076            if any_null(&rel.props)? || any_null(&node.props)? {
1077                return Ok(true);
1078            }
1079        }
1080        Ok(false)
1081    }
1082
1083    fn merge_one_row(
1084        &self,
1085        write_txn: &WriteTransaction,
1086        clause: &MergeClause,
1087        row: &BindingRow,
1088        guard: &ExecutionGuard<'_>,
1089    ) -> Result<Vec<BindingRow>, QueryError> {
1090        // The bare-already-bound-start and reused-relationship-variable
1091        // cases are rejected at compile time (`semantic::bind_merge`),
1092        // not only here -- a zero-row MATCH would otherwise skip both
1093        // entirely even though real Cypher's `VariableAlreadyBound` is a
1094        // structural/scope error, not a data-dependent one. A completely
1095        // unconstrained, unbound token (bare `MERGE (a)`, no label/
1096        // property) is real, valid Cypher -- searches for/creates any
1097        // node with no constraints at all (TCK's Merge1 [1]), not an
1098        // error; an earlier version of this codebase treated it as an
1099        // "ambiguous shape" mistake to reject, which real Cypher's own
1100        // TCK disproves.
1101        for (rel, _node) in &clause.pattern.hops {
1102            if rel.hop_range.is_some() {
1103                return Err(QueryError::Semantic(
1104                    "MERGE doesn't support variable-length relationship patterns (e.g. [:TYPE*1..3])".into(),
1105                ));
1106            }
1107        }
1108        // `MERGE p = ...` -- give every anonymous token in the pattern a
1109        // synthetic name first (same convention ordinary MATCH's own
1110        // named-path capture uses, see `execute_match`'s `QueryClause::
1111        // Match` arm), so `assemble_path` below has a real row binding to
1112        // read at every position regardless of whether the user wrote one
1113        // -- then strip those synthetic keys back out before this row
1114        // becomes visible to the rest of the query. A no-`path_var` MERGE
1115        // clones `clause.pattern` once here rather than working with it
1116        // by reference throughout, so this function has exactly one
1117        // pattern to work from either way.
1118        let (pattern, synthesized) = if clause.path_var.is_some() {
1119            name_pattern_for_path(&clause.pattern)
1120        } else {
1121            (clause.pattern.clone(), HashSet::new())
1122        };
1123        let pattern = &pattern;
1124        // A MERGE pattern's own inline `{...}` property evaluating to
1125        // null can never be searched-or-created consistently: a null
1126        // property is never equal to anything (so the search half can
1127        // never find a node/edge that "has" it), but storing a
1128        // property as null is equivalent to not storing it at all (see
1129        // `apply_set_item`'s own SET-to-null convention) -- so the
1130        // create half would silently produce something that doesn't
1131        // structurally match the pattern that created it. Real Cypher's
1132        // MergeReadOwnWrites error, checked once per row (a property
1133        // expression can reference this row's other bindings, e.g.
1134        // `MERGE (n {x: m.missing})`).
1135        if self.merge_pattern_has_null_property(Txn::Write(write_txn), clause, row, guard)? {
1136            return Err(QueryError::Semantic(
1137                "MERGE pattern property is null — a MERGE's own {...} properties can never be \
1138                 null (searching for null never matches anything, but storing null is the same \
1139                 as not storing the property at all)"
1140                    .into(),
1141            ));
1142        }
1143
1144        // Try the pattern as an ordinary MATCH first. Whatever's already
1145        // bound in `row` (e.g. `a` from a preceding MATCH) becomes a Seed,
1146        // not a fresh scan — build_match_plan already knows how to do
1147        // this, the same mechanism every ordinary MATCH clause uses. For a
1148        // one-hop pattern this already searches the *connected*
1149        // sub-pattern (Expand from the resolved source, Filter by the
1150        // target's own constraints), not each node independently — which
1151        // is exactly the correctness property MERGE needs and gets for
1152        // free by reusing this instead of inventing bespoke search logic.
1153        let carried_vars: HashSet<String> = row.keys().cloned().collect();
1154        let plan = apply_index_seeks(
1155            build_match_plan(pattern, &None, &carried_vars)?,
1156            Txn::Write(write_txn),
1157        )?;
1158        let found = self.eval_plan(
1159            Txn::Write(write_txn),
1160            &plan,
1161            std::slice::from_ref(row),
1162            guard,
1163        )?;
1164        if !found.is_empty() {
1165            return Ok(found
1166                .into_iter()
1167                .map(|mut r| {
1168                    if let Some(path_var) = &clause.path_var {
1169                        let path_binding = assemble_path(pattern, &r);
1170                        for key in &synthesized {
1171                            r.remove(key);
1172                        }
1173                        r.insert(path_var.clone(), path_binding);
1174                    }
1175                    tag_merge_created(r, false)
1176                })
1177                .collect());
1178        }
1179
1180        // Nothing found — create exactly one new instance. Reuses
1181        // resolve_or_create_node, the same "reuse if the token's var is
1182        // already bound in the row, else create fresh" logic
1183        // Tail::Create/materialize_create already use.
1184        let mut new_row = row.clone();
1185        let start_id = self.resolve_or_create_node(write_txn, &pattern.start, &new_row, guard)?;
1186        if let Some(var) = &pattern.start.var {
1187            new_row.insert(var.clone(), Binding::Node(start_id));
1188        }
1189        // At most one hop (enforced at parse time) -- a plain `if let`,
1190        // not a loop, so there's no dangling "previous node" state to
1191        // thread once a 2nd+ hop is ever supported.
1192        if let Some((rel, node)) = pattern.hops.first() {
1193            let node_id = self.resolve_or_create_node(write_txn, node, &new_row, guard)?;
1194            if let Some(var) = &node.var {
1195                new_row.insert(var.clone(), Binding::Node(node_id));
1196            }
1197            let rel_label = rel.rel_types.first().cloned().expect(
1198                "MERGE relationship has exactly one type -- checked by semantic::bind_merge",
1199            );
1200            let rel_props =
1201                self.eval_props_to_values(Txn::Write(write_txn), &rel.props, &new_row, guard)?;
1202            // An undirected pattern (`-[r]-`) with nothing to match
1203            // defaults to an outgoing relationship when creating -- real
1204            // Cypher's own rule (TCK's Merge5 [11], "Use outgoing
1205            // direction when unspecified").
1206            let (src, dst) = match rel.direction {
1207                RelDirection::Right | RelDirection::Either => (start_id, node_id),
1208                RelDirection::Left => (node_id, start_id),
1209            };
1210            let edge_id =
1211                GraphStore::create_edge_in_txn(write_txn, &rel_label, src, dst, rel_props)?;
1212            if let Some(var) = &rel.var {
1213                new_row.insert(var.clone(), Binding::Edge(edge_id));
1214            }
1215        }
1216        if let Some(path_var) = &clause.path_var {
1217            let path_binding = assemble_path(pattern, &new_row);
1218            for key in &synthesized {
1219                new_row.remove(key);
1220            }
1221            new_row.insert(path_var.clone(), path_binding);
1222        }
1223        Ok(vec![tag_merge_created(new_row, true)])
1224    }
1225
1226    /// Applies `ON CREATE SET`/`ON MATCH SET` to the right rows (matching
1227    /// real Cypher semantics exactly: `ON CREATE` fires whenever anything
1228    /// in the pattern was newly created, `ON MATCH` only when the whole
1229    /// pattern already existed as-is — the single per-row
1230    /// `MERGE_CREATED_KEY` tag is the correct model for this, not a
1231    /// simplification of it — see `eval_optional_part`'s
1232    /// `OPTIONAL_SEED_IDX_KEY` for the same hidden-tag precedent), then
1233    /// strips the tag before the rows become visible to the rest of the
1234    /// query.
1235    fn apply_merge_set(
1236        &self,
1237        write_txn: &WriteTransaction,
1238        clause: &MergeClause,
1239        rows: &mut [BindingRow],
1240        guard: &ExecutionGuard<'_>,
1241    ) -> Result<(), QueryError> {
1242        for row in rows.iter_mut() {
1243            let created = match row.remove(MERGE_CREATED_KEY) {
1244                Some(Binding::Value(PropertyValue::Bool(b))) => b,
1245                other => unreachable!(
1246                    "{MERGE_CREATED_KEY} tagged internally as Binding::Value(Bool), got {other:?}"
1247                ),
1248            };
1249            let items = if created {
1250                &clause.on_create
1251            } else {
1252                &clause.on_match
1253            };
1254            for item in items {
1255                self.apply_set_item(Txn::Write(write_txn), write_txn, row, item, guard)?;
1256            }
1257        }
1258        Ok(())
1259    }
1260
1261    fn execute_match(
1262        &self,
1263        txn: Txn,
1264        clauses: &[QueryClause],
1265        tail: &Option<Tail>,
1266        modifiers: ResultModifiers<'_>,
1267        guard: &ExecutionGuard<'_>,
1268    ) -> Result<QueryResult, QueryError> {
1269        self.execute_match_seeded(txn, clauses, tail, modifiers, None, guard)
1270    }
1271
1272    /// `execute_match`'s general form -- `seed` is `None` for an ordinary
1273    /// top-level statement (nothing carried in, same as `execute_match`'s
1274    /// old fixed behavior) or `Some(row)` for a correlated `exists { MATCH
1275    /// ... RETURN ... }` subquery (`eval_exists_subquery`): the outer row's
1276    /// own bindings become this statement's starting `current_rows`/
1277    /// `carried_vars`, so a pattern referencing an outer-bound name (`(n)
1278    /// -->(m)` where `n` is already bound) seeds from it (`LogicalPlan::
1279    /// Seed`) instead of scanning fresh, exactly like a later clause in an
1280    /// ordinary multi-clause statement already does with an earlier
1281    /// clause's bindings.
1282    fn execute_match_seeded(
1283        &self,
1284        txn: Txn,
1285        clauses: &[QueryClause],
1286        tail: &Option<Tail>,
1287        modifiers: ResultModifiers<'_>,
1288        seed: Option<&BindingRow>,
1289        guard: &ExecutionGuard<'_>,
1290    ) -> Result<QueryResult, QueryError> {
1291        let ResultModifiers {
1292            order_by,
1293            skip,
1294            limit,
1295        } = modifiers;
1296        // Threads bindings through each MATCH/UNWIND/WITH clause.
1297        // `carried_vars` tells the planner which of the next MATCH clause's
1298        // pattern variables are already bound (-> LogicalPlan::Seed) rather
1299        // than fresh (-> a scan). Starts empty (except for `seed`'s own
1300        // vars, if any): the first clause never has anything else carried
1301        // into it.
1302        let mut carried_vars: HashSet<String> = match seed {
1303            Some(row) => row.keys().cloned().collect(),
1304            None => HashSet::new(),
1305        };
1306        let mut current_rows: Vec<BindingRow> = vec![seed.cloned().unwrap_or_default()];
1307        // A plain, non-blocking RETURN can stop the final MATCH pipeline as
1308        // soon as SKIP+LIMIT rows have arrived (SKIP rows still have to
1309        // physically flow through the pipeline to be counted and dropped
1310        // below -- only the *count* the stream stops at grows, not
1311        // anything about what SKIP itself does). ORDER BY, DISTINCT,
1312        // aggregation, mutations, and WITH must still consume/materialize
1313        // their complete input before applying a final limit.
1314        let final_stream_limit = match (order_by, limit, tail) {
1315            (None, Some(limit), Some(Tail::Return(items, false))) if !has_aggregate(items) => {
1316                Some(skip.unwrap_or(0).max(0) as usize + limit.max(0) as usize)
1317            }
1318            _ => None,
1319        };
1320        for (clause_index, clause) in clauses.iter().enumerate() {
1321            let is_final_clause = clause_index + 1 == clauses.len();
1322            match clause {
1323                QueryClause::Match(part) => {
1324                    let plan_limit = is_final_clause
1325                        .then_some(final_stream_limit)
1326                        .flatten()
1327                        .filter(|_| !part.shortest_path && !part.optional && part.with.is_none());
1328                    current_rows = if part.shortest_path {
1329                        // Not a LogicalPlan/eval_plan traversal at all —
1330                        // see eval_shortest_path's docs.
1331                        self.eval_shortest_path(txn, part, &current_rows, guard)?
1332                    } else if let Some(path_var) = &part.path_var {
1333                        let (named_pattern, synthesized) = name_pattern_for_path(&part.pattern);
1334                        // A named path's own inline `WHERE` can reference
1335                        // the path variable itself (`WHERE length(p) =
1336                        // 1`, TCK's MatchWhere1 `[12]`/`[13]`) -- `p`
1337                        // isn't in the row until *after* `assemble_path`
1338                        // below, so (for a plain, non-`OPTIONAL` MATCH)
1339                        // it can't be pushed into the plan the way an
1340                        // ordinary pattern's `WHERE` is; applied as a
1341                        // post-filter instead, once every row really has
1342                        // `p`. `OPTIONAL MATCH` still pushes it into the
1343                        // plan -- its own null-padding semantics need the
1344                        // filter fused into the "did this seed row match
1345                        // anything" check `eval_optional_part` does, and
1346                        // a `WHERE` referencing `p` there is a narrower,
1347                        // untested-by-the-TCK edge case left as-is.
1348                        let defer_where = !part.optional && part.where_clause.is_some();
1349                        let plan_where = if defer_where {
1350                            &None
1351                        } else {
1352                            &part.where_clause
1353                        };
1354                        let plan = apply_index_seeks(
1355                            build_match_plan(&named_pattern, plan_where, &carried_vars)?,
1356                            txn,
1357                        )?;
1358                        let mut rows = if part.optional {
1359                            let new_vars = pattern_new_vars(&named_pattern, &carried_vars);
1360                            self.eval_optional_part(txn, &plan, &current_rows, &new_vars, guard)?
1361                        } else {
1362                            // `plan_limit`'s own early-stop assumes every
1363                            // emitted row is already a real, final row --
1364                            // not true when the WHERE filter above got
1365                            // deferred (a limited prefix could still get
1366                            // filtered further below), so it's skipped
1367                            // for that case (limiting instead happens
1368                            // naturally via the smaller `rows` this
1369                            // clause returns).
1370                            let limit = plan_limit.filter(|_| !defer_where);
1371                            self.eval_plan_with_limit(txn, &plan, &current_rows, guard, limit)?
1372                        };
1373                        for row in &mut rows {
1374                            let path_binding = assemble_path(&named_pattern, row);
1375                            for key in &synthesized {
1376                                row.remove(key);
1377                            }
1378                            row.insert(path_var.clone(), path_binding);
1379                        }
1380                        if defer_where {
1381                            let where_clause = part
1382                                .where_clause
1383                                .as_ref()
1384                                .expect("defer_where implies where_clause is Some");
1385                            let mut filtered = Vec::with_capacity(rows.len());
1386                            for row in rows {
1387                                if self.eval_expr(txn, where_clause, &row, guard)? == Some(true) {
1388                                    filtered.push(row);
1389                                }
1390                            }
1391                            rows = filtered;
1392                        }
1393                        rows
1394                    } else {
1395                        let plan = apply_index_seeks(
1396                            build_match_plan(&part.pattern, &part.where_clause, &carried_vars)?,
1397                            txn,
1398                        )?;
1399                        if part.optional {
1400                            let new_vars = pattern_new_vars(&part.pattern, &carried_vars);
1401                            self.eval_optional_part(txn, &plan, &current_rows, &new_vars, guard)?
1402                        } else {
1403                            self.eval_plan_with_limit(txn, &plan, &current_rows, guard, plan_limit)?
1404                        }
1405                    };
1406                    let mut new_vars = pattern_all_vars(&part.pattern);
1407                    if let Some(path_var) = &part.path_var {
1408                        new_vars.insert(path_var.clone());
1409                    }
1410                    current_rows = self.apply_with_or_carry(
1411                        txn,
1412                        &part.with,
1413                        current_rows,
1414                        new_vars,
1415                        &mut carried_vars,
1416                        guard,
1417                    )?;
1418                }
1419                QueryClause::Unwind(u) => {
1420                    current_rows = self.eval_unwind(txn, u, &current_rows, guard)?;
1421                    current_rows = self.apply_with_or_carry(
1422                        txn,
1423                        &u.with,
1424                        current_rows,
1425                        HashSet::from([u.var.clone()]),
1426                        &mut carried_vars,
1427                        guard,
1428                    )?;
1429                }
1430                QueryClause::Call(call) => {
1431                    current_rows = self.eval_call_clause(txn, call, &current_rows, guard)?;
1432                    let new_vars: HashSet<String> = match &call.yield_items {
1433                        Some(CallYield::Items(items, _)) => items
1434                            .iter()
1435                            .map(|(name, alias)| alias.clone().unwrap_or_else(|| name.clone()))
1436                            .collect(),
1437                        // `Star` never reaches here (`queryCallSt`'s own
1438                        // grammar has no `YIELD *` alternative) and `None`
1439                        // binds nothing new.
1440                        Some(CallYield::Star) | None => HashSet::new(),
1441                    };
1442                    current_rows = self.apply_with_or_carry(
1443                        txn,
1444                        &call.with,
1445                        current_rows,
1446                        new_vars,
1447                        &mut carried_vars,
1448                        guard,
1449                    )?;
1450                }
1451                QueryClause::Merge(m) => {
1452                    // MERGE always needs real `.insert`-capable write
1453                    // access, whether or not the rest of the statement
1454                    // would otherwise be read-only (e.g. `MERGE (n) RETURN
1455                    // n`) — see `is_read_only`, which already accounts for
1456                    // this by checking `clauses` too, so `txn` is
1457                    // guaranteed to be `Txn::Write` here.
1458                    let write_txn = require_write_txn(txn);
1459                    current_rows = self.eval_merge(write_txn, m, &current_rows, guard)?;
1460                    let mut new_vars = pattern_all_vars(&m.pattern);
1461                    if let Some(path_var) = &m.path_var {
1462                        new_vars.insert(path_var.clone());
1463                    }
1464                    current_rows = self.apply_with_or_carry(
1465                        txn,
1466                        &m.with,
1467                        current_rows,
1468                        new_vars,
1469                        &mut carried_vars,
1470                        guard,
1471                    )?;
1472                }
1473                // A statement-leading WITH -- no pattern was matched, so
1474                // there's nothing to seed `new_vars` with beyond what the
1475                // WITH clause itself projects (`apply_with_or_carry`
1476                // always takes the `Some(with)` branch here, never the
1477                // "no WITH, just extend carried_vars" one, since `with` is
1478                // always present on this variant by construction).
1479                QueryClause::With(with) => {
1480                    current_rows = self.apply_with_or_carry(
1481                        txn,
1482                        &Some(with.clone()),
1483                        current_rows,
1484                        HashSet::new(),
1485                        &mut carried_vars,
1486                        guard,
1487                    )?;
1488                }
1489                // `SET ... WITH ...` -- same real `.set_*_prop_in_txn`
1490                // write access `materialize_set`'s own per-row loop
1491                // already needs (guaranteed `Txn::Write` here for the
1492                // same reason its own docs give). Doesn't change any
1493                // row's bindings, only mutates the underlying graph --
1494                // `current_rows`/`carried_vars` both pass through
1495                // unchanged, the following `clause` (always a `WITH`,
1496                // see `set_as_clause`'s grammar) handles its own
1497                // projection/`WHERE`/`ORDER BY` normally from there.
1498                QueryClause::Set(items) => {
1499                    let write_txn = require_write_txn(txn);
1500                    for row in &current_rows {
1501                        for item in items {
1502                            self.apply_set_item(txn, write_txn, row, item, guard)?;
1503                        }
1504                    }
1505                }
1506                // `DELETE/DETACH DELETE ... WITH ...` -- same passthrough
1507                // reasoning as `QueryClause::Set` above (see
1508                // `delete_as_clause`'s grammar docs). Reuses the same
1509                // `delete_binding`/`delete_value` helpers `materialize_delete`
1510                // itself calls.
1511                QueryClause::Delete { items, detach } => {
1512                    let write_txn = require_write_txn(txn);
1513                    self.delete_targets(txn, write_txn, items, &current_rows, *detach, guard)?;
1514                }
1515                // `REMOVE ... WITH ...` -- same passthrough reasoning as
1516                // `QueryClause::Set` above (see `remove_as_clause`'s
1517                // grammar docs).
1518                QueryClause::Remove(items) => {
1519                    let write_txn = require_write_txn(txn);
1520                    for row in &current_rows {
1521                        for item in items {
1522                            apply_remove_item(write_txn, row, item)?;
1523                        }
1524                    }
1525                }
1526                // `CREATE ... WITH ...` -- unlike Set/Delete/Remove above,
1527                // this DOES change every row's bindings (each pattern's
1528                // own fresh/reused vars), so `current_rows` is replaced,
1529                // not passed through, and `carried_vars` is extended
1530                // directly (no bundled `.with` field on this variant to
1531                // route through `apply_with_or_carry` the way `Merge`
1532                // does above -- the following `WITH` is its own separate
1533                // `QueryClause::With` entry, picked up by this same loop's
1534                // next iteration, which needs `carried_vars` to already
1535                // reflect these new names by then).
1536                QueryClause::Create(patterns) => {
1537                    let write_txn = require_write_txn(txn);
1538                    current_rows =
1539                        self.materialize_create(write_txn, patterns, &current_rows, guard)?;
1540                    carried_vars.extend(patterns.iter().flat_map(pattern_all_vars));
1541                }
1542            }
1543            guard.check_intermediate_rows(current_rows.len())?;
1544        }
1545        // ORDER BY must see every matching row before LIMIT truncates —
1546        // sort, then take N, not the other way around. Only pre-truncate
1547        // (the v1 "doesn't short-circuit" path) when there's no ORDER BY to
1548        // invalidate it; DELETE/SET+LIMIT keep their "stop after N
1549        // bindings" behavior since they have no ORDER BY position in the
1550        // grammar. RETURN DISTINCT is excluded too, same reasoning as
1551        // ORDER BY: DISTINCT can still drop rows *after* this point, so
1552        // pre-truncating the raw input here could return fewer than
1553        // `limit` distinct rows even when more exist -- its LIMIT gets
1554        // applied after dedup instead, below.
1555        let distinct_return = tail_is_distinct_return(tail);
1556        if order_by.is_none() && !distinct_return {
1557            let skip_n = skip.unwrap_or(0).max(0) as usize;
1558            if skip_n > 0 {
1559                current_rows.drain(0..skip_n.min(current_rows.len()));
1560            }
1561            if let Some(count) = limit {
1562                current_rows.truncate(count.max(0) as usize);
1563            }
1564        }
1565        // Delete/Set need real `.insert`/`.remove`-capable write access,
1566        // not just `Txn`'s read-only `get`/`iter` — but they're only ever
1567        // reached via `Executor::execute`'s write-dispatch path (see
1568        // `is_read_only`), which always opens a `WriteTransaction`, so
1569        // `txn` is guaranteed to be `Txn::Write` here.
1570        // A non-aggregating RETURN's ORDER BY can reference either a
1571        // RETURN-introduced alias (`RETURN friend.id AS friendId ORDER BY
1572        // friendId`) or a variable still in scope that isn't returned at
1573        // all (`RETURN n.num AS prop ORDER BY n.num` — `n` itself never
1574        // appears in the RETURN list) — real Cypher allows both. Sorting
1575        // needs both the pre-projection bindings *and* the post-projection
1576        // output columns available at once, so it happens after
1577        // `materialize_return`, against a combined view of the two (see
1578        // `apply_order_by_with_scope`) rather than either alone. The
1579        // aggregating case can't use pre-projection bindings at all
1580        // (grouping has already collapsed the per-row bindings by then), so
1581        // it keeps sorting the post-projection output alone via
1582        // `apply_order_by`, further down.
1583        let mut order_by_pre_applied = false;
1584        let mut result = match tail {
1585            // A missing tail only ever occurs with a MERGE clause and
1586            // nothing after it — a pure write, same empty result shape
1587            // standalone CREATE already returns (not one blank row per
1588            // `current_rows`, which a synthetic `Tail::Return(vec![])`
1589            // would produce instead).
1590            None => QueryResult {
1591                columns: vec![],
1592                rows: vec![],
1593            },
1594            Some(Tail::Return(items, distinct)) => {
1595                if let Some(ob) = order_by {
1596                    // DISTINCT (like aggregation) can drop rows, breaking
1597                    // the 1:1 correspondence `apply_order_by_with_scope`
1598                    // needs between `current_rows` and the projected
1599                    // output -- ORDER BY after DISTINCT can only sort the
1600                    // post-projection, post-dedup result, same as the
1601                    // aggregating case just below.
1602                    if !has_aggregate(items) && !distinct {
1603                        let projected =
1604                            self.materialize_return(txn, items, &current_rows, *distinct, guard)?;
1605                        order_by_pre_applied = true;
1606                        self.apply_order_by_with_scope(
1607                            txn,
1608                            &current_rows,
1609                            projected,
1610                            ob,
1611                            skip,
1612                            limit,
1613                        )?
1614                    } else if !distinct {
1615                        order_by_pre_applied = true;
1616                        self.materialize_aggregating_return_with_order(
1617                            txn,
1618                            items,
1619                            &current_rows,
1620                            ob,
1621                            (skip, limit),
1622                            guard,
1623                        )?
1624                    } else {
1625                        self.materialize_return(txn, items, &current_rows, *distinct, guard)?
1626                    }
1627                } else {
1628                    self.materialize_return(txn, items, &current_rows, *distinct, guard)?
1629                }
1630            }
1631            Some(Tail::ReturnStar(distinct)) => {
1632                let items = return_star_items(carried_vars.iter().cloned())?;
1633                let projected =
1634                    self.materialize_return(txn, &items, &current_rows, *distinct, guard)?;
1635                if let Some(ob) = order_by {
1636                    if !distinct {
1637                        order_by_pre_applied = true;
1638                        self.apply_order_by_with_scope(
1639                            txn,
1640                            &current_rows,
1641                            projected,
1642                            ob,
1643                            skip,
1644                            limit,
1645                        )?
1646                    } else {
1647                        projected
1648                    }
1649                } else {
1650                    projected
1651                }
1652            }
1653            Some(Tail::Delete(vars, ret)) => {
1654                self.materialize_delete(txn, vars, &current_rows, false, ret, guard)?
1655            }
1656            Some(Tail::DetachDelete(vars, ret)) => {
1657                self.materialize_delete(txn, vars, &current_rows, true, ret, guard)?
1658            }
1659            Some(Tail::Set(items, ret)) => {
1660                self.materialize_set(txn, items, &current_rows, ret, guard)?
1661            }
1662            Some(Tail::Remove(items, ret)) => {
1663                self.materialize_remove(txn, items, &current_rows, ret, guard)?
1664            }
1665            Some(Tail::Create(patterns, ret)) => {
1666                let updated_rows = self.materialize_create(
1667                    require_write_txn(txn),
1668                    patterns,
1669                    &current_rows,
1670                    guard,
1671                )?;
1672                match ret {
1673                    Some(rt) => {
1674                        self.materialize_return(txn, &rt.items, &updated_rows, rt.distinct, guard)?
1675                    }
1676                    None => QueryResult {
1677                        columns: vec![],
1678                        rows: vec![],
1679                    },
1680                }
1681            }
1682        };
1683        if let Some(order_by) = order_by {
1684            if !order_by_pre_applied {
1685                let tail_items: Option<&[ReturnItem]> = match tail {
1686                    Some(Tail::Return(items, _)) => Some(items),
1687                    _ => None,
1688                };
1689                result.rows = apply_order_by(
1690                    result.rows,
1691                    &result.columns,
1692                    order_by,
1693                    tail_items,
1694                    skip,
1695                    limit,
1696                )?;
1697            }
1698        } else if distinct_return {
1699            // The pre-truncate above was skipped for exactly this case --
1700            // apply SKIP/LIMIT now, after materialize_return's dedup,
1701            // instead.
1702            let skip_n = skip.unwrap_or(0).max(0) as usize;
1703            if skip_n > 0 {
1704                result.rows.drain(0..skip_n.min(result.rows.len()));
1705            }
1706            if let Some(count) = limit {
1707                result.rows.truncate(count.max(0) as usize);
1708            }
1709        }
1710        guard.check_result_rows(result.rows.len())?;
1711        Ok(result)
1712    }
1713
1714    /// Applies a clause's optional trailing `WITH` (shared by both
1715    /// `QueryClause::Match` and `QueryClause::Unwind`, which can each end
1716    /// in one — see `QueryClause`'s docs), or, with no `WITH`, grows
1717    /// `carried_vars` by `new_vars` so the next clause shares this one's
1718    /// binding scope — same "no WITH means stay in scope" rule `OPTIONAL
1719    /// MATCH` already gets, now uniform across clause kinds.
1720    fn apply_with_or_carry(
1721        &self,
1722        txn: Txn,
1723        with: &Option<WithClause>,
1724        rows: Vec<BindingRow>,
1725        new_vars: HashSet<String>,
1726        carried_vars: &mut HashSet<String>,
1727        guard: &ExecutionGuard<'_>,
1728    ) -> Result<Vec<BindingRow>, QueryError> {
1729        let Some(with) = with else {
1730            carried_vars.extend(new_vars);
1731            return Ok(rows);
1732        };
1733        // `WITH *` -- expand to every name already carried into this
1734        // clause *plus* whatever this same clause's own pattern just
1735        // bound (`new_vars`, e.g. MERGE's own target -- `carried_vars`
1736        // alone wouldn't have that yet, since it's only ever updated at
1737        // this function's very end). `with_owned` only exists to give
1738        // the rest of this function a `&WithClause` with `items` already
1739        // containing the expanded names, without touching any of its
1740        // other fields (`order_by`/`skip`/`limit`/`distinct`/
1741        // `where_clause` all stay exactly as parsed).
1742        let with_owned;
1743        let with: &WithClause = if with.star {
1744            // A `HashSet` union, not a plain chain -- `new_vars` can
1745            // legitimately overlap with `carried_vars` (e.g. `MATCH (a)
1746            // MERGE (a)-[:R]->(b)` reuses the already-bound `a`), and a
1747            // raw chain would double it up into two identical columns.
1748            let star_items = with_star_items(carried_vars.union(&new_vars).cloned());
1749            let mut owned = with.clone();
1750            let mut items = star_items;
1751            items.extend(owned.items);
1752            owned.items = items;
1753            with_owned = owned;
1754            &with_owned
1755        } else {
1756            with
1757        };
1758        let with_skip = self.resolve_skip_limit(txn, with.skip.as_ref(), "SKIP", guard)?;
1759        let with_limit = self.resolve_skip_limit(txn, with.limit.as_ref(), "LIMIT", guard)?;
1760        let rows = if let Some(with_order_by) = with
1761            .order_by
1762            .as_ref()
1763            .filter(|_| has_aggregate(&with.items))
1764        {
1765            // `materialize_aggregating_with_with_order` folds its own
1766            // extra composed ORDER BY keys through the same grouping pass
1767            // as `with.items` -- also covers `with.distinct` correctly
1768            // without any extra handling here, since grouping already
1769            // makes every output row unique by its own grouping-key
1770            // columns (see that function's `RETURN`-side twin's own docs
1771            // on why that makes `DISTINCT` a no-op downstream of
1772            // aggregation).
1773            self.materialize_aggregating_with_with_order(
1774                txn,
1775                &with.items,
1776                &rows,
1777                with_order_by,
1778                (with_skip, with_limit),
1779                guard,
1780            )?
1781        } else {
1782            // Only cloned when actually needed below (ORDER BY on a
1783            // non-aggregating, non-`DISTINCT` WITH) -- avoids the copy on
1784            // every other WITH shape.
1785            let pre_with_rows = (with.order_by.is_some() && !with.distinct).then(|| rows.clone());
1786            let mut rows = self.materialize_with(txn, with, &rows, guard)?;
1787            if let Some(with_order_by) = &with.order_by {
1788                // Only a non-aggregating, non-`DISTINCT` WITH keeps a 1:1
1789                // row correspondence with its pre-WITH input -- see
1790                // `apply_order_by_bindings`'s own docs on why that's
1791                // exactly when ORDER BY can also see the pre-WITH scope.
1792                rows = self.apply_order_by_bindings(
1793                    txn,
1794                    rows,
1795                    pre_with_rows.as_deref(),
1796                    &with.items,
1797                    with_order_by,
1798                    (with_skip, with_limit),
1799                )?;
1800            } else {
1801                let skip_n = with_skip.unwrap_or(0).max(0) as usize;
1802                if skip_n > 0 {
1803                    rows.drain(0..skip_n.min(rows.len()));
1804                }
1805                if let Some(with_limit) = with_limit {
1806                    rows.truncate(with_limit.max(0) as usize);
1807                }
1808            }
1809            rows
1810        };
1811        *carried_vars = with
1812            .items
1813            .iter()
1814            .enumerate()
1815            .map(with_item_output_name)
1816            .collect();
1817        Ok(rows)
1818    }
1819
1820    /// `UNWIND`'s fan-out. Not a graph traversal — like `WITH`, handled
1821    /// directly here rather than through a `LogicalPlan`/`eval_plan` (see
1822    /// `UnwindClause`'s docs). Cross-joins each input row against every
1823    /// element of that row's resolved list, then applies the clause's own
1824    /// `WHERE`.
1825    fn eval_unwind(
1826        &self,
1827        txn: Txn,
1828        clause: &UnwindClause,
1829        rows: &[BindingRow],
1830        guard: &ExecutionGuard<'_>,
1831    ) -> Result<Vec<BindingRow>, QueryError> {
1832        let mut out = Vec::new();
1833        for row in rows {
1834            let source_value = self.eval_return_expr(txn, &clause.source.0, row, guard)?;
1835            let elements: Vec<Binding> = match source_value {
1836                Value::List(items) => items.iter().map(value_to_binding_restore).collect(),
1837                // `UNWIND null AS x` behaves like unwinding an empty list
1838                // (zero rows) in real Cypher, not an error.
1839                Value::Null => Vec::new(),
1840                other => {
1841                    return Err(QueryError::Type(format!(
1842                        "UNWIND needs a list, got {other:?}"
1843                    )))
1844                }
1845            };
1846            for element in elements {
1847                let mut new_row = row.clone();
1848                new_row.insert(clause.var.clone(), element);
1849                out.push(new_row);
1850            }
1851        }
1852        if let Some(where_clause) = &clause.where_clause {
1853            let mut filtered = Vec::with_capacity(out.len());
1854            for row in out {
1855                if self.eval_with_expr(txn, where_clause, &row, guard)? == Some(true) {
1856                    filtered.push(row);
1857                }
1858            }
1859            out = filtered;
1860        }
1861        Ok(out)
1862    }
1863
1864    /// `shortestPath((a)-[:TYPE*..N]-(b))` — a real parent-pointer BFS
1865    /// between two already-bound endpoints, not a `LogicalPlan`/
1866    /// `VarExpand` traversal (which only tracks final position plus a
1867    /// visited set, not the hop-by-hop chain a path needs to reconstruct).
1868    /// BFS visits in non-decreasing depth order, so the first time `b` is
1869    /// reached is *a* shortest path — stop there and reconstruct via
1870    /// parent pointers, rather than enumerating every path up to some
1871    /// bound the way `VarExpand` does.
1872    ///
1873    /// Both endpoints must already be bound by a preceding clause (e.g.
1874    /// `MATCH (a:Person{name:'Alice'}), (b:Person{name:'Bob'}) MATCH p =
1875    /// shortestPath((a)-[:KNOWS*]-(b)) RETURN p` — parser-enforced, see
1876    /// `parser::validate_shortest_path_pattern`) — v1 doesn't attempt to
1877    /// resolve a fresh/scanned endpoint here the way ordinary MATCH does,
1878    /// since "shortest path to *any* node matching these constraints" is a
1879    /// different, more ambiguous question than "shortest path between
1880    /// these two specific nodes."
1881    ///
1882    /// Every input row always survives (unlike an ordinary pattern match,
1883    /// which can produce zero rows for a non-match) — an unreachable pair
1884    /// binds the path variable to `Null`, same as `OPTIONAL MATCH`'s
1885    /// null-padding, rather than dropping the row. `part.optional` is
1886    /// therefore a no-op here, not separately handled. Exceeding the
1887    /// safety depth cap on an unbounded (`*..`) search also resolves to
1888    /// `Null`, not an error — unlike `VarExpand`'s cap (which errors,
1889    /// because truncating there would silently produce an *incomplete
1890    /// set* of paths, a wrong-answer risk), `shortestPath()` is only ever
1891    /// answering "is there a path within the searched horizon," which is
1892    /// a well-defined answer either way.
1893    fn eval_shortest_path(
1894        &self,
1895        txn: Txn,
1896        part: &QueryPart,
1897        rows: &[BindingRow],
1898        guard: &ExecutionGuard<'_>,
1899    ) -> Result<Vec<BindingRow>, QueryError> {
1900        let Some(path_var) = &part.path_var else {
1901            // Nothing names the result, so there's nothing to bind and no
1902            // filtering effect (see this function's docs) — pure no-op.
1903            return Ok(rows.to_vec());
1904        };
1905        let start_var = part.pattern.start.var.as_deref().expect(
1906            "shortestPath()'s start node always has a var — validated at parse time by \
1907             validate_shortest_path_pattern",
1908        );
1909        let (rel, end_node) = &part.pattern.hops[0];
1910        let end_var = end_node.var.as_deref().expect(
1911            "shortestPath()'s end node always has a var — validated at parse time by \
1912             validate_shortest_path_pattern",
1913        );
1914        let (min_hops, max_hops) = rel.hop_range.expect(
1915            "shortestPath()'s relationship is always variable-length — validated at parse time by \
1916             validate_shortest_path_pattern",
1917        );
1918        let direction = match rel.direction {
1919            RelDirection::Right => ExpandDirection::Out,
1920            RelDirection::Left => ExpandDirection::In,
1921            RelDirection::Either => ExpandDirection::Either,
1922        };
1923        let rel_labels = &rel.rel_types;
1924
1925        let mut out = Vec::with_capacity(rows.len());
1926        for row in rows {
1927            let start_id = require_bound_node(row, start_var)?;
1928            let end_id = require_bound_node(row, end_var)?;
1929            let path = self.shortest_path_between(
1930                txn,
1931                start_id,
1932                end_id,
1933                ShortestPathSpec {
1934                    direction,
1935                    rel_labels,
1936                    min_hops,
1937                    max_hops,
1938                },
1939            )?;
1940            let mut new_row = row.clone();
1941            let binding = match path {
1942                Some(elems) => Binding::Path(elems),
1943                None => Binding::Value(PropertyValue::Null),
1944            };
1945            new_row.insert(path_var.clone(), binding);
1946            out.push(new_row);
1947        }
1948        if let Some(where_clause) = &part.where_clause {
1949            let mut filtered = Vec::with_capacity(out.len());
1950            for row in out {
1951                if self.eval_expr(txn, where_clause, &row, guard)? == Some(true) {
1952                    filtered.push(row);
1953                }
1954            }
1955            out = filtered;
1956        }
1957        Ok(out)
1958    }
1959
1960    /// The BFS itself. `min_hops` is only ever 0 or 1 (`validate_shortest_
1961    /// path_pattern` rejects anything higher) — deliberately: a plain
1962    /// visited-set BFS can't correctly answer "shortest path of at least N
1963    /// hops" for N > 1 (a node first reached at a too-early depth would
1964    /// need to stay revisitable for a later, longer route to it, which a
1965    /// visited-set structurally can't represent) without a different
1966    /// (node, depth)-keyed algorithm. Rejecting the case outright at parse
1967    /// time is safer than silently answering it wrong.
1968    fn shortest_path_between(
1969        &self,
1970        txn: Txn,
1971        start: NodeId,
1972        end: NodeId,
1973        spec: ShortestPathSpec<'_>,
1974    ) -> Result<Option<Vec<PathBinding>>, QueryError> {
1975        if start == end && spec.min_hops == 0 {
1976            return Ok(Some(vec![PathBinding::Node(start)]));
1977        }
1978        let cap = spec.max_hops.unwrap_or(VAR_EXPAND_DEPTH_CAP);
1979        let mut parent: HashMap<NodeId, (NodeId, EdgeId)> = HashMap::new();
1980        let mut visited: HashSet<NodeId> = HashSet::new();
1981        visited.insert(start);
1982        let mut frontier = vec![start];
1983        let mut depth = 0u32;
1984        while depth < cap && !frontier.is_empty() {
1985            depth += 1;
1986            let mut next_frontier = Vec::new();
1987            for node in frontier {
1988                for entry in neighbors_for_direction(txn, node, spec.direction, spec.rel_labels)? {
1989                    if entry.other == end {
1990                        parent.insert(entry.other, (node, entry.edge_id));
1991                        return Ok(Some(reconstruct_path(&parent, start, end)));
1992                    }
1993                    if visited.insert(entry.other) {
1994                        parent.insert(entry.other, (node, entry.edge_id));
1995                        next_frontier.push(entry.other);
1996                    }
1997                }
1998            }
1999            frontier = next_frontier;
2000        }
2001        Ok(None)
2002    }
2003
2004    /// Projects `rows` through a `WITH` clause. Unlike `materialize_return`
2005    /// (which resolves everything down to display `Value`s), a bare
2006    /// variable reference (`WITH message`) must keep its graph identity
2007    /// (`Binding::Node`/`Edge`) so the next `QueryPart` can keep
2008    /// traversing from it — only computed expressions collapse to a
2009    /// scalar `Binding::Value`.
2010    fn materialize_with(
2011        &self,
2012        txn: Txn,
2013        with: &WithClause,
2014        rows: &[BindingRow],
2015        guard: &ExecutionGuard<'_>,
2016    ) -> Result<Vec<BindingRow>, QueryError> {
2017        let is_aggregating = has_aggregate(&with.items);
2018        let mut out = if !is_aggregating {
2019            let mut out = Vec::with_capacity(rows.len());
2020            for row in rows {
2021                let mut new_row = BindingRow::new();
2022                for (i, item) in with.items.iter().enumerate() {
2023                    let name = with_item_output_name((i, item));
2024                    let binding = self.item_binding(txn, &item.expr, row, guard)?;
2025                    new_row.insert(name, binding);
2026                }
2027                out.push(new_row);
2028            }
2029            out
2030        } else {
2031            validate_return_items(&with.items)?;
2032            let grouped = self.resolve_grouped_rows(txn, &with.items, rows, guard)?;
2033            grouped
2034                .into_iter()
2035                .map(|bindings| {
2036                    with.items
2037                        .iter()
2038                        .enumerate()
2039                        .zip(bindings)
2040                        .map(|((i, item), b)| (with_item_output_name((i, item)), b))
2041                        .collect()
2042                })
2043                .collect()
2044        };
2045        if let Some(where_clause) = &with.where_clause {
2046            let mut filtered = Vec::with_capacity(out.len());
2047            if is_aggregating {
2048                // Aggregation collapses many input rows into one group --
2049                // there's no single pre-WITH row left to fall back to, so
2050                // (matching real Cypher) WHERE only sees the grouped/
2051                // aggregated names, same as `RETURN`'s own aggregate WHERE.
2052                for row in out {
2053                    if self.eval_with_expr(txn, where_clause, &row, guard)? == Some(true) {
2054                        filtered.push(row);
2055                    }
2056                }
2057            } else {
2058                // Real Cypher lets a `WITH x AS y WHERE ...` immediately
2059                // following see *both* the pre-WITH binding (`x`) and the
2060                // new alias (`y`) -- confirmed via the TCK's own
2061                // `WithWhere7` scenarios. New aliases shadow same-named
2062                // old bindings on conflict. Still true with `DISTINCT` --
2063                // unlike aggregation, `DISTINCT` alone doesn't collapse
2064                // several pre-WITH rows into one *ambiguous* group; it's
2065                // a dedup applied to the *surviving*, still individually-
2066                // real rows, which is why the dedup itself happens below,
2067                // after this filter, not before it (TCK's WithWhere1
2068                // `[2]`: `WITH DISTINCT a.name2 AS name WHERE a.name2 =
2069                // 'B'` needs `a` from the row that produced each
2070                // candidate `name`, not just `name` itself).
2071                for (row, new_row) in rows.iter().zip(out) {
2072                    let mut merged = row.clone();
2073                    merged.extend(new_row.iter().map(|(k, v)| (k.clone(), v.clone())));
2074                    if self.eval_with_expr(txn, where_clause, &merged, guard)? == Some(true) {
2075                        filtered.push(new_row);
2076                    }
2077                }
2078            }
2079            out = filtered;
2080        }
2081        if with.distinct {
2082            out = dedup_binding_rows(&with.items, out)?;
2083        }
2084        Ok(out)
2085    }
2086
2087    /// `materialize_aggregating_return_with_order`'s `WITH`-side twin --
2088    /// same "fold extra composed ORDER BY keys through the same grouping
2089    /// pass as `with_items` themselves" approach (TCK's WithOrderBy4
2090    /// `[16]`-`[18]`), just producing `Vec<BindingRow>` (preserving graph
2091    /// identity for whatever clause comes after this `WITH`) instead of a
2092    /// final `QueryResult` -- the extra keys' own values are only ever
2093    /// used for sorting here, never carried into the output rows.
2094    fn materialize_aggregating_with_with_order(
2095        &self,
2096        txn: Txn,
2097        with_items: &[ReturnItem],
2098        rows: &[BindingRow],
2099        order_by: &[(ReturnExpr, SortDir)],
2100        skip_limit: (Option<i64>, Option<i64>),
2101        guard: &ExecutionGuard<'_>,
2102    ) -> Result<Vec<BindingRow>, QueryError> {
2103        let (skip, limit) = skip_limit;
2104        enum OrderKeySource {
2105            RealColumn(usize),
2106            Extra(usize),
2107        }
2108        let mut extra_exprs: Vec<ReturnExpr> = Vec::new();
2109        let order_by_source: Vec<OrderKeySource> = order_by
2110            .iter()
2111            .map(|(expr, _)| {
2112                match with_items
2113                    .iter()
2114                    .enumerate()
2115                    .position(|(i, it)| item_matches_leaf(expr, i, it))
2116                {
2117                    Some(i) => OrderKeySource::RealColumn(i),
2118                    None => {
2119                        let idx = extra_exprs.len();
2120                        extra_exprs.push(expr.clone());
2121                        OrderKeySource::Extra(idx)
2122                    }
2123                }
2124            })
2125            .collect();
2126        let extended_items: Vec<ReturnItem> = with_items
2127            .iter()
2128            .cloned()
2129            .chain(
2130                extra_exprs
2131                    .into_iter()
2132                    .map(|expr| ReturnItem { expr, alias: None }),
2133            )
2134            .collect();
2135        validate_return_items(&extended_items)?;
2136        let grouped = self.resolve_grouped_rows(txn, &extended_items, rows, guard)?;
2137        let real_len = with_items.len();
2138        let mut keyed: Vec<(Vec<Value>, BindingRow)> = Vec::with_capacity(grouped.len());
2139        for bindings in grouped {
2140            let (real, extra) = bindings.split_at(real_len);
2141            let real_values: Vec<Value> = real
2142                .iter()
2143                .map(|b| self.binding_to_value(txn, b))
2144                .collect::<Result<Vec<_>, _>>()?;
2145            let extra_values: Vec<Value> = extra
2146                .iter()
2147                .map(|b| self.binding_to_value(txn, b))
2148                .collect::<Result<Vec<_>, _>>()?;
2149            let keys: Vec<Value> = order_by_source
2150                .iter()
2151                .map(|src| match src {
2152                    OrderKeySource::RealColumn(i) => real_values[*i].clone(),
2153                    OrderKeySource::Extra(k) => extra_values[*k].clone(),
2154                })
2155                .collect();
2156            let real_row: BindingRow = with_items
2157                .iter()
2158                .enumerate()
2159                .zip(real)
2160                .map(|((i, item), binding)| (with_item_output_name((i, item)), binding.clone()))
2161                .collect();
2162            keyed.push((keys, real_row));
2163        }
2164        Ok(top_k_by(keyed, order_by, skip, limit)
2165            .into_iter()
2166            .map(|(_, row)| row)
2167            .collect())
2168    }
2169
2170    /// The `Binding` one WITH/RETURN item evaluates to for one input row. A
2171    /// bare `Var` keeps its graph identity (`Binding::Node`/`Edge`) so a
2172    /// later `QueryPart` can keep traversing from it; anything else
2173    /// (computed expressions) collapses to `Binding::Value`. Shared by the
2174    /// non-aggregating `materialize_with` path and grouping-key evaluation.
2175    fn item_binding(
2176        &self,
2177        txn: Txn,
2178        expr: &ReturnExpr,
2179        row: &BindingRow,
2180        guard: &ExecutionGuard<'_>,
2181    ) -> Result<Binding, QueryError> {
2182        match expr {
2183            ReturnExpr::Var(v) => row
2184                .get(v)
2185                .cloned()
2186                .ok_or_else(|| QueryError::UnboundVariable(v.clone())),
2187            other => {
2188                let value = self.eval_return_expr(txn, other, row, guard)?;
2189                // `value_to_property_value` collapses Node/Edge/List/Path
2190                // to Null -- fine for a bare Var (handled above, never
2191                // reaches here) but wrong for any *wrapped* non-Var
2192                // expression that still evaluates to one of those (a list
2193                // literal/index/slice, or a CASE branch returning a bound
2194                // node/edge): those need the matching real Binding kind,
2195                // not a silently-nulled scalar. `Path` still falls back to
2196                // Null here -- a real, separate gap (needs a `Value::Path`
2197                // -> `Binding::Path` conversion this doesn't have yet),
2198                // not something any currently-reachable expression form
2199                // produces though.
2200                Ok(match value {
2201                    Value::Node(n) => Binding::Node(n.id),
2202                    Value::Edge(e) => Binding::Edge(e.id),
2203                    Value::List(items) => Binding::List(items),
2204                    Value::Map(m) => Binding::Map(m),
2205                    other => Binding::Value(value_to_property_value(&other)),
2206                })
2207            }
2208        }
2209    }
2210
2211    /// Same sort as `apply_order_by`, but over `BindingRow`s (a `WITH`
2212    /// clause's own ORDER BY, which must run before that row set becomes
2213    /// the seed for the next `QueryPart` — sorting/limiting a WITH changes
2214    /// *which* rows continue, not just their presentation order).
2215    fn apply_order_by_bindings(
2216        &self,
2217        txn: Txn,
2218        rows: Vec<BindingRow>,
2219        // `Some`, same length as `rows`, only for a non-aggregating,
2220        // non-`DISTINCT` WITH (1:1 row correspondence with the pre-WITH
2221        // input) -- lets ORDER BY see both the pre-WITH scope and the
2222        // new aliases, matching `where_clause`'s own merge (real Cypher:
2223        // `WITH a.count AS count ORDER BY a.count`, `a` isn't projected
2224        // but is still a valid sort key, TCK's With4 [6]). `None` for an
2225        // aggregating/`DISTINCT` WITH -- many pre-WITH rows collapse
2226        // into one output row there, so no single pre-WITH scope exists
2227        // to merge in.
2228        pre_with_rows: Option<&[BindingRow]>,
2229        with_items: &[ReturnItem],
2230        order_by: &[(ReturnExpr, SortDir)],
2231        skip_limit: (Option<i64>, Option<i64>),
2232    ) -> Result<Vec<BindingRow>, QueryError> {
2233        let (skip, limit) = skip_limit;
2234        // Same reasoning as `apply_order_by`'s `order_by_col` shortcut: an
2235        // ORDER BY item that repeats a WITH item's expression verbatim
2236        // (`WITH sum(x) AS s ORDER BY sum(x)`, TCK's WithOrderBy4 [11])
2237        // refers to that already-computed item, not a fresh expression --
2238        // look it up by its output name directly (works whether or not
2239        // that item has an alias) rather than re-evaluating the
2240        // expression, which would need pre-aggregation bindings that no
2241        // longer exist at this post-`materialize_with` point (an
2242        // aggregate call reaching `eval_projected_expr` always errors, by
2243        // design).
2244        let order_by_output: Vec<Option<String>> = order_by
2245            .iter()
2246            .map(|(expr, _)| {
2247                with_items
2248                    .iter()
2249                    .enumerate()
2250                    .find(|(_, item)| item.expr == *expr)
2251                    .map(with_item_output_name)
2252            })
2253            .collect();
2254        let mut keyed: Vec<(Vec<Value>, BindingRow)> = Vec::with_capacity(rows.len());
2255        for (i, row) in rows.into_iter().enumerate() {
2256            let mut value_map = self.binding_row_to_value_map(txn, &row)?;
2257            if let Some(pre) = pre_with_rows {
2258                // Pre-WITH names fill in gaps only -- a new alias with the
2259                // same name already occupies that key in `value_map` and
2260                // must keep winning (matches `materialize_with`'s own
2261                // "new aliases shadow same-named old bindings" rule).
2262                for (k, v) in self.binding_row_to_value_map(txn, &pre[i])? {
2263                    value_map.entry(k).or_insert(v);
2264                }
2265            }
2266            let keys = order_by
2267                .iter()
2268                .zip(&order_by_output)
2269                .map(|((expr, _), output_name)| match output_name {
2270                    Some(name) => Ok(value_map.get(name).cloned().unwrap_or(Value::Null)),
2271                    None => eval_projected_expr(expr, &value_map),
2272                })
2273                .collect::<Result<Vec<_>, _>>()?;
2274            keyed.push((keys, row));
2275        }
2276        Ok(top_k_by(keyed, order_by, skip, limit)
2277            .into_iter()
2278            .map(|(_, row)| row)
2279            .collect())
2280    }
2281
2282    /// Sorts an already-`materialize_return`d result for a non-aggregating
2283    /// `RETURN`, evaluating each ORDER BY expression against *both* the
2284    /// pre-projection `BindingRow` it came from and its own projected
2285    /// output columns overlaid on top — real Cypher allows ORDER BY to
2286    /// reference either a RETURN alias or a still-in-scope variable that
2287    /// wasn't returned at all, so neither view alone is enough (see the
2288    /// call site in `execute_match`). `binding_rows` and `result.rows` are
2289    /// the same length and pairwise correspond — `materialize_return`'s
2290    /// non-aggregating path preserves row order 1:1 with its input.
2291    fn apply_order_by_with_scope(
2292        &self,
2293        txn: Txn,
2294        binding_rows: &[BindingRow],
2295        result: QueryResult,
2296        order_by: &[(ReturnExpr, SortDir)],
2297        skip: Option<i64>,
2298        limit: Option<i64>,
2299    ) -> Result<QueryResult, QueryError> {
2300        let QueryResult { columns, rows } = result;
2301        let mut keyed: Vec<(Vec<Value>, Vec<Value>)> = Vec::with_capacity(rows.len());
2302        for (binding_row, row) in binding_rows.iter().zip(rows) {
2303            let mut value_map = self.binding_row_to_value_map(txn, binding_row)?;
2304            for (col, val) in columns.iter().zip(&row) {
2305                value_map.insert(col.clone(), val.clone());
2306            }
2307            let keys = order_by
2308                .iter()
2309                .map(|(expr, _)| eval_projected_expr(expr, &value_map))
2310                .collect::<Result<Vec<_>, _>>()?;
2311            keyed.push((keys, row));
2312        }
2313        let rows = top_k_by(keyed, order_by, skip, limit)
2314            .into_iter()
2315            .map(|(_, row)| row)
2316            .collect();
2317        Ok(QueryResult { columns, rows })
2318    }
2319
2320    fn binding_row_to_value_map(
2321        &self,
2322        txn: Txn,
2323        row: &BindingRow,
2324    ) -> Result<HashMap<String, Value>, QueryError> {
2325        let mut map = HashMap::with_capacity(row.len());
2326        for (k, binding) in row {
2327            map.insert(k.clone(), self.binding_to_value(txn, binding)?);
2328        }
2329        Ok(map)
2330    }
2331
2332    /// Resolves a `Binding` to its display `Value` — a `Node`/`Edge`
2333    /// binding fetches the full current record, a scalar `Value` binding
2334    /// passes through (collapsing a stored `PropertyValue::Null` to
2335    /// `Value::Null`, same as everywhere else null is represented).
2336    fn binding_to_value(&self, txn: Txn, b: &Binding) -> Result<Value, QueryError> {
2337        Ok(match b {
2338            Binding::Node(id) => Value::Node(deleted_entity_access(GraphStore::get_node_in_txn(
2339                txn, *id,
2340            )?)?),
2341            Binding::Edge(id) => Value::Edge(deleted_entity_access(GraphStore::get_edge_in_txn(
2342                txn, *id,
2343            )?)?),
2344            Binding::Value(PropertyValue::Null) => Value::Null,
2345            Binding::Value(pv) => property_value_to_value(pv.clone()),
2346            Binding::List(items) => Value::List(items.clone()),
2347            Binding::Map(m) => Value::Map(m.clone()),
2348            Binding::Path(elems) => Value::Path(self.resolve_path_elems(txn, elems)?),
2349        })
2350    }
2351
2352    /// `startNode(r)`/`endNode(r)` — unlike every other builtin function
2353    /// (`labels()`, `type()`, ...), which reads straight off the already-
2354    /// materialized `Value::Node`/`Edge` it's given, this needs a *second*
2355    /// `GraphStore` lookup: `Edge.src`/`.dst` are bare `NodeId`s, not full
2356    /// records. `call_builtin` (the free function every other builtin
2357    /// dispatches through) has no `Txn` to do that lookup with, so these
2358    /// two are special-cased here instead, before ever reaching it.
2359    fn start_or_end_node(
2360        &self,
2361        txn: Txn,
2362        which: &str,
2363        arg: Option<&Value>,
2364    ) -> Result<Value, QueryError> {
2365        match arg {
2366            None | Some(Value::Null) => Ok(Value::Null),
2367            Some(Value::Edge(e)) => {
2368                let id = if which == "startnode" { e.src } else { e.dst };
2369                let node = deleted_entity_access(GraphStore::get_node_in_txn(txn, id)?)?;
2370                Ok(Value::Node(node))
2371            }
2372            Some(other) => Err(QueryError::Type(format!(
2373                "{which}() expects a relationship, got {other:?}"
2374            ))),
2375        }
2376    }
2377
2378    /// `type(r)` -- unlike every other property/label access, real Cypher
2379    /// still allows this after `DELETE r` deleted the relationship
2380    /// earlier in the same statement (a relationship's type never
2381    /// changes, so there's nothing mutable a live record could be hiding
2382    /// -- unlike `labels()`/property access, which stay real
2383    /// `DeletedEntityAccess` errors, TCK's Return2 `[14]`-`[17]`). Tries
2384    /// the ordinary evaluation first; only on failure, and only for a
2385    /// bare `Var` bound to an edge, falls back to `guard`'s cached type
2386    /// from the moment it was deleted (`ExecutionGuard::
2387    /// deleted_edge_types`'s own docs). Any other failure (unbound
2388    /// variable, a genuinely wrong argument type, ...) propagates
2389    /// unchanged.
2390    fn eval_type_call(
2391        &self,
2392        txn: Txn,
2393        arg_expr: Option<&ReturnExpr>,
2394        row: &BindingRow,
2395        guard: &ExecutionGuard<'_>,
2396    ) -> Result<Value, QueryError> {
2397        let Some(arg_expr) = arg_expr else {
2398            return type_builtin(None);
2399        };
2400        match self.eval_return_expr(txn, arg_expr, row, guard) {
2401            Ok(v) => type_builtin(Some(&v)),
2402            Err(err) => {
2403                if let ReturnExpr::Var(v) = arg_expr {
2404                    if let Some(Binding::Edge(id)) = row.get(v) {
2405                        if let Some(label) = guard.deleted_edge_type(*id) {
2406                            return Ok(Value::Property(PropertyValue::String(label)));
2407                        }
2408                    }
2409                }
2410                Err(err)
2411            }
2412        }
2413    }
2414
2415    /// `binding_to_value`'s per-element helper for `Binding::Path` — fetches
2416    /// each element's full current record, same "keep just the id in the
2417    /// row, resolve to a full record only when materializing for display"
2418    /// split `Binding::Node`/`Edge` already use above.
2419    fn resolve_path_elems(
2420        &self,
2421        txn: Txn,
2422        elems: &[PathBinding],
2423    ) -> Result<Vec<PathElem>, QueryError> {
2424        elems
2425            .iter()
2426            .map(|e| {
2427                Ok(match e {
2428                    PathBinding::Node(id) => PathElem::Node(deleted_entity_access(
2429                        GraphStore::get_node_in_txn(txn, *id)?,
2430                    )?),
2431                    PathBinding::Edge(id) => PathElem::Edge(deleted_entity_access(
2432                        GraphStore::get_edge_in_txn(txn, *id)?,
2433                    )?),
2434                })
2435            })
2436            .collect()
2437    }
2438
2439    /// Folds `rows` into groups keyed by every non-aggregate item's per-row
2440    /// `Binding` (via `item_binding`), then finishes each aggregating
2441    /// item's accumulator(s) per group. Returns one `Vec<Binding>` per
2442    /// output group, column-aligned with `items`. Shared by
2443    /// `materialize_with` and `materialize_return` — both already take the
2444    /// same `rows: &[BindingRow]` input type, so the grouping core stays
2445    /// in `Binding`-space (preserving graph identity for bare-var grouping
2446    /// keys) and each caller does its own thin final conversion.
2447    ///
2448    /// An item "aggregates" (`contains_aggregate`) in one of two shapes:
2449    /// purely (`count(a)`, `count(*)`, the only shape this used to
2450    /// support) or composed with other expressions (`count(a) + 3`, `a,
2451    /// count(a)` isn't this -- `a` is its own separate, non-aggregating
2452    /// item). Either way, `Group.accs[i]` holds one `AggAcc` per
2453    /// aggregate-bearing subexpression found in that item's tree
2454    /// (`collect_agg_nodes`'s order — empty for a non-aggregating item,
2455    /// exactly one for the purely-aggregating shape), and finishing a
2456    /// composed item evaluates its whole expression tree via
2457    /// `rewrite_composed_item` rather than just unwrapping a single
2458    /// accumulator. `validate_return_items` (which callers must run
2459    /// first) already guarantees every non-aggregate leaf inside a
2460    /// composed item's tree matches some *other* item's own top-level
2461    /// expression verbatim, so this function trusts that invariant rather
2462    /// than re-checking it.
2463    ///
2464    /// Grouping-key lookup is a hash-map lookup (`group_index`, keyed by
2465    /// `binding_hash_key`'s output — `Binding`/`PropertyValue` don't
2466    /// derive `Eq`/`Hash` themselves, `PropertyValue::Float` can't, so
2467    /// `HashKey` stands in for them; see its docs) into `groups`, which
2468    /// stays a plain `Vec` for insertion-order-stable output when there's
2469    /// no ORDER BY. O(1) average per row, not the O(rows × groups) linear
2470    /// scan this used to be — see BENCHMARKS.md for the measured
2471    /// before/after.
2472    fn resolve_grouped_rows(
2473        &self,
2474        txn: Txn,
2475        items: &[ReturnItem],
2476        rows: &[BindingRow],
2477        guard: &ExecutionGuard<'_>,
2478    ) -> Result<Vec<Vec<Binding>>, QueryError> {
2479        struct Group {
2480            // Aligned to `items`: `Some` at a non-aggregating item's
2481            // index, `None` at an aggregating one's (whether purely
2482            // aggregating or composed) -- exactly one of
2483            // `key_bindings[i]`/`!accs[i].is_empty()` holds per `i`.
2484            key_bindings: Vec<Option<Binding>>,
2485            accs: Vec<Vec<AggAcc>>,
2486            row_count: i64,
2487        }
2488        fn fresh_accs(items: &[ReturnItem]) -> Vec<Vec<AggAcc>> {
2489            items
2490                .iter()
2491                .map(|item| {
2492                    let mut nodes = Vec::new();
2493                    collect_agg_nodes(&item.expr, &mut nodes);
2494                    nodes
2495                        .into_iter()
2496                        .map(|node| match node {
2497                            ReturnExpr::CountStar => AggAcc::identity("count", false),
2498                            ReturnExpr::Call { name, distinct, .. } => {
2499                                AggAcc::identity(name, *distinct)
2500                            }
2501                            _ => unreachable!(
2502                                "collect_agg_nodes only ever collects CountStar/aggregate Call nodes"
2503                            ),
2504                        })
2505                        .collect()
2506                })
2507                .collect()
2508        }
2509        // Computed once, not per row -- `item_agg_nodes[i][k]` is exactly
2510        // the node `group.accs[i][k]` accumulates for, every row.
2511        let item_agg_nodes: Vec<Vec<&ReturnExpr>> = items
2512            .iter()
2513            .map(|item| {
2514                let mut nodes = Vec::new();
2515                collect_agg_nodes(&item.expr, &mut nodes);
2516                nodes
2517            })
2518            .collect();
2519
2520        // Groups live in `groups` (insertion order, for stable output when
2521        // there's no ORDER BY) with `group_index` as a hash-based lookup
2522        // into it, keyed by a hashable stand-in for `key_bindings` (see
2523        // `HashKey` — `Binding`/`PropertyValue` don't derive `Eq`/`Hash`
2524        // themselves, `PropertyValue::Float` can't). O(1) average lookup
2525        // per row instead of the O(groups) linear scan this replaced —
2526        // see BENCHMARKS.md for the measured before/after.
2527        let mut groups: Vec<Group> = Vec::new();
2528        let mut group_index: HashMap<Vec<Option<HashKey>>, usize> = HashMap::new();
2529        for row in rows {
2530            let mut key_bindings = Vec::with_capacity(items.len());
2531            for item in items {
2532                key_bindings.push(if contains_aggregate(&item.expr) {
2533                    None
2534                } else {
2535                    Some(self.item_binding(txn, &item.expr, row, guard)?)
2536                });
2537            }
2538            let hash_key: Vec<Option<HashKey>> = key_bindings
2539                .iter()
2540                .map(|b| b.as_ref().map(binding_hash_key).transpose())
2541                .collect::<Result<Vec<_>, _>>()?;
2542            let group_idx = *group_index.entry(hash_key).or_insert_with(|| {
2543                groups.push(Group {
2544                    key_bindings: key_bindings.clone(),
2545                    accs: fresh_accs(items),
2546                    row_count: 0,
2547                });
2548                groups.len() - 1
2549            });
2550            let group = &mut groups[group_idx];
2551            group.row_count += 1;
2552            for (i, nodes) in item_agg_nodes.iter().enumerate() {
2553                for (k, node) in nodes.iter().enumerate() {
2554                    match node {
2555                        // `count(*)` counts rows, not values -- folded
2556                        // unconditionally (no null-skip: there's no
2557                        // per-row expression to be null) via a dummy
2558                        // always-non-null argument, reusing `AggAcc::
2559                        // Count`'s existing fold logic instead of a
2560                        // separate no-accumulator path (see `fresh_accs`).
2561                        ReturnExpr::CountStar => {
2562                            group.accs[i][k].fold(&Value::Literal(Literal::Bool(true)))?;
2563                        }
2564                        ReturnExpr::Call { name, args, .. } => {
2565                            // Standard Cypher null-skipping: a null
2566                            // argument (e.g. an unmatched OPTIONAL MATCH
2567                            // variable) contributes to neither the
2568                            // accumulator nor its DISTINCT dedup set.
2569                            let value = self.eval_return_expr(txn, &args[0], row, guard)?;
2570                            if is_percentile_name(name) {
2571                                // percentileCont/percentileDisc's second
2572                                // argument (the percentile) is evaluated
2573                                // per row too -- in practice always a
2574                                // constant across the group, but nothing
2575                                // structurally requires that, so it's just
2576                                // evaluated fresh every row like any other
2577                                // expression rather than memoized once.
2578                                let percentile =
2579                                    self.eval_return_expr(txn, &args[1], row, guard)?;
2580                                if !matches!(value, Value::Null) {
2581                                    group.accs[i][k].fold_percentile(&value, &percentile)?;
2582                                }
2583                            } else if !matches!(value, Value::Null) {
2584                                group.accs[i][k].fold(&value)?;
2585                            }
2586                        }
2587                        _ => unreachable!(
2588                            "collect_agg_nodes only ever collects CountStar/aggregate Call nodes"
2589                        ),
2590                    }
2591                }
2592            }
2593        }
2594
2595        // Global aggregate over an empty result set (no grouping-key items
2596        // at all, and no rows to seed a group from) still produces exactly
2597        // one output row — `count`/`count(*)` -> 0, `sum` -> 0,
2598        // `avg`/`min`/`max` -> Null, `collect` -> [] — via the same
2599        // fresh-accumulator `finish()` path a normal empty-contribution
2600        // group already uses below, not a separate code path.
2601        let no_key_items = items.iter().all(|item| contains_aggregate(&item.expr));
2602        if groups.is_empty() && no_key_items {
2603            groups.push(Group {
2604                key_bindings: vec![None; items.len()],
2605                accs: fresh_accs(items),
2606                row_count: 0,
2607            });
2608        }
2609
2610        let mut out = Vec::with_capacity(groups.len());
2611        for mut group in groups {
2612            let ctx = GroupFinishCtx {
2613                items,
2614                key_bindings: &group.key_bindings,
2615            };
2616            let mut row_out = Vec::with_capacity(items.len());
2617            for (i, item) in items.iter().enumerate() {
2618                let binding = match &group.key_bindings[i] {
2619                    Some(b) => b.clone(),
2620                    None => {
2621                        let mut accs = std::mem::take(&mut group.accs[i]).into_iter();
2622                        let mut subst = HashMap::new();
2623                        let rewritten = self
2624                            .rewrite_composed_item(txn, &item.expr, &ctx, &mut accs, &mut subst)?;
2625                        value_to_binding(eval_projected_expr(&rewritten, &subst)?)
2626                    }
2627                };
2628                row_out.push(binding);
2629            }
2630            out.push(row_out);
2631        }
2632        Ok(out)
2633    }
2634
2635    /// Finishing half of a composed aggregate item (`count(a) + 3`):
2636    /// rewrites `expr`'s tree into an equivalent one `eval_projected_expr`
2637    /// can evaluate without any further graph access, replacing every
2638    /// aggregate-bearing subexpression with a synthetic `Var` referencing
2639    /// its now-finished accumulator's value in `subst` (consumed from
2640    /// `accs` in `collect_agg_nodes`'s order, the same order `fresh_accs`/
2641    /// the per-row fold loop in `resolve_grouped_rows` built them in), and
2642    /// every non-aggregate `Var`/`Prop` leaf with a synthetic `Var`
2643    /// referencing whichever *other* item's own grouping-key `Binding` it
2644    /// structurally matches (`validate_return_items` already guarantees
2645    /// exactly one such match exists — never reached otherwise). Each
2646    /// substituted value gets its own fresh, guaranteed-unique slot name
2647    /// (`subst.len()` at insertion time), so nothing here can collide with
2648    /// a real Cypher identifier the user wrote.
2649    fn rewrite_composed_item(
2650        &self,
2651        txn: Txn,
2652        expr: &ReturnExpr,
2653        ctx: &GroupFinishCtx<'_>,
2654        accs: &mut std::vec::IntoIter<AggAcc>,
2655        subst: &mut HashMap<String, Value>,
2656    ) -> Result<ReturnExpr, QueryError> {
2657        if matches!(expr, ReturnExpr::CountStar)
2658            || matches!(expr, ReturnExpr::Call { name, .. } if is_aggregate_name(name))
2659        {
2660            let value = accs
2661                .next()
2662                .expect("accs is aligned with this same expr's collect_agg_nodes traversal order")
2663                .finish();
2664            let slot = format!("__slot{}", subst.len());
2665            subst.insert(slot.clone(), value);
2666            return Ok(ReturnExpr::Var(slot));
2667        }
2668        if matches!(expr, ReturnExpr::Var(_) | ReturnExpr::Prop(_)) {
2669            let j = ctx
2670                .items
2671                .iter()
2672                .enumerate()
2673                .position(|(i, it)| item_matches_leaf(expr, i, it) && !contains_aggregate(&it.expr))
2674                .expect(
2675                    "validate_return_items already checked this leaf matches a grouping-key item",
2676                );
2677            let binding = ctx.key_bindings[j]
2678                .clone()
2679                .expect("a non-aggregating item always has a key binding");
2680            let value = self.binding_to_value(txn, &binding)?;
2681            let slot = format!("__slot{}", subst.len());
2682            subst.insert(slot.clone(), value);
2683            return Ok(ReturnExpr::Var(slot));
2684        }
2685        Ok(match expr {
2686            ReturnExpr::Lit(lit) => ReturnExpr::Lit(lit.clone()),
2687            ReturnExpr::Call {
2688                name,
2689                args,
2690                distinct,
2691            } => ReturnExpr::Call {
2692                name: name.clone(),
2693                distinct: *distinct,
2694                args: args
2695                    .iter()
2696                    .map(|a| self.rewrite_composed_item(txn, a, ctx, accs, subst))
2697                    .collect::<Result<_, _>>()?,
2698            },
2699            ReturnExpr::Case { test, whens, else_ } => ReturnExpr::Case {
2700                test: test
2701                    .as_deref()
2702                    .map(|t| self.rewrite_composed_item(txn, t, ctx, accs, subst))
2703                    .transpose()?
2704                    .map(Box::new),
2705                whens: whens
2706                    .iter()
2707                    .map(|(w, t)| {
2708                        Ok::<_, QueryError>((
2709                            self.rewrite_composed_item(txn, w, ctx, accs, subst)?,
2710                            self.rewrite_composed_item(txn, t, ctx, accs, subst)?,
2711                        ))
2712                    })
2713                    .collect::<Result<_, _>>()?,
2714                else_: else_
2715                    .as_deref()
2716                    .map(|e| self.rewrite_composed_item(txn, e, ctx, accs, subst))
2717                    .transpose()?
2718                    .map(Box::new),
2719            },
2720            ReturnExpr::Arith(l, op, r) => ReturnExpr::Arith(
2721                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
2722                *op,
2723                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
2724            ),
2725            ReturnExpr::Neg(e) => ReturnExpr::Neg(Box::new(
2726                self.rewrite_composed_item(txn, e, ctx, accs, subst)?,
2727            )),
2728            ReturnExpr::ListLit(list_items) => ReturnExpr::ListLit(
2729                list_items
2730                    .iter()
2731                    .map(|i| self.rewrite_composed_item(txn, i, ctx, accs, subst))
2732                    .collect::<Result<_, _>>()?,
2733            ),
2734            ReturnExpr::Index(base, index) => ReturnExpr::Index(
2735                Box::new(self.rewrite_composed_item(txn, base, ctx, accs, subst)?),
2736                Box::new(self.rewrite_composed_item(txn, index, ctx, accs, subst)?),
2737            ),
2738            ReturnExpr::PropOf(base, prop) => ReturnExpr::PropOf(
2739                Box::new(self.rewrite_composed_item(txn, base, ctx, accs, subst)?),
2740                prop.clone(),
2741            ),
2742            ReturnExpr::Slice(base, start, end) => ReturnExpr::Slice(
2743                Box::new(self.rewrite_composed_item(txn, base, ctx, accs, subst)?),
2744                start
2745                    .as_deref()
2746                    .map(|s| self.rewrite_composed_item(txn, s, ctx, accs, subst))
2747                    .transpose()?
2748                    .map(Box::new),
2749                end.as_deref()
2750                    .map(|e| self.rewrite_composed_item(txn, e, ctx, accs, subst))
2751                    .transpose()?
2752                    .map(Box::new),
2753            ),
2754            // `where_clause`/`project` are deliberately left untouched
2755            // (cloned verbatim), not recursed into -- they run once per
2756            // *element* of `source`'s own already-rewritten result, in a
2757            // scope `eval_projected_expr`'s own `ListComp`/`Quantifier`
2758            // handling builds itself (the outer `subst` map plus a fresh
2759            // binding for `var`, per element). Rewriting a `Var`/`Prop`
2760            // leaf in here the same way `source` gets rewritten would
2761            // wrongly try to resolve the comprehension's own *local* loop
2762            // variable (`x`/`ok`) as if it had to be some other item's
2763            // grouping key -- there's no such item, since it's not an
2764            // outer reference at all (found via TCK's List11 [3]: `ALL(ok
2765            // IN collect(...) WHERE ok)` panicked trying to resolve `ok`
2766            // this way). `validate_composed_expr`'s own `ListComp` arm
2767            // already guarantees `project` has no aggregate to substitute
2768            // in the first place; `where_clause` is the same documented
2769            // scope gap `contains_aggregate` has everywhere else.
2770            ReturnExpr::ListComp {
2771                var,
2772                source,
2773                where_clause,
2774                project,
2775            } => ReturnExpr::ListComp {
2776                var: var.clone(),
2777                source: Box::new(self.rewrite_composed_item(txn, source, ctx, accs, subst)?),
2778                where_clause: where_clause.clone(),
2779                project: project.clone(),
2780            },
2781            ReturnExpr::Quantifier {
2782                kind,
2783                var,
2784                source,
2785                where_clause,
2786            } => ReturnExpr::Quantifier {
2787                kind: *kind,
2788                var: var.clone(),
2789                source: Box::new(self.rewrite_composed_item(txn, source, ctx, accs, subst)?),
2790                where_clause: where_clause.clone(),
2791            },
2792            ReturnExpr::MapLit(entries) => ReturnExpr::MapLit(
2793                entries
2794                    .iter()
2795                    .map(|(k, v)| {
2796                        Ok::<_, QueryError>((
2797                            k.clone(),
2798                            self.rewrite_composed_item(txn, v, ctx, accs, subst)?,
2799                        ))
2800                    })
2801                    .collect::<Result<_, _>>()?,
2802            ),
2803            ReturnExpr::And(l, r) => ReturnExpr::And(
2804                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
2805                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
2806            ),
2807            ReturnExpr::Or(l, r) => ReturnExpr::Or(
2808                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
2809                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
2810            ),
2811            ReturnExpr::Xor(l, r) => ReturnExpr::Xor(
2812                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
2813                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
2814            ),
2815            ReturnExpr::Not(e) => ReturnExpr::Not(Box::new(
2816                self.rewrite_composed_item(txn, e, ctx, accs, subst)?,
2817            )),
2818            ReturnExpr::Compare(l, op, r) => ReturnExpr::Compare(
2819                Box::new(self.rewrite_composed_item(txn, l, ctx, accs, subst)?),
2820                *op,
2821                Box::new(self.rewrite_composed_item(txn, r, ctx, accs, subst)?),
2822            ),
2823            ReturnExpr::IsNull(e) => ReturnExpr::IsNull(Box::new(
2824                self.rewrite_composed_item(txn, e, ctx, accs, subst)?,
2825            )),
2826            ReturnExpr::In(needle, haystack) => ReturnExpr::In(
2827                Box::new(self.rewrite_composed_item(txn, needle, ctx, accs, subst)?),
2828                Box::new(self.rewrite_composed_item(txn, haystack, ctx, accs, subst)?),
2829            ),
2830            ReturnExpr::HasLabel(v, l) => ReturnExpr::HasLabel(v.clone(), l.clone()),
2831            ReturnExpr::PatternPredicate(p) => ReturnExpr::PatternPredicate(p.clone()),
2832            ReturnExpr::PatternComprehension { .. } => expr.clone(),
2833            ReturnExpr::ExistsPattern { .. } => expr.clone(),
2834            ReturnExpr::ExistsSubquery(_) => expr.clone(),
2835            ReturnExpr::Var(_) | ReturnExpr::Prop(_) | ReturnExpr::CountStar => {
2836                unreachable!("handled above, before this match")
2837            }
2838        })
2839    }
2840
2841    /// WITH's HAVING-equivalent — evaluated against the already-projected/
2842    /// grouped row, same as ORDER BY. Never pushed into the planner (see
2843    /// `WithExpr`'s docs).
2844    /// `Option<bool>` — `None` is Cypher's "unknown" (see `compare()`'s
2845    /// docs), propagated through `AND`/`OR`/`NOT` via `and3`/`or3`/`map`
2846    /// instead of collapsing to `false` partway through. Every call site
2847    /// filters a row by checking `== Some(true)` — unknown behaves like
2848    /// `false` for filtering purposes, but *only* at that final step, not
2849    /// internally, since `AND`/`OR`'s truth tables need to tell "false"
2850    /// and "unknown" apart to combine correctly.
2851    fn eval_with_expr(
2852        &self,
2853        txn: Txn,
2854        expr: &WithExpr,
2855        row: &BindingRow,
2856        guard: &ExecutionGuard<'_>,
2857    ) -> Result<Option<bool>, QueryError> {
2858        Ok(match expr {
2859            WithExpr::And(l, r) => and3(
2860                self.eval_with_expr(txn, l, row, guard)?,
2861                self.eval_with_expr(txn, r, row, guard)?,
2862            ),
2863            WithExpr::Or(l, r) => or3(
2864                self.eval_with_expr(txn, l, row, guard)?,
2865                self.eval_with_expr(txn, r, row, guard)?,
2866            ),
2867            WithExpr::Not(e) => self.eval_with_expr(txn, e, row, guard)?.map(|b| !b),
2868            WithExpr::Compare(lhs, op, rhs) => {
2869                let lv = self.eval_return_expr(txn, lhs, row, guard)?;
2870                let rv = self.eval_return_expr(txn, rhs, row, guard)?;
2871                compare_values(&lv, *op, &rv)
2872            }
2873            // Always definite -- same reasoning as `Expr::IsNull`.
2874            WithExpr::IsNull(e) => Some(matches!(
2875                self.eval_return_expr(txn, e, row, guard)?,
2876                Value::Null
2877            )),
2878            // Unlike an ordinary MATCH's own `WHERE` (`Expr`), which folds
2879            // a bare pattern predicate into `Expr::Pattern` at parse time
2880            // (`return_expr_to_expr`), `WithExpr` has no such folding --
2881            // `WITH ... WHERE a.id = 0 AND (a)-->(b)` embeds it straight
2882            // as a `ReturnExpr::PatternPredicate` inside `Bare`/`And`/`Or`.
2883            // Special-cased here (rather than in `eval_return_expr`, which
2884            // errors on it -- a pattern predicate is only ever meaningful
2885            // as a predicate, never as a real projected value) so `WITH
2886            // ... WHERE` gets the same existential-search semantics MATCH's
2887            // own `WHERE` already has (TCK's WithWhere4 `[2]`).
2888            WithExpr::Bare(ReturnExpr::PatternPredicate(pattern)) => {
2889                Some(self.eval_pattern_predicate_exists(txn, pattern, row, guard)?)
2890            }
2891            WithExpr::Bare(e) => self.eval_return_expr_bool3(txn, e, row, guard)?,
2892        })
2893    }
2894
2895    /// `WHERE (n)-[:REL]->()` etc (TCK's Pattern1) -- existential: true
2896    /// iff at least one real match of `pattern` exists, with every
2897    /// already-bound named endpoint (`n`, and `m` in `(n)-->(m)` when `m`
2898    /// is also bound by an earlier MATCH) held fixed to this row's own
2899    /// binding rather than searched freely. `semantic::
2900    /// validate_pattern_predicate` already rejected any named endpoint
2901    /// that ISN'T already bound (real Cypher's `UndefinedVariable`), so
2902    /// every named var here is safe to seed. Reuses the exact same
2903    /// `build_match_plan` "already-bound var -> Seed, not a fresh scan"
2904    /// mechanism `eval_merge`'s own "try as an ordinary MATCH first" half
2905    /// already relies on -- for a one-hop pattern this is a real
2906    /// connected-subgraph search (Expand + Filter), not an isolated
2907    /// per-node check. `Some(1)`-limited: existence is all that's needed,
2908    /// so there's no reason to enumerate every match. Shared by `Expr::
2909    /// Pattern` (an ordinary MATCH's own WHERE) and `WithExpr::Bare`'s
2910    /// `PatternPredicate` case (a WITH's own WHERE) -- same semantics
2911    /// either way, just reached from two different expression shapes.
2912    fn eval_pattern_predicate_exists(
2913        &self,
2914        txn: Txn,
2915        pattern: &Pattern,
2916        row: &BindingRow,
2917        guard: &ExecutionGuard<'_>,
2918    ) -> Result<bool, QueryError> {
2919        let carried_vars: HashSet<String> = row.keys().cloned().collect();
2920        let plan = apply_index_seeks(build_match_plan(pattern, &None, &carried_vars)?, txn)?;
2921        let found =
2922            self.eval_plan_with_limit(txn, &plan, std::slice::from_ref(row), guard, Some(1))?;
2923        Ok(!found.is_empty())
2924    }
2925
2926    /// `exists { MATCH ... RETURN ... }`'s "full" form (TCK's
2927    /// ExistentialSubquery2/3) -- runs `stmt` (always a `Statement::Match`,
2928    /// `semantic::validate_statement` rejects anything else reaching here
2929    /// and rejects every mutating clause inside it, so this only ever sees
2930    /// a real read-only pipeline) correlated against `row` via
2931    /// `execute_match_seeded`, then checks whether it produced at least
2932    /// one output row -- the inner RETURN's own projected *values* are
2933    /// never inspected, only whether the row exists at all, same as
2934    /// `eval_pattern_predicate_exists`/`Expr::Exists` above.
2935    fn eval_exists_subquery(
2936        &self,
2937        txn: Txn,
2938        stmt: &Statement,
2939        row: &BindingRow,
2940        guard: &ExecutionGuard<'_>,
2941    ) -> Result<bool, QueryError> {
2942        let Statement::Match {
2943            clauses,
2944            tail,
2945            order_by,
2946            skip,
2947            limit,
2948        } = stmt
2949        else {
2950            unreachable!(
2951                "semantic::validate_statement only allows Statement::Match inside exists {{}}"
2952            )
2953        };
2954        let skip = self.resolve_skip_limit(txn, skip.as_deref(), "SKIP", guard)?;
2955        let limit = self.resolve_skip_limit(txn, limit.as_deref(), "LIMIT", guard)?;
2956        let result = self.execute_match_seeded(
2957            txn,
2958            clauses,
2959            tail,
2960            ResultModifiers {
2961                order_by,
2962                skip,
2963                limit,
2964            },
2965            Some(row),
2966            guard,
2967        )?;
2968        Ok(!result.rows.is_empty())
2969    }
2970
2971    /// Evaluates an `OPTIONAL MATCH` part with left-outer-join semantics:
2972    /// every outer row survives, whether or not the optional pattern
2973    /// matched anything for it. Must wrap the *whole* subplan rather than
2974    /// null-padding inside `Expand`/`VarExpand` themselves — baking it in
2975    /// there would turn every default (non-optional) `Expand` into a
2976    /// left-outer-join too (breaking existing inner-join semantics), and
2977    /// would mis-handle multi-hop optional patterns: IS7's optional
2978    /// pattern is 2 hops, and per-hop null-padding would emit one
2979    /// null-padded row per *hop-1* match even when hop 2 also matched,
2980    /// instead of collapsing to exactly one row per outer row that had
2981    /// zero end-to-end matches.
2982    ///
2983    /// Implementation: tag each outer row with its index, evaluate the
2984    /// subplan once over the whole tagged batch (a single seed, not one
2985    /// call per row), group results back by that index, then for any
2986    /// outer index with zero results, emit the outer row unchanged plus
2987    /// `Null` for every variable the optional pattern would have newly
2988    /// introduced.
2989    fn eval_optional_part(
2990        &self,
2991        txn: Txn,
2992        plan: &LogicalPlan,
2993        outer_rows: &[BindingRow],
2994        new_vars: &HashSet<String>,
2995        guard: &ExecutionGuard<'_>,
2996    ) -> Result<Vec<BindingRow>, QueryError> {
2997        let tagged: Vec<BindingRow> = outer_rows
2998            .iter()
2999            .enumerate()
3000            .map(|(i, row)| {
3001                let mut r = row.clone();
3002                r.insert(
3003                    OPTIONAL_SEED_IDX_KEY.to_string(),
3004                    Binding::Value(PropertyValue::Int(i as i64)),
3005                );
3006                r
3007            })
3008            .collect();
3009        guard.check_intermediate_rows(tagged.len())?;
3010        let results = self.eval_plan(txn, plan, &tagged, guard)?;
3011        let mut by_idx: HashMap<i64, Vec<BindingRow>> = HashMap::new();
3012        for mut row in results {
3013            let idx = match row.remove(OPTIONAL_SEED_IDX_KEY) {
3014                Some(Binding::Value(PropertyValue::Int(i))) => i,
3015                other => unreachable!(
3016                    "__seed_idx tagged internally as Binding::Value(Int), got {other:?}"
3017                ),
3018            };
3019            by_idx.entry(idx).or_default().push(row);
3020        }
3021        let mut out = Vec::with_capacity(outer_rows.len());
3022        for (i, outer_row) in outer_rows.iter().enumerate() {
3023            match by_idx.remove(&(i as i64)) {
3024                Some(matches) => out.extend(matches),
3025                None => {
3026                    let mut padded = outer_row.clone();
3027                    for var in new_vars {
3028                        padded.insert(var.clone(), Binding::Value(PropertyValue::Null));
3029                    }
3030                    out.push(padded);
3031                }
3032            }
3033            guard.check_intermediate_rows(out.len())?;
3034        }
3035        Ok(out)
3036    }
3037
3038    fn eval_plan(
3039        &self,
3040        txn: Txn,
3041        plan: &LogicalPlan,
3042        seed: &[BindingRow],
3043        guard: &ExecutionGuard<'_>,
3044    ) -> Result<Vec<BindingRow>, QueryError> {
3045        self.eval_plan_with_limit(txn, plan, seed, guard, None)
3046    }
3047
3048    fn eval_plan_with_limit(
3049        &self,
3050        txn: Txn,
3051        plan: &LogicalPlan,
3052        seed: &[BindingRow],
3053        guard: &ExecutionGuard<'_>,
3054        limit: Option<usize>,
3055    ) -> Result<Vec<BindingRow>, QueryError> {
3056        let stream = self.stream_plan(txn, plan, seed, guard, limit);
3057        match limit {
3058            Some(limit) => stream.take(limit).collect(),
3059            None => stream.collect(),
3060        }
3061    }
3062
3063    /// Build a pull-based row pipeline. Each iterator owns only its current
3064    /// row (plus one relationship fan-out at an Expand), so scan/filter/
3065    /// expand chains no longer allocate a Vec at every logical-plan node.
3066    /// Blocking clause boundaries still collect this stream explicitly.
3067    fn stream_plan<'s>(
3068        &'s self,
3069        txn: Txn<'s>,
3070        plan: &'s LogicalPlan,
3071        seed: &'s [BindingRow],
3072        guard: &'s ExecutionGuard<'_>,
3073        scan_limit: Option<usize>,
3074    ) -> RowStream<'s> {
3075        match plan {
3076            LogicalPlan::Seed { var } => {
3077                debug_assert!(
3078                    seed.first().is_none_or(|row| row.contains_key(var)),
3079                    "Seed{{var: {var:?}}} planned for a var not present in the carried-forward rows"
3080                );
3081                Self::count_stream(Box::new(seed.iter().cloned().map(Ok)), guard)
3082            }
3083            LogicalPlan::AllNodesScan { var } => {
3084                self.stream_scan(txn, var, None, seed, guard, scan_limit)
3085            }
3086            LogicalPlan::NodeByLabelScan { var, label } => {
3087                self.stream_scan(txn, var, Some(label), seed, guard, scan_limit)
3088            }
3089            LogicalPlan::IndexSeek {
3090                var,
3091                label,
3092                prop,
3093                value,
3094            } => self.stream_index_seek(
3095                txn,
3096                IndexSeekSpec {
3097                    var,
3098                    label,
3099                    prop,
3100                    value,
3101                },
3102                seed,
3103                guard,
3104                scan_limit,
3105            ),
3106            LogicalPlan::Expand {
3107                input,
3108                from_var,
3109                to_var,
3110                rel_var,
3111                rel_labels,
3112                direction,
3113            } => {
3114                let mut input = self.stream_plan(txn, input, seed, guard, None);
3115                let mut current: Option<(BindingRow, std::vec::IntoIter<AdjEntry>)> = None;
3116                let mut done = false;
3117                let stream = std::iter::from_fn(move || loop {
3118                    if done {
3119                        return None;
3120                    }
3121                    if let Some((row, entries)) = &mut current {
3122                        if let Some(entry) = entries.next() {
3123                            if let Err(error) = guard.relationship_expansion() {
3124                                done = true;
3125                                return Some(Err(error));
3126                            }
3127                            let mut new_row = row.clone();
3128                            new_row.insert(to_var.clone(), Binding::Node(entry.other));
3129                            if let Some(rel_var) = rel_var {
3130                                new_row.insert(rel_var.clone(), Binding::Edge(entry.edge_id));
3131                            }
3132                            return Some(Ok(new_row));
3133                        }
3134                        current = None;
3135                    }
3136
3137                    let row = match input.next()? {
3138                        Ok(row) => row,
3139                        Err(error) => {
3140                            done = true;
3141                            return Some(Err(error));
3142                        }
3143                    };
3144                    let from_id = match row.get(from_var) {
3145                        Some(Binding::Node(id)) => *id,
3146                        // A null binding has no neighbors and contributes
3147                        // no rows. Missing or structurally invalid bindings
3148                        // remain errors.
3149                        Some(Binding::Value(PropertyValue::Null)) => continue,
3150                        _ => {
3151                            done = true;
3152                            return Some(Err(QueryError::UnboundVariable(from_var.clone())));
3153                        }
3154                    };
3155                    match neighbors_for_direction(txn, from_id, *direction, rel_labels) {
3156                        Ok(entries) => current = Some((row, entries.into_iter())),
3157                        Err(error) => {
3158                            done = true;
3159                            return Some(Err(error));
3160                        }
3161                    }
3162                });
3163                Self::count_stream(Box::new(stream), guard)
3164            }
3165            LogicalPlan::VarExpand {
3166                input,
3167                from_var,
3168                to_var,
3169                rel_labels,
3170                direction,
3171                min_hops,
3172                max_hops,
3173                exclude_edge_vars,
3174                exclude_edge_sets,
3175                exclude_edge_var,
3176                path_segment_var,
3177                rel_list_var,
3178                rel_props,
3179            } => {
3180                let mut input = self.stream_plan(txn, input, seed, guard, None);
3181                let mut pending = Vec::new().into_iter();
3182                let mut done = false;
3183                let stream = std::iter::from_fn(move || loop {
3184                    if done {
3185                        return None;
3186                    }
3187                    if let Some(row) = pending.next() {
3188                        return Some(Ok(row));
3189                    }
3190                    let row = match input.next()? {
3191                        Ok(row) => row,
3192                        Err(error) => {
3193                            done = true;
3194                            return Some(Err(error));
3195                        }
3196                    };
3197                    match self.expand_variable_row(
3198                        txn,
3199                        row,
3200                        VarExpandSpec {
3201                            from_var,
3202                            to_var,
3203                            rel_labels,
3204                            direction: *direction,
3205                            min_hops: *min_hops,
3206                            max_hops: *max_hops,
3207                            exclude_edge_vars,
3208                            exclude_edge_sets,
3209                            exclude_edge_var,
3210                            path_segment_var: path_segment_var.as_deref(),
3211                            rel_list_var: rel_list_var.as_deref(),
3212                            rel_props,
3213                        },
3214                        guard,
3215                    ) {
3216                        Ok(rows) => pending = rows.into_iter(),
3217                        Err(error) => {
3218                            done = true;
3219                            return Some(Err(error));
3220                        }
3221                    }
3222                });
3223                Self::count_stream(Box::new(stream), guard)
3224            }
3225            LogicalPlan::MatchRelList {
3226                input,
3227                from_var,
3228                to_var,
3229                rel_list_var,
3230                rel_labels,
3231                direction,
3232                min_hops,
3233                max_hops,
3234            } => {
3235                let mut input = self.stream_plan(txn, input, seed, guard, None);
3236                let mut done = false;
3237                let stream = std::iter::from_fn(move || loop {
3238                    if done {
3239                        return None;
3240                    }
3241                    let row = match input.next()? {
3242                        Ok(row) => row,
3243                        Err(error) => {
3244                            done = true;
3245                            return Some(Err(error));
3246                        }
3247                    };
3248                    match self.match_bound_rel_list_row(
3249                        row,
3250                        MatchRelListSpec {
3251                            from_var,
3252                            to_var,
3253                            rel_list_var,
3254                            rel_labels,
3255                            direction: *direction,
3256                            min_hops: *min_hops,
3257                            max_hops: *max_hops,
3258                        },
3259                    ) {
3260                        Ok(Some(row)) => return Some(Ok(row)),
3261                        Ok(None) => continue,
3262                        Err(error) => {
3263                            done = true;
3264                            return Some(Err(error));
3265                        }
3266                    }
3267                });
3268                Self::count_stream(Box::new(stream), guard)
3269            }
3270            LogicalPlan::Filter { input, predicate } => {
3271                let mut input = self.stream_plan(txn, input, seed, guard, None);
3272                let mut done = false;
3273                let stream = std::iter::from_fn(move || loop {
3274                    if done {
3275                        return None;
3276                    }
3277                    let row = match input.next()? {
3278                        Ok(row) => row,
3279                        Err(error) => {
3280                            done = true;
3281                            return Some(Err(error));
3282                        }
3283                    };
3284                    if let Err(error) = guard.checkpoint() {
3285                        done = true;
3286                        return Some(Err(error));
3287                    }
3288                    match self.eval_expr(txn, predicate, &row, guard) {
3289                        Ok(Some(true)) => return Some(Ok(row)),
3290                        Ok(_) => continue,
3291                        Err(error) => {
3292                            done = true;
3293                            return Some(Err(error));
3294                        }
3295                    }
3296                });
3297                Self::count_stream(Box::new(stream), guard)
3298            }
3299        }
3300    }
3301
3302    fn count_stream<'s>(mut stream: RowStream<'s>, guard: &'s ExecutionGuard<'_>) -> RowStream<'s> {
3303        let mut produced = 0usize;
3304        let mut done = false;
3305        Box::new(std::iter::from_fn(move || {
3306            if done {
3307                return None;
3308            }
3309            let item = stream.next()?;
3310            if item.is_ok() {
3311                produced = match produced.checked_add(1) {
3312                    Some(produced) => produced,
3313                    None => {
3314                        done = true;
3315                        return Some(Err(QueryError::ResourceLimit(
3316                            "stream row counter overflow".into(),
3317                        )));
3318                    }
3319                };
3320                if let Err(error) = guard.check_intermediate_rows(produced) {
3321                    done = true;
3322                    return Some(Err(error));
3323                }
3324            } else {
3325                done = true;
3326            }
3327            Some(item)
3328        }))
3329    }
3330
3331    fn stream_scan<'s>(
3332        &'s self,
3333        txn: Txn<'s>,
3334        var: &'s str,
3335        label: Option<&'s str>,
3336        seed: &'s [BindingRow],
3337        guard: &'s ExecutionGuard<'_>,
3338        row_limit: Option<usize>,
3339    ) -> RowStream<'s> {
3340        let mut initialized = false;
3341        let mut node_ids = Vec::new();
3342        let mut seed_index = 0usize;
3343        let mut node_index = 0usize;
3344        let mut done = false;
3345        let stream = std::iter::from_fn(move || {
3346            if done || seed.is_empty() {
3347                return None;
3348            }
3349            if !initialized {
3350                initialized = true;
3351                let budget_node_limit = guard.options.max_intermediate_rows.map(|max_rows| {
3352                    max_rows
3353                        .checked_div(seed.len())
3354                        .unwrap_or(0)
3355                        .saturating_add(1)
3356                });
3357                let storage_limit = match (row_limit, budget_node_limit) {
3358                    (Some(a), Some(b)) => Some(a.min(b)),
3359                    (Some(a), None) => Some(a),
3360                    (None, Some(b)) => Some(b),
3361                    (None, None) => None,
3362                };
3363                let storage_limit = storage_limit.unwrap_or(usize::MAX);
3364                match GraphStore::all_node_ids_limited_in_txn(txn, label, storage_limit) {
3365                    Ok(ids) => node_ids = ids,
3366                    Err(error) => {
3367                        done = true;
3368                        return Some(Err(error.into()));
3369                    }
3370                }
3371            }
3372            if node_ids.is_empty() || seed_index >= seed.len() {
3373                return None;
3374            }
3375            if let Err(error) = guard.checkpoint() {
3376                done = true;
3377                return Some(Err(error));
3378            }
3379            let mut row = seed[seed_index].clone();
3380            row.insert(var.to_string(), Binding::Node(node_ids[node_index]));
3381            node_index += 1;
3382            if node_index == node_ids.len() {
3383                node_index = 0;
3384                seed_index += 1;
3385            }
3386            Some(Ok(row))
3387        });
3388        Self::count_stream(Box::new(stream), guard)
3389    }
3390
3391    /// `LogicalPlan::IndexSeek`'s streaming operator -- same cross-join-
3392    /// against-`seed` shape as `stream_scan`, but the id list comes from
3393    /// one exact-match `PROPERTY_INDEX` lookup instead of a label scan.
3394    /// `row_limit` bounds the lookup itself the same way `stream_scan`'s
3395    /// does -- a non-unique index can still match far more nodes than a
3396    /// `LIMIT` needs, so the same "ask storage for at most the budget,
3397    /// not everything" reasoning applies, just against `PROPERTY_INDEX`
3398    /// instead of `NODE_LABEL_INDEX`.
3399    fn stream_index_seek<'s>(
3400        &'s self,
3401        txn: Txn<'s>,
3402        spec: IndexSeekSpec<'s>,
3403        seed: &'s [BindingRow],
3404        guard: &'s ExecutionGuard<'_>,
3405        row_limit: Option<usize>,
3406    ) -> RowStream<'s> {
3407        let mut initialized = false;
3408        let mut node_ids: Vec<NodeId> = Vec::new();
3409        let mut seed_index = 0usize;
3410        let mut node_index = 0usize;
3411        let mut done = false;
3412        let stream = std::iter::from_fn(move || {
3413            if done || seed.is_empty() {
3414                return None;
3415            }
3416            if !initialized {
3417                initialized = true;
3418                let budget_node_limit = guard.options.max_intermediate_rows.map(|max_rows| {
3419                    max_rows
3420                        .checked_div(seed.len())
3421                        .unwrap_or(0)
3422                        .saturating_add(1)
3423                });
3424                let storage_limit = match (row_limit, budget_node_limit) {
3425                    (Some(a), Some(b)) => Some(a.min(b)),
3426                    (Some(a), None) => Some(a),
3427                    (None, Some(b)) => Some(b),
3428                    (None, None) => None,
3429                };
3430                let result = match storage_limit {
3431                    Some(limit) => GraphStore::lookup_by_index_limited_in_txn(
3432                        txn, spec.label, spec.prop, spec.value, limit,
3433                    ),
3434                    None => {
3435                        GraphStore::lookup_by_index_in_txn(txn, spec.label, spec.prop, spec.value)
3436                    }
3437                };
3438                match result {
3439                    Ok(ids) => node_ids = ids,
3440                    Err(error) => {
3441                        done = true;
3442                        return Some(Err(error.into()));
3443                    }
3444                }
3445            }
3446            if node_ids.is_empty() || seed_index >= seed.len() {
3447                return None;
3448            }
3449            if let Err(error) = guard.checkpoint() {
3450                done = true;
3451                return Some(Err(error));
3452            }
3453            let mut row = seed[seed_index].clone();
3454            row.insert(spec.var.to_string(), Binding::Node(node_ids[node_index]));
3455            node_index += 1;
3456            if node_index == node_ids.len() {
3457                node_index = 0;
3458                seed_index += 1;
3459            }
3460            Some(Ok(row))
3461        });
3462        Self::count_stream(Box::new(stream), guard)
3463    }
3464
3465    fn expand_variable_row(
3466        &self,
3467        txn: Txn,
3468        row: BindingRow,
3469        spec: VarExpandSpec<'_>,
3470        guard: &ExecutionGuard<'_>,
3471    ) -> Result<Vec<BindingRow>, QueryError> {
3472        let start_id = match row.get(spec.from_var) {
3473            Some(Binding::Node(id)) => *id,
3474            Some(Binding::Value(PropertyValue::Null)) => return Ok(Vec::new()),
3475            _ => return Err(QueryError::UnboundVariable(spec.from_var.to_string())),
3476        };
3477        let mut out = Vec::new();
3478        if spec.min_hops == 0 {
3479            let mut new_row = row.clone();
3480            new_row.insert(spec.to_var.to_string(), Binding::Node(start_id));
3481            if let Some(path_segment_var) = spec.path_segment_var {
3482                new_row.insert(path_segment_var.to_string(), Binding::Path(Vec::new()));
3483            }
3484            if let Some(rel_list_var) = spec.rel_list_var {
3485                new_row.insert(rel_list_var.to_string(), Binding::List(Vec::new()));
3486            }
3487            new_row.insert(spec.exclude_edge_var.to_string(), Binding::Path(Vec::new()));
3488            out.push(new_row);
3489        }
3490        // `[:TYPE* {year: 1988}]` -- evaluated once here (constant across
3491        // the whole BFS, not per-candidate; the values can reference this
3492        // row's own already-bound variables, same as a fixed hop's inline
3493        // props already can) and checked against each candidate edge's
3494        // own stored properties during expansion below (TCK's Match4
3495        // `[5]`).
3496        let rel_props = spec
3497            .rel_props
3498            .iter()
3499            .map(|(key, expr)| {
3500                let value = self.eval_return_expr(txn, expr, &row, guard)?;
3501                Ok::<_, QueryError>((key.as_str(), value_to_property_value(&value)))
3502            })
3503            .collect::<Result<Vec<_>, _>>()?;
3504        let unbounded = spec.max_hops.is_none();
3505        let effective_max = spec.max_hops.unwrap_or(VAR_EXPAND_DEPTH_CAP);
3506        // Real Cypher's edge-isomorphism rule (no relationship repeated
3507        // within one MATCH pattern) applies across the *whole* pattern, not
3508        // just within this hop's own BFS -- seed the excluded set with
3509        // whatever edges earlier fixed hops of this same pattern already
3510        // bound, so this traversal can't walk back over one of them (see
3511        // `LogicalPlan::VarExpand`'s docs; found via TCK's Match5 `[27]`).
3512        // Complementary direction: an *earlier variable-length* hop's own
3513        // traversed-edge set (deposited under its own `exclude_edge_var`,
3514        // see `LogicalPlan::VarExpand`'s docs) -- union every such row's
3515        // `Binding::Path` edge ids in too (TCK's Match4 `[7]`).
3516        let seed_used_edges: HashSet<EdgeId> = spec
3517            .exclude_edge_vars
3518            .iter()
3519            .filter_map(|v| match row.get(v) {
3520                Some(Binding::Edge(id)) => Some(*id),
3521                _ => None,
3522            })
3523            .chain(spec.exclude_edge_sets.iter().flat_map(|v| {
3524                match row.get(v) {
3525                    Some(Binding::Path(segment)) => segment
3526                        .iter()
3527                        .filter_map(|p| match p {
3528                            PathBinding::Edge(id) => Some(*id),
3529                            PathBinding::Node(_) => None,
3530                        })
3531                        .collect::<Vec<_>>(),
3532                    _ => Vec::new(),
3533                }
3534            }))
3535            .collect();
3536        // The ordered `Edge, Node, Edge, Node, ...` sequence built up so
3537        // far, alongside the existing `used_edges` isomorphism set --
3538        // only actually consulted when `path_segment_var` is set (named-
3539        // path capture over this hop, see `LogicalPlan::VarExpand`'s own
3540        // docs), but always threaded through the BFS regardless (a plain
3541        // `Vec`, cheap to carry and clone even when unused).
3542        let mut frontier = vec![(start_id, seed_used_edges, Vec::<PathBinding>::new())];
3543        let mut depth = 0u32;
3544        while depth < effective_max && !frontier.is_empty() {
3545            depth += 1;
3546            let mut next_frontier = Vec::new();
3547            for (node, used_edges, segment) in frontier {
3548                for entry in neighbors_for_direction(txn, node, spec.direction, spec.rel_labels)? {
3549                    guard.relationship_expansion()?;
3550                    if used_edges.contains(&entry.edge_id) {
3551                        continue;
3552                    }
3553                    if !rel_props.is_empty() {
3554                        let edge = deleted_entity_access(GraphStore::get_edge_in_txn(
3555                            txn,
3556                            entry.edge_id,
3557                        )?)?;
3558                        let matches = rel_props
3559                            .iter()
3560                            .all(|(key, expected)| edge.props.get(*key) == Some(expected));
3561                        if !matches {
3562                            continue;
3563                        }
3564                    }
3565                    let mut next_used_edges = used_edges.clone();
3566                    next_used_edges.insert(entry.edge_id);
3567                    let mut next_segment = segment.clone();
3568                    next_segment.push(PathBinding::Edge(entry.edge_id));
3569                    next_segment.push(PathBinding::Node(entry.other));
3570                    next_frontier.push((entry.other, next_used_edges, next_segment.clone()));
3571                    guard.check_intermediate_rows(next_frontier.len())?;
3572                    if depth >= spec.min_hops {
3573                        let mut new_row = row.clone();
3574                        new_row.insert(spec.to_var.to_string(), Binding::Node(entry.other));
3575                        if let Some(path_segment_var) = spec.path_segment_var {
3576                            new_row.insert(
3577                                path_segment_var.to_string(),
3578                                Binding::Path(next_segment.clone()),
3579                            );
3580                        }
3581                        if let Some(rel_list_var) = spec.rel_list_var {
3582                            let edges = segment_edges_to_list(txn, &next_segment)?;
3583                            new_row.insert(rel_list_var.to_string(), edges);
3584                        }
3585                        new_row.insert(
3586                            spec.exclude_edge_var.to_string(),
3587                            Binding::Path(next_segment.clone()),
3588                        );
3589                        out.push(new_row);
3590                        guard.check_intermediate_rows(out.len())?;
3591                    }
3592                }
3593            }
3594            frontier = next_frontier;
3595            if depth == effective_max && unbounded && !frontier.is_empty() {
3596                return Err(QueryError::ResourceLimit(format!(
3597                    "variable-length traversal exceeded the safety depth cap ({VAR_EXPAND_DEPTH_CAP} \
3598                     hops) — likely a cyclic graph or unexpectedly large fanout; narrow the pattern or \
3599                     add an explicit upper bound (e.g. *0..10)"
3600                )));
3601            }
3602        }
3603        Ok(out)
3604    }
3605
3606    /// `LogicalPlan::MatchRelList`'s own docs -- deterministic, no search:
3607    /// `spec.rel_list_var`'s edges are already concrete, so there's
3608    /// exactly one possible walk to check, starting from `spec.from_var`'s
3609    /// already-bound node. Returns `Ok(None)` (row dropped, not an error)
3610    /// for every "doesn't match" case -- wrong hop count, a broken chain,
3611    /// an edge whose label isn't in `spec.rel_labels` -- same "no match
3612    /// survives" convention `Expand`/`VarExpand` already use for a filter
3613    /// that simply excludes a row.
3614    fn match_bound_rel_list_row(
3615        &self,
3616        row: BindingRow,
3617        spec: MatchRelListSpec<'_>,
3618    ) -> Result<Option<BindingRow>, QueryError> {
3619        let start_id = match row.get(spec.from_var) {
3620            Some(Binding::Node(id)) => *id,
3621            Some(Binding::Value(PropertyValue::Null)) => return Ok(None),
3622            _ => return Err(QueryError::UnboundVariable(spec.from_var.to_string())),
3623        };
3624        let edges: Vec<&Edge> = match row.get(spec.rel_list_var) {
3625            Some(Binding::List(items)) => items
3626                .iter()
3627                .map(|v| match v {
3628                    Value::Edge(e) => Ok(e),
3629                    other => Err(QueryError::Type(format!(
3630                        "'{}' must be a list of relationships, found {other:?} in it",
3631                        spec.rel_list_var
3632                    ))),
3633                })
3634                .collect::<Result<_, _>>()?,
3635            Some(Binding::Value(PropertyValue::Null)) => return Ok(None),
3636            _ => return Err(QueryError::UnboundVariable(spec.rel_list_var.to_string())),
3637        };
3638        let hops = edges.len() as u32;
3639        if hops < spec.min_hops || spec.max_hops.is_some_and(|max| hops > max) {
3640            return Ok(None);
3641        }
3642        if !spec.rel_labels.is_empty() && edges.iter().any(|e| !spec.rel_labels.contains(&e.label))
3643        {
3644            return Ok(None);
3645        }
3646        let mut current = start_id;
3647        for edge in &edges {
3648            let next = match spec.direction {
3649                ExpandDirection::Out if edge.src == current => edge.dst,
3650                ExpandDirection::In if edge.dst == current => edge.src,
3651                ExpandDirection::Either if edge.src == current => edge.dst,
3652                ExpandDirection::Either if edge.dst == current => edge.src,
3653                _ => return Ok(None),
3654            };
3655            current = next;
3656        }
3657        let mut new_row = row.clone();
3658        new_row.insert(spec.to_var.to_string(), Binding::Node(current));
3659        Ok(Some(new_row))
3660    }
3661
3662    /// `Option<bool>` — see `eval_with_expr`'s docs, same reasoning.
3663    /// `HasLabel`/`VarEq` never produce "unknown" (they operate on real
3664    /// bound node/edge identity, not a possibly-null property), so they
3665    /// always return `Some`.
3666    fn eval_expr(
3667        &self,
3668        txn: Txn,
3669        expr: &Expr,
3670        row: &BindingRow,
3671        guard: &ExecutionGuard<'_>,
3672    ) -> Result<Option<bool>, QueryError> {
3673        Ok(match expr {
3674            Expr::And(l, r) => and3(
3675                self.eval_expr(txn, l, row, guard)?,
3676                self.eval_expr(txn, r, row, guard)?,
3677            ),
3678            Expr::Or(l, r) => or3(
3679                self.eval_expr(txn, l, row, guard)?,
3680                self.eval_expr(txn, r, row, guard)?,
3681            ),
3682            Expr::Not(e) => self.eval_expr(txn, e, row, guard)?.map(|b| !b),
3683            Expr::Compare(pa, op, lit) => {
3684                let prop_value = self.lookup_prop(txn, pa, row)?;
3685                compare(&prop_value, *op, lit)
3686            }
3687            Expr::PropCompare(left, op, right) => {
3688                let a = self.lookup_prop(txn, left, row)?;
3689                let b = self.lookup_prop(txn, right, row)?;
3690                compare_property_pair_opt(&a, *op, &b)
3691            }
3692            // Always definite -- that's the whole point of IS NULL, so
3693            // this is the one `Expr` leaf that's always `Some`, same as
3694            // `HasLabel`/`VarEq` below.
3695            Expr::IsNull(pa) => Some(matches!(
3696                self.lookup_prop(txn, pa, row)?,
3697                None | Some(PropertyValue::Null)
3698            )),
3699            Expr::HasLabel(var, label) => {
3700                let binding = row
3701                    .get(var)
3702                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
3703                let Binding::Node(id) = binding else {
3704                    return Err(QueryError::UnboundVariable(var.clone()));
3705                };
3706                let node = GraphStore::get_node_in_txn(txn, *id)?;
3707                Some(node.is_some_and(|n| n.labels.iter().any(|l| l == label)))
3708            }
3709            Expr::VarEq(a, b) => {
3710                let ba = row
3711                    .get(a)
3712                    .ok_or_else(|| QueryError::UnboundVariable(a.clone()))?;
3713                let bb = row
3714                    .get(b)
3715                    .ok_or_else(|| QueryError::UnboundVariable(b.clone()))?;
3716                Some(match (ba, bb) {
3717                    (Binding::Node(x), Binding::Node(y)) => x == y,
3718                    (Binding::Edge(x), Binding::Edge(y)) => x == y,
3719                    // A null-padded `Binding::Value` (from an earlier
3720                    // OPTIONAL MATCH that didn't match) can't equal a
3721                    // real node/edge, and comparing across binding kinds
3722                    // (a node vs an edge) is never meaningful here — the
3723                    // planner only ever synthesizes VarEq between two
3724                    // occurrences of the same pattern variable, which are
3725                    // always the same kind when both are real.
3726                    _ => false,
3727                })
3728            }
3729            Expr::GeneralCompare(lhs, op, rhs) => {
3730                let lv = self.eval_return_expr(txn, lhs, row, guard)?;
3731                let rv = self.eval_return_expr(txn, rhs, row, guard)?;
3732                compare_values(&lv, *op, &rv)
3733            }
3734            Expr::GeneralIsNull(e) => Some(matches!(
3735                self.eval_return_expr(txn, e, row, guard)?,
3736                Value::Null
3737            )),
3738            Expr::GeneralBare(e) => self.eval_return_expr_bool3(txn, e, row, guard)?,
3739            // `WHERE (n)-[:REL]->()` etc (TCK's Pattern1) -- existential:
3740            // true iff at least one real match of `pattern` exists, with
3741            // every already-bound named endpoint (`n`, and `m` in `(n)-->
3742            // (m)` when `m` is also bound by an earlier MATCH) held fixed
3743            // to this row's own binding rather than searched freely.
3744            // `semantic::bind_pattern_predicate` already rejected any
3745            // named endpoint that ISN'T already bound (real Cypher's
3746            // UndefinedVariable), so every named var here is safe to seed.
3747            // Reuses the exact same `build_match_plan` "already-bound var
3748            // -> Seed, not a fresh scan" mechanism `eval_merge`'s own
3749            // "try as an ordinary MATCH first" half already relies on --
3750            // for a one-hop pattern this is a real connected-subgraph
3751            // search (Expand + Filter), not an isolated per-node check.
3752            // `Some(1)`-limited: existence is all that's needed, so
3753            // there's no reason to enumerate every match.
3754            Expr::Pattern(pattern) => {
3755                Some(self.eval_pattern_predicate_exists(txn, pattern, row, guard)?)
3756            }
3757            // `exists { (n)-->(m) WHERE ... }` (TCK's ExistentialSubquery1,
3758            // the "simple" form) -- same existential search as `Pattern`
3759            // above, just with its own inline `where?` threaded straight
3760            // into `build_match_plan`, same as an ordinary `MATCH ...
3761            // WHERE ...` (not evaluated as a separate post-filter step).
3762            Expr::Exists {
3763                pattern,
3764                where_clause,
3765            } => {
3766                let carried_vars: HashSet<String> = row.keys().cloned().collect();
3767                let wc: Option<Expr> = where_clause.as_deref().cloned();
3768                let plan = apply_index_seeks(build_match_plan(pattern, &wc, &carried_vars)?, txn)?;
3769                let found = self.eval_plan_with_limit(
3770                    txn,
3771                    &plan,
3772                    std::slice::from_ref(row),
3773                    guard,
3774                    Some(1),
3775                )?;
3776                Some(!found.is_empty())
3777            }
3778            // `exists { MATCH ... RETURN ... }` (TCK's
3779            // ExistentialSubquery2/3, the "full" form) -- runs the nested
3780            // statement correlated against `row` (`execute_match_seeded`)
3781            // and checks whether it produced at least one output row.
3782            Expr::ExistsSubquery(stmt) => Some(self.eval_exists_subquery(txn, stmt, row, guard)?),
3783            // See `Expr::EdgeNotInSet`'s own docs -- `edge_var` is always
3784            // a real `Binding::Edge` (a fixed hop's own filter var, the
3785            // only thing this gets generated for) and `edge_set_var` is
3786            // always the `Binding::Path` `expand_variable_row` deposits
3787            // for *every* variable-length hop, unconditionally (see
3788            // `LogicalPlan::VarExpand::exclude_edge_var`'s own docs) --
3789            // never anything else, so there's no null/wrong-kind case to
3790            // handle here the way `VarEq` above has to.
3791            Expr::EdgeNotInSet {
3792                edge_var,
3793                edge_set_var,
3794            } => {
3795                let Some(Binding::Edge(edge_id)) = row.get(edge_var) else {
3796                    return Err(QueryError::UnboundVariable(edge_var.clone()));
3797                };
3798                let Some(Binding::Path(segment)) = row.get(edge_set_var) else {
3799                    return Err(QueryError::UnboundVariable(edge_set_var.clone()));
3800                };
3801                Some(
3802                    !segment
3803                        .iter()
3804                        .any(|elem| matches!(elem, PathBinding::Edge(id) if id == edge_id)),
3805                )
3806            }
3807        })
3808    }
3809
3810    fn lookup_prop(
3811        &self,
3812        txn: Txn,
3813        pa: &PropAccess,
3814        row: &BindingRow,
3815    ) -> Result<Option<PropertyValue>, QueryError> {
3816        let binding = row
3817            .get(&pa.var)
3818            .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
3819        match binding {
3820            // A missing *property key* on an existing node/edge is a real,
3821            // legal "absent" (-> null downstream) -- but a missing
3822            // *node/edge record* means it was deleted earlier in this same
3823            // statement (`deleted_entity_access`'s docs), which is a real
3824            // error (`MATCH (n) DELETE n RETURN n.num` -- TCK's Return2
3825            // scenario [15]), not a silent null. These are two different
3826            // kinds of "missing" and must not be collapsed into one.
3827            Binding::Node(id) => {
3828                let node = deleted_entity_access(GraphStore::get_node_in_txn(txn, *id)?)?;
3829                Ok(node.props.get(&pa.prop).cloned())
3830            }
3831            Binding::Edge(id) => {
3832                let edge = deleted_entity_access(GraphStore::get_edge_in_txn(txn, *id)?)?;
3833                Ok(edge.props.get(&pa.prop).cloned())
3834            }
3835            // A WITH-projected scalar (or list/map) has no scalar `.prop`
3836            // to access via this path — e.g. `WITH message.id AS
3837            // messageId` then `messageId.foo` isn't meaningful. Treat as
3838            // absent rather than erroring, consistent with how a missing
3839            // property already behaves. `Binding::Map` specifically *does*
3840            // have real `.prop` access, just not through this method (its
3841            // values aren't always a scalar `PropertyValue`) — see
3842            // `lookup_prop_value`, which `ReturnExpr::Prop` actually calls.
3843            // A `Binding::Value` holding a `Date`/`Duration` also has real
3844            // `.prop` access (`d.year`, etc) — also handled there, not
3845            // here, for the same "not always a scalar `PropertyValue`"
3846            // reason (well, it always *is* one here, but `lookup_prop_value`
3847            // is where that access actually happens either way).
3848            Binding::Value(_) | Binding::List(_) | Binding::Map(_) => Ok(None),
3849            // Unlike the others, a path is a real type error, not just an
3850            // "absent" property -- real Cypher's `InvalidArgumentType`
3851            // (TCK's MatchWhere1 `[14]`: `MATCH r = (n)-[*]->() WHERE
3852            // r.name = 'apa'`). Property access never had a meaning for a
3853            // path to begin with (it's not a graph-object-shaped value).
3854            Binding::Path(_) => Err(QueryError::Type(format!(
3855                "'{}' is a path — property access requires a node, relationship, or map",
3856                pa.var
3857            ))),
3858        }
3859    }
3860
3861    /// `ReturnExpr::Prop`'s own lookup -- unlike `lookup_prop` (used by
3862    /// pattern-level `WHERE`, which only ever compares a real node/edge
3863    /// property against a `Literal`), a map's value can be any `Value`
3864    /// shape (nested list/map/node), not just a scalar `PropertyValue`,
3865    /// so this returns the wider type and handles `Binding::Map` itself
3866    /// rather than collapsing through `lookup_prop`. A `Binding::Value`
3867    /// holding a `Date`/`Duration` is handled here too, for the same
3868    /// reason -- `d.year`/`d.months`/etc are real component accessors
3869    /// (Temporal5's whole scenario shape, `WITH v.date AS d ... RETURN
3870    /// d.year`), not a stored property `lookup_prop` could ever find.
3871    ///
3872    /// Only a node, relationship, map, or temporal value has any `.prop`
3873    /// to access at all -- a plain scalar (`Bool`/`Int`/`Float`/`String`)
3874    /// or a `List` is a real type error here (real Cypher's own
3875    /// `InvalidArgumentType` is raised at *compile* time; this codebase's
3876    /// `Kind` system can't see through a WITH-projected value's real
3877    /// runtime shape to catch it any earlier -- see `infer_expr`'s own
3878    /// `Kind::Scalar` docs -- so it surfaces here instead), not a silent
3879    /// `null` (TCK's Graph6 [9] / Map1 [6]). `null` itself is exempt --
3880    /// real Cypher's null propagation rule, not a type error.
3881    fn lookup_prop_value(
3882        &self,
3883        txn: Txn,
3884        pa: &PropAccess,
3885        row: &BindingRow,
3886    ) -> Result<Value, QueryError> {
3887        match row.get(&pa.var) {
3888            Some(Binding::Map(m)) => Ok(m.get(&pa.prop).cloned().unwrap_or(Value::Null)),
3889            Some(Binding::Value(PropertyValue::Null)) => Ok(Value::Null),
3890            Some(Binding::Value(pv)) => match temporal_component(pv, &pa.prop) {
3891                Some(component) => Ok(Value::Property(component)),
3892                None if is_temporal_property_value(pv) => Ok(Value::Null),
3893                None => Err(QueryError::Type(format!(
3894                    "'{}' can't have properties accessed on it -- property access requires a \
3895                     node, relationship, map, or temporal value",
3896                    pa.var
3897                ))),
3898            },
3899            Some(Binding::List(_)) => Err(QueryError::Type(format!(
3900                "'{}' can't have properties accessed on it -- property access requires a node, \
3901                 relationship, map, or temporal value, not a list",
3902                pa.var
3903            ))),
3904            Some(_) => Ok(match self.lookup_prop(txn, pa, row)? {
3905                Some(PropertyValue::Null) | None => Value::Null,
3906                Some(pv) => property_value_to_value(pv),
3907            }),
3908            None => Err(QueryError::UnboundVariable(pa.var.clone())),
3909        }
3910    }
3911
3912    fn materialize_return(
3913        &self,
3914        txn: Txn,
3915        items: &[ReturnItem],
3916        rows: &[BindingRow],
3917        distinct: bool,
3918        guard: &ExecutionGuard<'_>,
3919    ) -> Result<QueryResult, QueryError> {
3920        let columns = items
3921            .iter()
3922            .enumerate()
3923            .map(|(i, item)| {
3924                item.alias
3925                    .clone()
3926                    .unwrap_or_else(|| default_column_name(&item.expr, i))
3927            })
3928            .collect();
3929        let mut out_rows = if !has_aggregate(items) {
3930            let mut out_rows = Vec::with_capacity(rows.len());
3931            for row in rows {
3932                let mut out_row = Vec::with_capacity(items.len());
3933                for item in items {
3934                    out_row.push(self.eval_return_expr(txn, &item.expr, row, guard)?);
3935                }
3936                out_rows.push(out_row);
3937            }
3938            out_rows
3939        } else {
3940            validate_return_items(items)?;
3941            let grouped = self.resolve_grouped_rows(txn, items, rows, guard)?;
3942            grouped
3943                .into_iter()
3944                .map(|bindings| {
3945                    bindings
3946                        .iter()
3947                        .map(|b| self.binding_to_value(txn, b))
3948                        .collect::<Result<Vec<_>, _>>()
3949                })
3950                .collect::<Result<Vec<_>, _>>()?
3951        };
3952        if distinct {
3953            out_rows = dedup_rows(out_rows)?;
3954        }
3955        Ok(QueryResult {
3956            columns,
3957            rows: out_rows,
3958        })
3959    }
3960
3961    /// An aggregating `RETURN`'s own `ORDER BY`, when at least one key
3962    /// doesn't verbatim/alias-match any item -- `RETURN me.age AS age,
3963    /// count(you.age) AS cnt ORDER BY age + count(you.age)` (TCK's
3964    /// ReturnOrderBy6). Folds those extra keys through the *same*
3965    /// grouping pass as `items` themselves, as synthetic unreturned extra
3966    /// items (reusing `resolve_grouped_rows`/`rewrite_composed_item`
3967    /// exactly as a composed RETURN item would, including an aggregate
3968    /// call that appears *only* in the ORDER BY key, nowhere in `items`
3969    /// -- real Cypher allows that too, it just needs to fold consistently
3970    /// with `items`' own implicit grouping, not literally reuse one of
3971    /// their accumulators), then uses their per-group values as
3972    /// additional sort keys before stripping them back off. Degrades to
3973    /// exactly the ordinary "sort by already-computed columns" behavior
3974    /// when every key does verbatim/alias-match (`extra_exprs` empty) --
3975    /// callers can route every aggregating-`RETURN`-with-`ORDER-BY` case
3976    /// through this one function rather than branching on whether extras
3977    /// are actually needed.
3978    ///
3979    /// `DISTINCT` isn't handled here -- deliberately: grouping already
3980    /// makes every output row unique by its own grouping-key columns (two
3981    /// groups can't have the same grouping key and still be different
3982    /// groups), so `RETURN DISTINCT` combined with aggregation is
3983    /// provably always a no-op downstream of this function regardless.
3984    fn materialize_aggregating_return_with_order(
3985        &self,
3986        txn: Txn,
3987        items: &[ReturnItem],
3988        rows: &[BindingRow],
3989        order_by: &[(ReturnExpr, SortDir)],
3990        skip_limit: (Option<i64>, Option<i64>),
3991        guard: &ExecutionGuard<'_>,
3992    ) -> Result<QueryResult, QueryError> {
3993        let (skip, limit) = skip_limit;
3994        enum OrderKeySource {
3995            RealColumn(usize),
3996            Extra(usize),
3997        }
3998        let mut extra_exprs: Vec<ReturnExpr> = Vec::new();
3999        let order_by_source: Vec<OrderKeySource> = order_by
4000            .iter()
4001            .map(|(expr, _)| {
4002                match items
4003                    .iter()
4004                    .enumerate()
4005                    .position(|(i, it)| item_matches_leaf(expr, i, it))
4006                {
4007                    Some(i) => OrderKeySource::RealColumn(i),
4008                    None => {
4009                        let idx = extra_exprs.len();
4010                        extra_exprs.push(expr.clone());
4011                        OrderKeySource::Extra(idx)
4012                    }
4013                }
4014            })
4015            .collect();
4016        let extended_items: Vec<ReturnItem> = items
4017            .iter()
4018            .cloned()
4019            .chain(
4020                extra_exprs
4021                    .into_iter()
4022                    .map(|expr| ReturnItem { expr, alias: None }),
4023            )
4024            .collect();
4025        validate_return_items(&extended_items)?;
4026        let grouped = self.resolve_grouped_rows(txn, &extended_items, rows, guard)?;
4027        let columns: Vec<String> = items
4028            .iter()
4029            .enumerate()
4030            .map(|(i, item)| {
4031                item.alias
4032                    .clone()
4033                    .unwrap_or_else(|| default_column_name(&item.expr, i))
4034            })
4035            .collect();
4036        let real_len = items.len();
4037        let mut keyed: Vec<(Vec<Value>, Vec<Value>)> = Vec::with_capacity(grouped.len());
4038        for bindings in grouped {
4039            let values: Vec<Value> = bindings
4040                .iter()
4041                .map(|b| self.binding_to_value(txn, b))
4042                .collect::<Result<Vec<_>, _>>()?;
4043            let (real, extra) = values.split_at(real_len);
4044            let keys: Vec<Value> = order_by_source
4045                .iter()
4046                .map(|src| match src {
4047                    OrderKeySource::RealColumn(i) => real[*i].clone(),
4048                    OrderKeySource::Extra(k) => extra[*k].clone(),
4049                })
4050                .collect();
4051            keyed.push((keys, real.to_vec()));
4052        }
4053        let rows = top_k_by(keyed, order_by, skip, limit)
4054            .into_iter()
4055            .map(|(_, row)| row)
4056            .collect();
4057        Ok(QueryResult { columns, rows })
4058    }
4059
4060    /// `SKIP`/`LIMIT` accept any expression, not just a literal integer
4061    /// (`SKIP $n`, `SKIP toInteger(rand()*9)` -- TCK's `ReturnSkipLimit1
4062    /// [2]`/`[3]`) -- evaluated exactly once here, against an empty row,
4063    /// since no pattern variable can be in scope at a statement's own
4064    /// SKIP/LIMIT (an `UnboundVariable` error from `eval_return_expr`
4065    /// below is exactly the right outcome if one is referenced). Params
4066    /// are already resolved to concrete `Literal`s by this point (see
4067    /// `params::substitute_params`).
4068    fn resolve_skip_limit(
4069        &self,
4070        txn: Txn,
4071        expr: Option<&ReturnExpr>,
4072        clause: &str,
4073        guard: &ExecutionGuard<'_>,
4074    ) -> Result<Option<i64>, QueryError> {
4075        let Some(expr) = expr else {
4076            return Ok(None);
4077        };
4078        let value = self.eval_return_expr(txn, expr, &BindingRow::new(), guard)?;
4079        let n = match value {
4080            Value::Literal(Literal::Int(n)) | Value::Property(PropertyValue::Int(n)) => n,
4081            _ => {
4082                return Err(QueryError::Semantic(format!(
4083                    "{clause} must evaluate to an integer"
4084                )));
4085            }
4086        };
4087        if n < 0 {
4088            return Err(QueryError::Semantic(format!("{clause} can't be negative")));
4089        }
4090        Ok(Some(n))
4091    }
4092
4093    fn eval_return_expr(
4094        &self,
4095        txn: Txn,
4096        expr: &ReturnExpr,
4097        row: &BindingRow,
4098        guard: &ExecutionGuard<'_>,
4099    ) -> Result<Value, QueryError> {
4100        match expr {
4101            ReturnExpr::Var(var) => {
4102                let binding = row
4103                    .get(var)
4104                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
4105                self.binding_to_value(txn, binding)
4106            }
4107            ReturnExpr::Prop(pa) => self.lookup_prop_value(txn, pa, row),
4108            ReturnExpr::PropOf(base, prop) => {
4109                let v = self.eval_return_expr(txn, base, row, guard)?;
4110                property_of_value(&v, prop)
4111            }
4112            ReturnExpr::Lit(lit) => Ok(match lit {
4113                Literal::Null => Value::Null,
4114                other => Value::Literal(other.clone()),
4115            }),
4116            ReturnExpr::Call { name, args, .. } => {
4117                // Reaching here with an aggregate name means an aggregate
4118                // call slipped past `validate_return_items` (which only
4119                // allows one at a return item's top level) — grouping
4120                // itself never calls `eval_return_expr` on the aggregate
4121                // wrapper, only on each aggregate's own argument
4122                // subexpression (see `resolve_grouped_rows`), so this is
4123                // an internal-consistency error, not a normal user path.
4124                if is_aggregate_name(name) {
4125                    return Err(QueryError::Semantic(format!(
4126                        "aggregate function '{name}' can only be used as a return item's top-level expression"
4127                    )));
4128                }
4129                let lower = name.to_ascii_lowercase();
4130                if lower == "type" {
4131                    // Special-cased *before* the generic arg-evaluation
4132                    // below -- that would eagerly fail on a deleted
4133                    // relationship (`deleted_entity_access`), before
4134                    // `eval_type_call` ever gets a chance to fall back to
4135                    // its cached type. See `ExecutionGuard::
4136                    // deleted_edge_types`'s own docs.
4137                    return self.eval_type_call(txn, args.first(), row, guard);
4138                }
4139                let arg_values = args
4140                    .iter()
4141                    .map(|a| self.eval_return_expr(txn, a, row, guard))
4142                    .collect::<Result<Vec<_>, _>>()?;
4143                if lower == "startnode" || lower == "endnode" {
4144                    return self.start_or_end_node(txn, &lower, arg_values.first());
4145                }
4146                call_builtin(name, &arg_values, self.now_snapshot())
4147            }
4148            ReturnExpr::CountStar => Err(QueryError::Semantic(
4149                "count(*) can only be used as a return item's top-level expression".into(),
4150            )),
4151            ReturnExpr::Case { test, whens, else_ } => {
4152                let test_value = match test {
4153                    Some(t) => Some(self.eval_return_expr(txn, t, row, guard)?),
4154                    None => None,
4155                };
4156                for (when, then) in whens {
4157                    let when_value = self.eval_return_expr(txn, when, row, guard)?;
4158                    // Deliberately reuses the same Null == Null -> true
4159                    // convention as `compare()` below, not standard
4160                    // three-valued NULL logic — IS7's `CASE r WHEN null
4161                    // THEN false ELSE true END` depends on this exact
4162                    // semantics to detect an OPTIONAL MATCH non-match.
4163                    let matched = match &test_value {
4164                        Some(tv) => value_eq(tv, &when_value),
4165                        None => matches!(when_value, Value::Literal(Literal::Bool(true))),
4166                    };
4167                    if matched {
4168                        return self.eval_return_expr(txn, then, row, guard);
4169                    }
4170                }
4171                match else_ {
4172                    Some(e) => self.eval_return_expr(txn, e, row, guard),
4173                    None => Ok(Value::Null),
4174                }
4175            }
4176            ReturnExpr::Arith(l, op, r) => {
4177                let lv = self.eval_return_expr(txn, l, row, guard)?;
4178                let rv = self.eval_return_expr(txn, r, row, guard)?;
4179                apply_arith(*op, &lv, &rv)
4180            }
4181            ReturnExpr::Neg(e) => {
4182                let v = self.eval_return_expr(txn, e, row, guard)?;
4183                apply_neg(&v)
4184            }
4185            ReturnExpr::ListLit(items) => Ok(Value::List(
4186                items
4187                    .iter()
4188                    .map(|item| self.eval_return_expr(txn, item, row, guard))
4189                    .collect::<Result<Vec<_>, _>>()?,
4190            )),
4191            ReturnExpr::Index(base, index) => {
4192                let base_v = self.eval_return_expr(txn, base, row, guard)?;
4193                let index_v = self.eval_return_expr(txn, index, row, guard)?;
4194                apply_index(&base_v, &index_v)
4195            }
4196            ReturnExpr::Slice(base, start, end) => {
4197                let base_v = self.eval_return_expr(txn, base, row, guard)?;
4198                let start_v = start
4199                    .as_deref()
4200                    .map(|s| self.eval_return_expr(txn, s, row, guard))
4201                    .transpose()?;
4202                let end_v = end
4203                    .as_deref()
4204                    .map(|e| self.eval_return_expr(txn, e, row, guard))
4205                    .transpose()?;
4206                apply_slice(&base_v, start_v.as_ref(), end_v.as_ref())
4207            }
4208            ReturnExpr::ListComp {
4209                var,
4210                source,
4211                where_clause,
4212                project,
4213            } => {
4214                let source_v = self.eval_return_expr(txn, source, row, guard)?;
4215                let items = match source_v {
4216                    Value::List(items) => items,
4217                    Value::Null => return Ok(Value::Null),
4218                    other => {
4219                        return Err(QueryError::Type(format!(
4220                            "list comprehension source must be a list, got {other:?}"
4221                        )))
4222                    }
4223                };
4224                let mut result = Vec::with_capacity(items.len());
4225                for item in items {
4226                    // A fresh overlay per element -- `var` shadows any
4227                    // outer binding of the same name for the duration of
4228                    // this one element, same scoping UNWIND already uses.
4229                    let mut scoped_row = row.clone();
4230                    scoped_row.insert(var.clone(), value_to_binding_restore(&item));
4231                    let keep = match where_clause {
4232                        Some(w) => {
4233                            self.eval_return_expr_bool3(txn, w, &scoped_row, guard)? == Some(true)
4234                        }
4235                        None => true,
4236                    };
4237                    if !keep {
4238                        continue;
4239                    }
4240                    result.push(match project {
4241                        Some(p) => self.eval_return_expr(txn, p, &scoped_row, guard)?,
4242                        None => item,
4243                    });
4244                }
4245                Ok(Value::List(result))
4246            }
4247            ReturnExpr::Quantifier {
4248                kind,
4249                var,
4250                source,
4251                where_clause,
4252            } => {
4253                let source_v = self.eval_return_expr(txn, source, row, guard)?;
4254                let items = match source_v {
4255                    Value::List(items) => items,
4256                    Value::Null => return Ok(Value::Null),
4257                    other => {
4258                        return Err(QueryError::Type(format!(
4259                            "quantifier source must be a list, got {other:?}"
4260                        )))
4261                    }
4262                };
4263                let mut preds = Vec::with_capacity(items.len());
4264                for item in &items {
4265                    let mut scoped_row = row.clone();
4266                    scoped_row.insert(var.clone(), value_to_binding_restore(item));
4267                    preds.push(match where_clause {
4268                        Some(w) => self.eval_return_expr_bool3(txn, w, &scoped_row, guard)?,
4269                        None => item_truthy(item),
4270                    });
4271                }
4272                Ok(match eval_quantifier(*kind, &preds) {
4273                    Some(b) => Value::Literal(Literal::Bool(b)),
4274                    None => Value::Null,
4275                })
4276            }
4277            ReturnExpr::MapLit(entries) => {
4278                let mut map = BTreeMap::new();
4279                for (k, v) in entries {
4280                    map.insert(k.clone(), self.eval_return_expr(txn, v, row, guard)?);
4281                }
4282                Ok(Value::Map(map))
4283            }
4284            ReturnExpr::And(l, r) => Ok(bool3_to_value(and3(
4285                self.eval_return_expr_bool3(txn, l, row, guard)?,
4286                self.eval_return_expr_bool3(txn, r, row, guard)?,
4287            ))),
4288            ReturnExpr::Or(l, r) => Ok(bool3_to_value(or3(
4289                self.eval_return_expr_bool3(txn, l, row, guard)?,
4290                self.eval_return_expr_bool3(txn, r, row, guard)?,
4291            ))),
4292            ReturnExpr::Xor(l, r) => Ok(bool3_to_value(xor3(
4293                self.eval_return_expr_bool3(txn, l, row, guard)?,
4294                self.eval_return_expr_bool3(txn, r, row, guard)?,
4295            ))),
4296            ReturnExpr::Not(e) => Ok(bool3_to_value(
4297                self.eval_return_expr_bool3(txn, e, row, guard)?.map(|b| !b),
4298            )),
4299            ReturnExpr::Compare(l, op, r) => {
4300                let lv = self.eval_return_expr(txn, l, row, guard)?;
4301                let rv = self.eval_return_expr(txn, r, row, guard)?;
4302                Ok(bool3_to_value(compare_values(&lv, *op, &rv)))
4303            }
4304            ReturnExpr::IsNull(e) => {
4305                let v = self.eval_return_expr(txn, e, row, guard)?;
4306                Ok(Value::Literal(Literal::Bool(matches!(v, Value::Null))))
4307            }
4308            ReturnExpr::In(needle, haystack) => {
4309                let nv = self.eval_return_expr(txn, needle, row, guard)?;
4310                let hv = self.eval_return_expr(txn, haystack, row, guard)?;
4311                Ok(bool3_to_value(list_membership_ternary(&nv, &hv)?))
4312            }
4313            ReturnExpr::HasLabel(var, labels) => {
4314                let binding = row
4315                    .get(var)
4316                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
4317                match binding {
4318                    Binding::Node(id) => {
4319                        let node = deleted_entity_access(GraphStore::get_node_in_txn(txn, *id)?)?;
4320                        Ok(Value::Literal(Literal::Bool(
4321                            labels.iter().all(|l| node.labels.contains(l)),
4322                        )))
4323                    }
4324                    // `r:TYPE` -- a relationship has exactly one type, so
4325                    // this is just an equality check, not a set-membership
4326                    // one; a conjunctive `r:A:B` (only reachable from
4327                    // general expression position, never real Cypher's own
4328                    // pattern-level `WHERE` -- relationships can't carry
4329                    // more than one type) is trivially always false unless
4330                    // every listed name is the same one type (TCK's Graph5
4331                    // "Node and edge label expressions" [2]).
4332                    Binding::Edge(id) => {
4333                        let edge = deleted_entity_access(GraphStore::get_edge_in_txn(txn, *id)?)?;
4334                        Ok(Value::Literal(Literal::Bool(
4335                            labels.iter().all(|l| edge.label == *l),
4336                        )))
4337                    }
4338                    Binding::Value(PropertyValue::Null) => Ok(Value::Null),
4339                    other => Err(QueryError::Type(format!(
4340                        "'{var}' isn't a node or relationship — (n:Label) needs one, got {other:?}"
4341                    ))),
4342                }
4343            }
4344            ReturnExpr::PatternPredicate(_) => Err(QueryError::Semantic(
4345                "a pattern predicate (`(n)-->()` etc) can only be used inside WHERE".into(),
4346            )),
4347            ReturnExpr::PatternComprehension {
4348                path_var,
4349                pattern,
4350                where_clause,
4351                projection,
4352            } => self.eval_pattern_comprehension(
4353                txn,
4354                PatternComprehensionSpec {
4355                    path_var,
4356                    pattern,
4357                    where_clause,
4358                    projection,
4359                },
4360                row,
4361                guard,
4362            ),
4363            ReturnExpr::ExistsPattern { .. } | ReturnExpr::ExistsSubquery(_) => Err(
4364                QueryError::Semantic("an exists {} subquery can only be used inside WHERE".into()),
4365            ),
4366        }
4367    }
4368
4369    /// `[p = (n)-->() | p]` / `[(n)-[:T]->(b) | b.name]` -- enumerates
4370    /// every match of `pattern` against the graph (already-bound named
4371    /// endpoints in `row` held fixed, exactly like `Expr::Pattern`'s own
4372    /// existential search reuses `build_match_plan`'s "already-bound var
4373    /// -> Seed, not a fresh scan" mechanism) and projects `projection`
4374    /// against each match's own resulting row, collecting into a
4375    /// `Value::List`. No limit on `eval_plan_with_limit` here (unlike
4376    /// `Expr::Pattern`'s `Some(1)`) -- a comprehension needs every match,
4377    /// not just whether one exists.
4378    ///
4379    /// A named path (`path_var: Some`) reuses `execute_match`'s own
4380    /// `name_pattern_for_path`/`assemble_path` pair verbatim -- same
4381    /// "synthesize internal names for any unnamed hop, assemble the path
4382    /// from those, then strip the synthesized keys (and the reserved
4383    /// variable-length-hop segment key, if any) back out" approach a real
4384    /// `MATCH p = ...` clause already uses, including over a single
4385    /// variable-length hop (TCK's Pattern2 `[9]`) -- also reuses
4386    /// `validate_named_path_pattern`'s own restriction on anything wider
4387    /// (a variable-length hop mixed with another hop) for the same reason
4388    /// it already applies to `MATCH`.
4389    fn eval_pattern_comprehension(
4390        &self,
4391        txn: Txn,
4392        spec: PatternComprehensionSpec<'_>,
4393        row: &BindingRow,
4394        guard: &ExecutionGuard<'_>,
4395    ) -> Result<Value, QueryError> {
4396        let PatternComprehensionSpec {
4397            path_var,
4398            pattern,
4399            where_clause,
4400            projection,
4401        } = spec;
4402        if path_var.is_some() {
4403            validate_named_path_pattern(pattern)?;
4404        }
4405        let carried_vars: HashSet<String> = row.keys().cloned().collect();
4406        let (named_pattern, synthesized) = match path_var {
4407            Some(_) => name_pattern_for_path(pattern),
4408            None => (pattern.clone(), HashSet::new()),
4409        };
4410        let wc: Option<Expr> = where_clause.as_deref().cloned();
4411        let plan = apply_index_seeks(build_match_plan(&named_pattern, &wc, &carried_vars)?, txn)?;
4412        let rows = self.eval_plan_with_limit(txn, &plan, std::slice::from_ref(row), guard, None)?;
4413        let mut out = Vec::with_capacity(rows.len());
4414        for mut r in rows {
4415            if let Some(pv) = path_var {
4416                let path_binding = assemble_path(&named_pattern, &r);
4417                for key in &synthesized {
4418                    r.remove(key);
4419                }
4420                r.insert(pv.clone(), path_binding);
4421            }
4422            out.push(self.eval_return_expr(txn, projection, &r, guard)?);
4423        }
4424        Ok(Value::List(out))
4425    }
4426
4427    /// A `WHERE`-position `ReturnExpr` (list comprehension/quantifier
4428    /// filters) evaluated as three-valued logic instead of a plain
4429    /// `Value` -- delegates to `eval_return_expr` then folds the result
4430    /// down via `value_to_bool3`.
4431    fn eval_return_expr_bool3(
4432        &self,
4433        txn: Txn,
4434        expr: &ReturnExpr,
4435        row: &BindingRow,
4436        guard: &ExecutionGuard<'_>,
4437    ) -> Result<Option<bool>, QueryError> {
4438        value_to_bool3(&self.eval_return_expr(txn, expr, row, guard)?)
4439    }
4440
4441    /// Deletes every `targets` expression's value, across every row --
4442    /// shared by `materialize_delete` (`DELETE`/`DETACH DELETE` as a
4443    /// statement tail) and `execute_match`'s own `QueryClause::Delete`
4444    /// (`DELETE ... WITH ...` mid-pattern). Edges are deleted immediately
4445    /// (no ordering constraint), but nodes are only *collected* into
4446    /// `pending_nodes` and deleted in a second pass, after every target
4447    /// across every row has contributed its own edges -- not deleted
4448    /// inline the way `delete_binding`/`delete_value` used to. A single
4449    /// non-`DETACH` `DELETE` naming *several* targets that collectively
4450    /// cover all of a node's edges (e.g. `DELETE pathColls.key[0],
4451    /// pathColls.key[1]`, two paths sharing a node, each contributing one
4452    /// of its two incident edges) must succeed -- deleting inline would
4453    /// try to delete the first path's node while the second path's edge
4454    /// (not yet processed) was still attached, a real bug found via TCK's
4455    /// Delete5 `[7]` once `{key: collect(p)}`-shaped composed expressions
4456    /// could reach this code path at all (previously rejected outright at
4457    /// compile time, before general aggregate composition was supported).
4458    fn delete_targets(
4459        &self,
4460        txn: Txn,
4461        write_txn: &WriteTransaction,
4462        targets: &[ReturnExpr],
4463        rows: &[BindingRow],
4464        detach: bool,
4465        guard: &ExecutionGuard<'_>,
4466    ) -> Result<(), QueryError> {
4467        let mut deleted_edges = HashSet::new();
4468        let mut pending_nodes = HashSet::new();
4469        for row in rows {
4470            for target in targets {
4471                // A bare variable (`DELETE r, a, b`, by far the common
4472                // case) deletes by the raw id already sitting in the row's
4473                // `Binding` -- no existence check, no property fetch.
4474                // That's what lets `DELETE r, a, b` work when two rows of
4475                // the same undirected match both reference the same `a`/
4476                // `b`/`r` (real, from TCK's Delete4 `[1]`): the second
4477                // row's own dedup lookup must succeed even though the
4478                // first row already deleted them. Anything else (`list[0]`,
4479                // `map.key`, a whole path variable's *elements* accessed
4480                // computedly, ...) has no such raw shortcut and goes
4481                // through real evaluation instead -- which correctly does
4482                // still error via `deleted_entity_access` if it tries to
4483                // read a property off something already gone, since that's
4484                // a genuine access, not just a re-statement of identity.
4485                if let ReturnExpr::Var(name) = target {
4486                    let binding = row
4487                        .get(name)
4488                        .ok_or_else(|| QueryError::UnboundVariable(name.clone()))?;
4489                    delete_binding(
4490                        txn,
4491                        binding,
4492                        write_txn,
4493                        &mut deleted_edges,
4494                        &mut pending_nodes,
4495                        guard,
4496                    )?;
4497                } else {
4498                    let value = self.eval_return_expr(txn, target, row, guard)?;
4499                    delete_value(
4500                        &value,
4501                        write_txn,
4502                        &mut deleted_edges,
4503                        &mut pending_nodes,
4504                        guard,
4505                    )?;
4506                }
4507            }
4508        }
4509        for id in pending_nodes {
4510            GraphStore::delete_node_in_txn(write_txn, id, detach)?;
4511        }
4512        Ok(())
4513    }
4514
4515    /// `ret`, when present, is evaluated *after* the physical delete runs,
4516    /// not before — real Cypher's own DELETE+RETURN TCK scenarios agree on
4517    /// this ordering: `MATCH (n) DELETE n RETURN n.num` must raise a
4518    /// `DeletedEntityAccess` error (TCK's Return2 scenarios [15]/[17]), not
4519    /// silently return the pre-delete value. `lookup_prop`/
4520    /// `binding_to_value` (via `deleted_entity_access`) already turn "the
4521    /// bound id's record is gone" into a proper `QueryError` rather than a
4522    /// silent null or a panic, which is exactly what makes deleting first
4523    /// safe here — every other real DELETE+RETURN shape (`count(*)`,
4524    /// `sum(num)` off a WITH-projected scalar, a literal, a null OPTIONAL
4525    /// MATCH binding) never touches the just-deleted entity's live record
4526    /// at all, so this ordering changes nothing for them.
4527    fn materialize_delete(
4528        &self,
4529        txn: Txn,
4530        targets: &[ReturnExpr],
4531        rows: &[BindingRow],
4532        detach: bool,
4533        ret: &Option<ReturnTail>,
4534        guard: &ExecutionGuard<'_>,
4535    ) -> Result<QueryResult, QueryError> {
4536        let write_txn = require_write_txn(txn);
4537        self.delete_targets(txn, write_txn, targets, rows, detach, guard)?;
4538        let result = match ret {
4539            Some(rt) => self.materialize_return(txn, &rt.items, rows, rt.distinct, guard)?,
4540            None => QueryResult {
4541                columns: vec![],
4542                rows: vec![],
4543            },
4544        };
4545        Ok(result)
4546    }
4547
4548    fn materialize_set(
4549        &self,
4550        txn: Txn,
4551        items: &[SetItem],
4552        rows: &[BindingRow],
4553        ret: &Option<ReturnTail>,
4554        guard: &ExecutionGuard<'_>,
4555    ) -> Result<QueryResult, QueryError> {
4556        let write_txn = require_write_txn(txn);
4557        for row in rows {
4558            for item in items {
4559                self.apply_set_item(txn, write_txn, row, item, guard)?;
4560            }
4561        }
4562        match ret {
4563            Some(rt) => self.materialize_return(txn, &rt.items, rows, rt.distinct, guard),
4564            None => Ok(QueryResult {
4565                columns: vec![],
4566                rows: vec![],
4567            }),
4568        }
4569    }
4570
4571    fn materialize_remove(
4572        &self,
4573        txn: Txn,
4574        items: &[RemoveItem],
4575        rows: &[BindingRow],
4576        ret: &Option<ReturnTail>,
4577        guard: &ExecutionGuard<'_>,
4578    ) -> Result<QueryResult, QueryError> {
4579        let write_txn = require_write_txn(txn);
4580        for row in rows {
4581            for item in items {
4582                apply_remove_item(write_txn, row, item)?;
4583            }
4584        }
4585        match ret {
4586            Some(rt) => self.materialize_return(txn, &rt.items, rows, rt.distinct, guard),
4587            None => Ok(QueryResult {
4588                columns: vec![],
4589                rows: vec![],
4590            }),
4591        }
4592    }
4593
4594    /// `<match_stmt> UNION [ALL] <match_stmt> ...` — every part shares the
4595    /// same `txn` (one snapshot for a read-only union, one write
4596    /// transaction otherwise — see `is_read_only`'s own `Union` handling)
4597    /// but no bindings: each part is `execute_match`'d completely
4598    /// independently, matching real Cypher's own scoping. Column names
4599    /// must match exactly across every part (real Cypher's
4600    /// `DifferentColumnsInUnion` — checked here, once each part's real
4601    /// `QueryResult.columns` is in hand, rather than statically, since
4602    /// nothing else in this codebase infers a `RETURN` list's column
4603    /// names without evaluating it). `all: false` (plain `UNION`) dedups
4604    /// the combined rows via the same `dedup_rows` `RETURN DISTINCT`
4605    /// already uses; `all: true` keeps every row.
4606    fn materialize_union(
4607        &self,
4608        txn: Txn,
4609        parts: &[Statement],
4610        all: bool,
4611        guard: &ExecutionGuard<'_>,
4612    ) -> Result<QueryResult, QueryError> {
4613        let mut combined: Option<QueryResult> = None;
4614        for part in parts {
4615            let Statement::Match {
4616                clauses,
4617                tail,
4618                order_by,
4619                skip,
4620                limit,
4621            } = part
4622            else {
4623                unreachable!(
4624                    "union_stmt parts are always Statement::Match -- see parser::parse_union_stmt"
4625                )
4626            };
4627            let skip = self.resolve_skip_limit(txn, skip.as_deref(), "SKIP", guard)?;
4628            let limit = self.resolve_skip_limit(txn, limit.as_deref(), "LIMIT", guard)?;
4629            let result = self.execute_match(
4630                txn,
4631                clauses,
4632                tail,
4633                ResultModifiers {
4634                    order_by,
4635                    skip,
4636                    limit,
4637                },
4638                guard,
4639            )?;
4640            combined = Some(match combined {
4641                None => result,
4642                Some(mut acc) => {
4643                    if acc.columns != result.columns {
4644                        return Err(QueryError::Semantic(format!(
4645                            "UNION requires every part to return the same columns -- got {:?} \
4646                             and {:?}",
4647                            acc.columns, result.columns
4648                        )));
4649                    }
4650                    acc.rows.extend(result.rows);
4651                    acc
4652                }
4653            });
4654            guard.check_intermediate_rows(combined.as_ref().map(|r| r.rows.len()).unwrap_or(0))?;
4655        }
4656        let mut result = combined.expect("union_stmt grammar guarantees at least 2 parts");
4657        if !all {
4658            result.rows = dedup_rows(result.rows)?;
4659        }
4660        Ok(result)
4661    }
4662
4663    fn apply_set_item(
4664        &self,
4665        txn: Txn,
4666        write_txn: &WriteTransaction,
4667        row: &BindingRow,
4668        item: &SetItem,
4669        guard: &ExecutionGuard<'_>,
4670    ) -> Result<(), QueryError> {
4671        match item {
4672            SetItem::Prop(pa, expr) => {
4673                let binding = row
4674                    .get(&pa.var)
4675                    .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
4676                // `SET` on a null binding is a documented no-op, same as
4677                // `DELETE`/`REMOVE` on one -- an `OPTIONAL MATCH` that found
4678                // nothing pads its variables with null (found via TCK's
4679                // Set1/Set3 "Ignore null when setting property/label"
4680                // scenarios).
4681                if matches!(binding, Binding::Value(PropertyValue::Null)) {
4682                    return Ok(());
4683                }
4684                let node_id = if let Binding::Node(id) = binding {
4685                    Some(*id)
4686                } else {
4687                    None
4688                };
4689                let edge_id = if let Binding::Edge(id) = binding {
4690                    Some(*id)
4691                } else {
4692                    None
4693                };
4694                if node_id.is_none() && edge_id.is_none() {
4695                    return Err(QueryError::UnboundVariable(format!(
4696                    "'{}' is a WITH-projected scalar, not a node/edge — SET needs a graph binding",
4697                    pa.var
4698                )));
4699                }
4700                let value = self.eval_return_expr(txn, expr, row, guard)?;
4701                // `SET n.prop = null` *removes* the property in real Cypher
4702                // (found via TCK's Set2 "Set a Property to Null" scenarios,
4703                // which this codebase previously couldn't parse at all --
4704                // `SET` had no trailing RETURN to observe the result with, so
4705                // this bug was never exercised until that gap closed).
4706                // Storing a literal `PropertyValue::Null` instead is
4707                // observably different: `n.prop` still shows up as a
4708                // (nulled-out) key when a caller enumerates a node's own
4709                // props (e.g. this RETURN's own node-to-string rendering),
4710                // where a real missing property wouldn't. The RHS being
4711                // `null` is now a *runtime* fact (it's any `ReturnExpr`, not
4712                // just the `Literal::Null` token), not something checkable
4713                // from the AST alone -- `SET n.prop = coalesce(x, null)`
4714                // must remove the property too if `x` turns out null.
4715                if matches!(value, Value::Null) {
4716                    if let Some(id) = node_id {
4717                        GraphStore::remove_node_prop_in_txn(write_txn, id, &pa.prop)?;
4718                    }
4719                    if let Some(id) = edge_id {
4720                        GraphStore::remove_edge_prop_in_txn(write_txn, id, &pa.prop)?;
4721                    }
4722                } else {
4723                    let pv = value_to_storable_property(&value).ok_or_else(|| {
4724                    QueryError::Type(format!(
4725                        "property '{}' can't be stored -- MarsDB's node/edge properties are limited \
4726                         to null/bool/int/float/string/date/duration; a list/map/node/edge/path value \
4727                         (got {value:?}) isn't storable",
4728                        pa.prop
4729                    ))
4730                })?;
4731                    if let Some(id) = node_id {
4732                        GraphStore::set_node_prop_in_txn(write_txn, id, &pa.prop, pv.clone())?;
4733                    }
4734                    if let Some(id) = edge_id {
4735                        GraphStore::set_edge_prop_in_txn(write_txn, id, &pa.prop, pv)?;
4736                    }
4737                }
4738            }
4739            SetItem::Labels(var, labels) => {
4740                let binding = row
4741                    .get(var)
4742                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
4743                match binding {
4744                    Binding::Node(id) => {
4745                        for label in labels {
4746                            GraphStore::add_node_label_in_txn(write_txn, *id, label)?;
4747                        }
4748                    }
4749                    // Same null-is-a-no-op rule as the property arm above.
4750                    Binding::Value(PropertyValue::Null) => {}
4751                    _ => {
4752                        return Err(QueryError::UnboundVariable(format!(
4753                            "'{var}' isn't a node — SET can only add labels to a node"
4754                        )))
4755                    }
4756                }
4757            }
4758            SetItem::MapAssign { var, value, merge } => {
4759                let binding = row
4760                    .get(var)
4761                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
4762                // Same null-is-a-no-op rule as the property arm above.
4763                if matches!(binding, Binding::Value(PropertyValue::Null)) {
4764                    return Ok(());
4765                }
4766                let node_id = if let Binding::Node(id) = binding {
4767                    Some(*id)
4768                } else {
4769                    None
4770                };
4771                let edge_id = if let Binding::Edge(id) = binding {
4772                    Some(*id)
4773                } else {
4774                    None
4775                };
4776                if node_id.is_none() && edge_id.is_none() {
4777                    return Err(QueryError::UnboundVariable(format!(
4778                        "'{var}' is a WITH-projected scalar, not a node/edge — SET needs a graph binding"
4779                    )));
4780                }
4781                let map_value = self.eval_return_expr(txn, value, row, guard)?;
4782                // A map literal is the common case, but real Cypher also
4783                // allows `SET r = a`/`SET r += a` where `a` is itself a
4784                // bound node/relationship -- copies its properties, same
4785                // as a map built from them would (TCK's Merge6 [6]/
4786                // Merge7 [4], "Copying properties from node").
4787                let entries = match map_value {
4788                    Value::Map(entries) => entries,
4789                    Value::Node(n) => n
4790                        .props
4791                        .into_iter()
4792                        .map(|(k, v)| (k, property_value_to_value(v)))
4793                        .collect(),
4794                    Value::Edge(e) => e
4795                        .props
4796                        .into_iter()
4797                        .map(|(k, v)| (k, property_value_to_value(v)))
4798                        .collect(),
4799                    other => {
4800                        return Err(QueryError::Type(format!(
4801                            "SET {var} = ...{} needs a map, node, or relationship, got {other:?}",
4802                            if *merge { " (+=)" } else { "" }
4803                        )))
4804                    }
4805                };
4806                // `SET n = {...}` (`merge: false`) replaces every existing
4807                // property -- delete whatever's already there first, not
4808                // just overwrite the map's own keys, or a key n already
4809                // had that the map doesn't mention would wrongly survive
4810                // (TCK's Set4 [2]/[3]/[4]).
4811                if !merge {
4812                    let existing_keys: Vec<String> = if let Some(id) = node_id {
4813                        deleted_entity_access(GraphStore::get_node_in_txn(txn, id)?)?
4814                            .props
4815                            .into_keys()
4816                            .collect()
4817                    } else {
4818                        deleted_entity_access(GraphStore::get_edge_in_txn(
4819                            txn,
4820                            edge_id.expect("node_id or edge_id is Some, checked above"),
4821                        )?)?
4822                        .props
4823                        .into_keys()
4824                        .collect()
4825                    };
4826                    for key in existing_keys {
4827                        if let Some(id) = node_id {
4828                            GraphStore::remove_node_prop_in_txn(write_txn, id, &key)?;
4829                        }
4830                        if let Some(id) = edge_id {
4831                            GraphStore::remove_edge_prop_in_txn(write_txn, id, &key)?;
4832                        }
4833                    }
4834                }
4835                // Either way, apply the map's own entries -- a `null`
4836                // value removes that one key (real Cypher's rule, same
4837                // "null means remove" convention `SetItem::Prop` already
4838                // has -- TCK's Set5 [4]), anything else sets it.
4839                for (key, entry_value) in entries {
4840                    if matches!(entry_value, Value::Null) {
4841                        if let Some(id) = node_id {
4842                            GraphStore::remove_node_prop_in_txn(write_txn, id, &key)?;
4843                        }
4844                        if let Some(id) = edge_id {
4845                            GraphStore::remove_edge_prop_in_txn(write_txn, id, &key)?;
4846                        }
4847                        continue;
4848                    }
4849                    let pv = value_to_storable_property(&entry_value).ok_or_else(|| {
4850                        QueryError::Type(format!(
4851                            "property '{key}' can't be stored -- MarsDB's node/edge properties are \
4852                             limited to null/bool/int/float/string/date/duration/list; a map/node/\
4853                             edge/path value (got {entry_value:?}) isn't storable"
4854                        ))
4855                    })?;
4856                    if let Some(id) = node_id {
4857                        GraphStore::set_node_prop_in_txn(write_txn, id, &key, pv.clone())?;
4858                    }
4859                    if let Some(id) = edge_id {
4860                        GraphStore::set_edge_prop_in_txn(write_txn, id, &key, pv)?;
4861                    }
4862                }
4863            }
4864        }
4865        Ok(())
4866    }
4867}
4868
4869/// `materialize_delete`'s bare-variable fast path -- deletes straight off
4870/// the row's raw `Binding` (just an id), no existence check and no
4871/// property fetch, so re-referencing an already-deleted-this-statement
4872/// entity by identity (a later row of the same multi-row `DELETE`) is a
4873/// silent dedup no-op, not an error. Mirrors `delete_value`'s shape
4874/// (including the path/null/type-error handling) but over `Binding`/
4875/// `PathBinding` (raw ids) instead of `Value`/`PathElem` (fully
4876/// materialized records).
4877/// Deletes edge `id`, first stashing its (immutable, so safe to cache)
4878/// type into `guard` -- see `ExecutionGuard::deleted_edge_types`'s own
4879/// docs for why. The lookup can't fail with a real error here: `id` was
4880/// just read out of a live `Binding::Edge`/`PathBinding::Edge` this same
4881/// transaction, so its record is still there to fetch (deletion hasn't
4882/// happened yet -- that's the very next line).
4883fn record_and_delete_edge(
4884    txn: Txn,
4885    write_txn: &WriteTransaction,
4886    id: EdgeId,
4887    guard: &ExecutionGuard<'_>,
4888) -> Result<(), QueryError> {
4889    if let Some(edge) = GraphStore::get_edge_in_txn(txn, id)? {
4890        guard.record_deleted_edge_type(id, edge.label);
4891    }
4892    GraphStore::delete_edge_in_txn(write_txn, id)?;
4893    Ok(())
4894}
4895
4896fn delete_binding(
4897    txn: Txn,
4898    binding: &Binding,
4899    write_txn: &WriteTransaction,
4900    deleted_edges: &mut HashSet<EdgeId>,
4901    pending_nodes: &mut HashSet<NodeId>,
4902    guard: &ExecutionGuard<'_>,
4903) -> Result<(), QueryError> {
4904    match binding {
4905        Binding::Node(id) => {
4906            pending_nodes.insert(*id);
4907        }
4908        Binding::Edge(id) => {
4909            if deleted_edges.insert(*id) {
4910                record_and_delete_edge(txn, write_txn, *id, guard)?;
4911            }
4912        }
4913        Binding::Path(elems) => {
4914            for elem in elems {
4915                if let PathBinding::Edge(id) = elem {
4916                    if deleted_edges.insert(*id) {
4917                        record_and_delete_edge(txn, write_txn, *id, guard)?;
4918                    }
4919                }
4920            }
4921            for elem in elems {
4922                if let PathBinding::Node(id) = elem {
4923                    pending_nodes.insert(*id);
4924                }
4925            }
4926        }
4927        // A null binding is a real, legal DELETE target -- an `OPTIONAL
4928        // MATCH` that didn't match pads its variables with null, and
4929        // deleting that is a documented no-op, not an error.
4930        Binding::Value(PropertyValue::Null) => {}
4931        Binding::Value(_) | Binding::List(_) | Binding::Map(_) => {
4932            return Err(QueryError::Type(
4933                "DELETE needs a node, relationship, or path, not a scalar/list/map".into(),
4934            ))
4935        }
4936    }
4937    Ok(())
4938}
4939
4940/// Deletes whatever `value` evaluated to -- a node, a relationship, every
4941/// node/edge in a path, or nothing at all for `null` (a documented no-op:
4942/// an `OPTIONAL MATCH` that didn't match pads its variables with null, and
4943/// deleting that is specified as silent, not an error). Anything else (a
4944/// list, a map, a bare scalar, ...) is a real `QueryError::Type` --
4945/// `DELETE`'s target must resolve to a graph element, unlike `SET`'s RHS.
4946/// Edges are deleted immediately; nodes are only collected into
4947/// `pending_nodes` -- `delete_targets` (the only caller) deletes them in
4948/// its own second pass, after every target across every row has had a
4949/// chance to delete its own edges first (see its own docs for why).
4950fn delete_value(
4951    value: &Value,
4952    write_txn: &WriteTransaction,
4953    deleted_edges: &mut HashSet<EdgeId>,
4954    pending_nodes: &mut HashSet<NodeId>,
4955    guard: &ExecutionGuard<'_>,
4956) -> Result<(), QueryError> {
4957    match value {
4958        Value::Node(n) => {
4959            pending_nodes.insert(n.id);
4960        }
4961        Value::Edge(e) => {
4962            if deleted_edges.insert(e.id) {
4963                guard.record_deleted_edge_type(e.id, e.label.clone());
4964                GraphStore::delete_edge_in_txn(write_txn, e.id)?;
4965            }
4966        }
4967        Value::Path(elems) => {
4968            for elem in elems {
4969                if let PathElem::Edge(e) = elem {
4970                    if deleted_edges.insert(e.id) {
4971                        guard.record_deleted_edge_type(e.id, e.label.clone());
4972                        GraphStore::delete_edge_in_txn(write_txn, e.id)?;
4973                    }
4974                }
4975            }
4976            for elem in elems {
4977                if let PathElem::Node(n) = elem {
4978                    pending_nodes.insert(n.id);
4979                }
4980            }
4981        }
4982        Value::Null => {}
4983        other => {
4984            return Err(QueryError::Type(format!(
4985                "DELETE needs a node, relationship, or path, got {other:?}"
4986            )))
4987        }
4988    }
4989    Ok(())
4990}
4991
4992fn apply_remove_item(
4993    write_txn: &WriteTransaction,
4994    row: &BindingRow,
4995    item: &RemoveItem,
4996) -> Result<(), QueryError> {
4997    match item {
4998        RemoveItem::Prop(pa) => {
4999            let binding = row
5000                .get(&pa.var)
5001                .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
5002            match binding {
5003                Binding::Node(id) => {
5004                    GraphStore::remove_node_prop_in_txn(write_txn, *id, &pa.prop)?;
5005                }
5006                Binding::Edge(id) => {
5007                    GraphStore::remove_edge_prop_in_txn(write_txn, *id, &pa.prop)?;
5008                }
5009                // Same null-is-a-no-op rule DELETE already follows (found
5010                // via TCK's Remove1 "Ignore null when removing property"
5011                // scenarios).
5012                Binding::Value(PropertyValue::Null) => {}
5013                Binding::Value(_) | Binding::List(_) | Binding::Map(_) | Binding::Path(_) => {
5014                    return Err(QueryError::UnboundVariable(format!(
5015                        "'{}' is a WITH-projected scalar, not a node/edge — REMOVE needs a graph binding",
5016                        pa.var
5017                    )))
5018                }
5019            }
5020        }
5021        RemoveItem::Labels(var, labels) => {
5022            let binding = row
5023                .get(var)
5024                .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
5025            match binding {
5026                Binding::Node(id) => {
5027                    for label in labels {
5028                        GraphStore::remove_node_label_in_txn(write_txn, *id, label)?;
5029                    }
5030                }
5031                // Same null-is-a-no-op rule as the property arm above
5032                // (found via TCK's Remove2 "Ignore null when removing a
5033                // node label" scenario).
5034                Binding::Value(PropertyValue::Null) => {}
5035                _ => {
5036                    return Err(QueryError::UnboundVariable(format!(
5037                        "'{var}' isn't a node — REMOVE can only remove labels from a node"
5038                    )))
5039                }
5040            }
5041        }
5042    }
5043    Ok(())
5044}
5045
5046/// Whether `tail`'s ultimate RETURN (if it has one at all -- either
5047/// `Tail::Return` itself, or a mutating tail's trailing `ReturnTail`) is a
5048/// `RETURN DISTINCT`. Used by `execute_match`'s LIMIT pre-truncate and
5049/// scan-limit-pushdown shortcuts, both of which must NOT fire for a
5050/// DISTINCT return -- dedup can drop rows, so capping the raw input at
5051/// `limit` before it runs could return fewer than `limit` distinct rows
5052/// even when more exist.
5053fn tail_is_distinct_return(tail: &Option<Tail>) -> bool {
5054    match tail {
5055        Some(Tail::Return(_, distinct)) | Some(Tail::ReturnStar(distinct)) => *distinct,
5056        Some(Tail::Delete(_, ret))
5057        | Some(Tail::DetachDelete(_, ret))
5058        | Some(Tail::Set(_, ret))
5059        | Some(Tail::Remove(_, ret))
5060        | Some(Tail::Create(_, ret)) => ret.as_ref().is_some_and(|rt| rt.distinct),
5061        None => false,
5062    }
5063}
5064
5065/// A statement never mutates anything iff it's a `MATCH ... RETURN` with no
5066/// `DELETE`/`DETACH DELETE`/`SET` tail *and* no `MERGE` clause anywhere in
5067/// it (`MERGE (n) RETURN n` has a `Tail::Return`, but still writes whenever
5068/// it has to create — checking `tail` alone here would be a real bug, not
5069/// just an incomplete check: it would send a MERGE-that-creates through a
5070/// `ReadTransaction`, which has no `.insert`). `Statement::Create` and
5071/// every other `Tail` variant always write. Confirmed by tracing every
5072/// function reachable from pattern/WHERE/WITH evaluation: none of them
5073/// ever call a table-mutating `*_in_txn` method for a `Tail::Return`
5074/// statement with no `MERGE` clause (a label-filtered scan looks up an
5075/// existing label id, it never allocates one — allocation only happens in
5076/// `create_node_in_txn`/`create_edge_in_txn`). `Executor::execute` uses
5077/// this to decide whether to open a `ReadTransaction` (no contention with
5078/// concurrent readers or a concurrent writer) or a `WriteTransaction`.
5079/// Returns whether executing `stmt` can mutate the graph. Public so callers
5080/// which execute generated or otherwise untrusted Cypher can enforce a
5081/// read-only policy using the same classification as the executor.
5082pub fn is_read_only(stmt: &Statement) -> bool {
5083    if let Statement::Union { parts, .. } = stmt {
5084        return parts.iter().all(is_read_only);
5085    }
5086    let Statement::Match {
5087        tail: Some(Tail::Return(_, _)) | Some(Tail::ReturnStar(_)),
5088        clauses,
5089        ..
5090    } = stmt
5091    else {
5092        return false;
5093    };
5094    !clauses.iter().any(|c| {
5095        matches!(
5096            c,
5097            QueryClause::Merge(_)
5098                | QueryClause::Set(_)
5099                | QueryClause::Delete { .. }
5100                | QueryClause::Remove(_)
5101                | QueryClause::Create(_)
5102                // A procedure is opaque to MarsDB -- it might write, so
5103                // any statement calling one is conservatively treated as
5104                // non-read-only too, same reasoning `Statement::
5105                // StandaloneCall` already gets for free (it isn't a
5106                // `Statement::Match` at all, so it never matches this
5107                // function's own read-only pattern above).
5108                | QueryClause::Call(_)
5109        )
5110    })
5111}
5112
5113/// Recovers the real `&WriteTransaction` from a `Txn` for `execute_match`
5114/// tail/clause arms (`DELETE`/`SET`, both the terminal-tail and
5115/// `QueryClause::Set`'s own mid-statement form) that need `.insert`/
5116/// `.remove`, not just `Txn`'s read-only `get`/`iter`. Panics if given
5117/// `Txn::Read` — which can't happen: any of these make `is_read_only`
5118/// return `false`, so `Executor::execute` always opens a
5119/// `WriteTransaction` (and thus `Txn::Write`) before reaching this path.
5120fn require_write_txn(txn: Txn<'_>) -> &WriteTransaction {
5121    let Txn::Write(write_txn) = txn else {
5122        unreachable!(
5123            "materialize_delete/materialize_set/QueryClause::Set only reached via the \
5124             write-dispatch path in Executor::execute — is_read_only(stmt) is false for any \
5125             statement with one of these, so execute always opens a WriteTransaction for them"
5126        )
5127    };
5128    write_txn
5129}
5130
5131fn default_column_name(expr: &ReturnExpr, idx: usize) -> String {
5132    match expr {
5133        ReturnExpr::Var(v) => v.clone(),
5134        ReturnExpr::Prop(pa) => format!("{}.{}", pa.var, pa.prop),
5135        ReturnExpr::Lit(_) => format!("col{idx}"),
5136        ReturnExpr::Call { name, .. } => format!("{name}(...)"),
5137        ReturnExpr::CountStar => "count(*)".to_string(),
5138        ReturnExpr::Case { .. } => format!("case{idx}"),
5139        ReturnExpr::Arith(..) | ReturnExpr::Neg(..) => format!("col{idx}"),
5140        ReturnExpr::ListLit(..)
5141        | ReturnExpr::Index(..)
5142        | ReturnExpr::PropOf(..)
5143        | ReturnExpr::Slice(..)
5144        | ReturnExpr::ListComp { .. }
5145        | ReturnExpr::Quantifier { .. }
5146        | ReturnExpr::MapLit(..)
5147        | ReturnExpr::And(..)
5148        | ReturnExpr::Or(..)
5149        | ReturnExpr::Xor(..)
5150        | ReturnExpr::Not(..)
5151        | ReturnExpr::Compare(..)
5152        | ReturnExpr::IsNull(..)
5153        | ReturnExpr::In(..)
5154        | ReturnExpr::HasLabel(..)
5155        | ReturnExpr::PatternPredicate(..)
5156        | ReturnExpr::PatternComprehension { .. }
5157        | ReturnExpr::ExistsPattern { .. }
5158        | ReturnExpr::ExistsSubquery(_) => format!("col{idx}"),
5159    }
5160}
5161
5162/// The name a `WITH`/`RETURN` item is known by afterward — its alias, or
5163/// a name derived from the expression (its bare var name, `col{i}`, etc).
5164/// `pub(crate)` so `explain.rs` can compute the same post-`WITH`
5165/// `carried_vars` set EXPLAIN needs without executing any rows.
5166pub(crate) fn with_item_output_name((i, item): (usize, &ReturnItem)) -> String {
5167    item.alias
5168        .clone()
5169        .unwrap_or_else(|| default_column_name(&item.expr, i))
5170}
5171
5172/// True iff `expr` contains an aggregate call anywhere inside it, at any
5173/// depth — used to reject an aggregate nested inside another aggregate's
5174/// argument, or inside a non-aggregate expression's `CASE`/`Call`
5175/// arguments (an aggregate must be a return item's *entire* top-level
5176/// expression — see `validate_return_items`).
5177/// Collects every aggregate-bearing subexpression in `expr` (a `CountStar`
5178/// or an aggregate-named `Call`), in a fixed pre-order -- the same
5179/// traversal `contains_aggregate` uses, just gathering references instead
5180/// of stopping at the first `true`. Doesn't recurse *into* a found node's
5181/// own arguments (an aggregate's argument is folded per-row as a whole,
5182/// not decomposed further -- see `resolve_grouped_rows`). The resulting
5183/// order is what makes a composed item's per-row folding
5184/// (`resolve_grouped_rows`) and its per-group finishing
5185/// (`Executor::rewrite_composed_item`) agree on which accumulator is
5186/// which, without needing to name or otherwise identify individual
5187/// aggregate calls within one item's expression tree.
5188fn collect_agg_nodes<'a>(expr: &'a ReturnExpr, out: &mut Vec<&'a ReturnExpr>) {
5189    match expr {
5190        ReturnExpr::CountStar => out.push(expr),
5191        ReturnExpr::Call { name, args, .. } => {
5192            if is_aggregate_name(name) {
5193                out.push(expr);
5194            } else {
5195                for arg in args {
5196                    collect_agg_nodes(arg, out);
5197                }
5198            }
5199        }
5200        ReturnExpr::Case { test, whens, else_ } => {
5201            if let Some(t) = test.as_deref() {
5202                collect_agg_nodes(t, out);
5203            }
5204            for (w, t) in whens {
5205                collect_agg_nodes(w, out);
5206                collect_agg_nodes(t, out);
5207            }
5208            if let Some(e) = else_.as_deref() {
5209                collect_agg_nodes(e, out);
5210            }
5211        }
5212        ReturnExpr::Arith(l, _, r) => {
5213            collect_agg_nodes(l, out);
5214            collect_agg_nodes(r, out);
5215        }
5216        ReturnExpr::Neg(e) => collect_agg_nodes(e, out),
5217        ReturnExpr::ListLit(items) => {
5218            for item in items {
5219                collect_agg_nodes(item, out);
5220            }
5221        }
5222        ReturnExpr::Index(base, index) => {
5223            collect_agg_nodes(base, out);
5224            collect_agg_nodes(index, out);
5225        }
5226        ReturnExpr::PropOf(base, _) => collect_agg_nodes(base, out),
5227        ReturnExpr::Slice(base, start, end) => {
5228            collect_agg_nodes(base, out);
5229            if let Some(s) = start.as_deref() {
5230                collect_agg_nodes(s, out);
5231            }
5232            if let Some(e) = end.as_deref() {
5233                collect_agg_nodes(e, out);
5234            }
5235        }
5236        // Same `where_clause`-not-checked scope limitation as
5237        // `contains_aggregate`'s matching arm.
5238        ReturnExpr::ListComp {
5239            source, project, ..
5240        } => {
5241            collect_agg_nodes(source, out);
5242            if let Some(p) = project.as_deref() {
5243                collect_agg_nodes(p, out);
5244            }
5245        }
5246        ReturnExpr::Quantifier { source, .. } => collect_agg_nodes(source, out),
5247        ReturnExpr::MapLit(entries) => {
5248            for (_, v) in entries {
5249                collect_agg_nodes(v, out);
5250            }
5251        }
5252        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
5253            collect_agg_nodes(l, out);
5254            collect_agg_nodes(r, out);
5255        }
5256        ReturnExpr::Not(e) => collect_agg_nodes(e, out),
5257        ReturnExpr::Compare(l, _, r) => {
5258            collect_agg_nodes(l, out);
5259            collect_agg_nodes(r, out);
5260        }
5261        ReturnExpr::IsNull(e) => collect_agg_nodes(e, out),
5262        ReturnExpr::In(needle, haystack) => {
5263            collect_agg_nodes(needle, out);
5264            collect_agg_nodes(haystack, out);
5265        }
5266        ReturnExpr::Var(_)
5267        | ReturnExpr::Prop(_)
5268        | ReturnExpr::Lit(_)
5269        | ReturnExpr::HasLabel(..)
5270        | ReturnExpr::PatternPredicate(..)
5271        | ReturnExpr::PatternComprehension { .. }
5272        | ReturnExpr::ExistsPattern { .. }
5273        | ReturnExpr::ExistsSubquery(_) => {}
5274    }
5275}
5276
5277pub(crate) fn contains_aggregate(expr: &ReturnExpr) -> bool {
5278    match expr {
5279        ReturnExpr::CountStar => true,
5280        ReturnExpr::Call { name, args, .. } => {
5281            is_aggregate_name(name) || args.iter().any(contains_aggregate)
5282        }
5283        ReturnExpr::Case { test, whens, else_ } => {
5284            test.as_deref().is_some_and(contains_aggregate)
5285                || whens
5286                    .iter()
5287                    .any(|(w, t)| contains_aggregate(w) || contains_aggregate(t))
5288                || else_.as_deref().is_some_and(contains_aggregate)
5289        }
5290        ReturnExpr::Arith(l, _, r) => contains_aggregate(l) || contains_aggregate(r),
5291        ReturnExpr::Neg(e) => contains_aggregate(e),
5292        ReturnExpr::ListLit(items) => items.iter().any(contains_aggregate),
5293        ReturnExpr::Index(base, index) => contains_aggregate(base) || contains_aggregate(index),
5294        ReturnExpr::PropOf(base, _) => contains_aggregate(base),
5295        ReturnExpr::Slice(base, start, end) => {
5296            contains_aggregate(base)
5297                || start.as_deref().is_some_and(contains_aggregate)
5298                || end.as_deref().is_some_and(contains_aggregate)
5299        }
5300        // `where_clause` isn't checked -- same scope limitation as
5301        // `UnwindClause`'s own filter, which never routes through this
5302        // check either; the source/project halves are the ones a real
5303        // TCK scenario nests an aggregate in (`size([x IN collect(r) ...])`).
5304        ReturnExpr::ListComp {
5305            source, project, ..
5306        } => contains_aggregate(source) || project.as_deref().is_some_and(contains_aggregate),
5307        ReturnExpr::Quantifier { source, .. } => contains_aggregate(source),
5308        ReturnExpr::MapLit(entries) => entries.iter().any(|(_, v)| contains_aggregate(v)),
5309        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
5310            contains_aggregate(l) || contains_aggregate(r)
5311        }
5312        ReturnExpr::Not(e) => contains_aggregate(e),
5313        ReturnExpr::Compare(l, _, r) => contains_aggregate(l) || contains_aggregate(r),
5314        ReturnExpr::IsNull(e) => contains_aggregate(e),
5315        ReturnExpr::In(needle, haystack) => {
5316            contains_aggregate(needle) || contains_aggregate(haystack)
5317        }
5318        ReturnExpr::Var(_)
5319        | ReturnExpr::Prop(_)
5320        | ReturnExpr::Lit(_)
5321        | ReturnExpr::HasLabel(..)
5322        | ReturnExpr::PatternPredicate(..)
5323        // A pattern comprehension's projection runs against its own
5324        // per-match row, not the outer query's group -- an aggregate
5325        // inside it wouldn't mean "aggregate across the outer group,"
5326        // it'd need its own separate grouping concept this codebase
5327        // doesn't have, so (like `PatternPredicate`) it's opaque here
5328        // rather than searched into.
5329        | ReturnExpr::PatternComprehension { .. }
5330        | ReturnExpr::ExistsPattern { .. }
5331        | ReturnExpr::ExistsSubquery(_) => false,
5332    }
5333}
5334
5335/// True iff any item's top-level expression is an aggregate call —
5336/// `materialize_with`/`materialize_return` dispatch to the grouping path
5337/// iff this is true, otherwise the existing row-at-a-time path runs
5338/// completely unchanged (zero perf/behavior impact on non-aggregating
5339/// queries).
5340pub(crate) fn has_aggregate(items: &[ReturnItem]) -> bool {
5341    // `contains_aggregate`, not a narrower "is the item's whole top-level
5342    // expression itself an aggregate call" check -- an aggregate nested
5343    // inside a wrapping expression (`1 + count(x)`, real Cypher composition
5344    // -- see `resolve_grouped_rows`) still needs to route to the grouping
5345    // path, both to actually compute it and so `validate_return_items` gets
5346    // a chance to reject an invalid composition with a clear error. A
5347    // narrower top-level-only check here would let such a query silently
5348    // take the ordinary per-row path instead (iterating `rows` directly,
5349    // which is empty for an empty MATCH), producing the wrong row count
5350    // instead of the right (or correctly rejected) one.
5351    items.iter().any(|item| contains_aggregate(&item.expr))
5352}
5353
5354/// True iff `expr` contains a call to `rand()` anywhere inside it, at any
5355/// depth -- same traversal shape as `contains_aggregate`, used only to
5356/// reject `rand()` as (part of) an aggregate's own argument (see
5357/// `validate_return_items`); `rand()` elsewhere in a query is completely
5358/// fine.
5359fn contains_rand_call(expr: &ReturnExpr) -> bool {
5360    match expr {
5361        ReturnExpr::Call { name, args, .. } => {
5362            name.eq_ignore_ascii_case("rand") || args.iter().any(contains_rand_call)
5363        }
5364        ReturnExpr::Case { test, whens, else_ } => {
5365            test.as_deref().is_some_and(contains_rand_call)
5366                || whens
5367                    .iter()
5368                    .any(|(w, t)| contains_rand_call(w) || contains_rand_call(t))
5369                || else_.as_deref().is_some_and(contains_rand_call)
5370        }
5371        ReturnExpr::Arith(l, _, r) => contains_rand_call(l) || contains_rand_call(r),
5372        ReturnExpr::Neg(e) => contains_rand_call(e),
5373        ReturnExpr::ListLit(items) => items.iter().any(contains_rand_call),
5374        ReturnExpr::Index(base, index) => contains_rand_call(base) || contains_rand_call(index),
5375        ReturnExpr::PropOf(base, _) => contains_rand_call(base),
5376        ReturnExpr::Slice(base, start, end) => {
5377            contains_rand_call(base)
5378                || start.as_deref().is_some_and(contains_rand_call)
5379                || end.as_deref().is_some_and(contains_rand_call)
5380        }
5381        ReturnExpr::ListComp {
5382            source, project, ..
5383        } => contains_rand_call(source) || project.as_deref().is_some_and(contains_rand_call),
5384        ReturnExpr::Quantifier { source, .. } => contains_rand_call(source),
5385        ReturnExpr::MapLit(entries) => entries.iter().any(|(_, v)| contains_rand_call(v)),
5386        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
5387            contains_rand_call(l) || contains_rand_call(r)
5388        }
5389        ReturnExpr::Not(e) => contains_rand_call(e),
5390        ReturnExpr::Compare(l, _, r) => contains_rand_call(l) || contains_rand_call(r),
5391        ReturnExpr::IsNull(e) => contains_rand_call(e),
5392        ReturnExpr::In(needle, haystack) => {
5393            contains_rand_call(needle) || contains_rand_call(haystack)
5394        }
5395        ReturnExpr::CountStar
5396        | ReturnExpr::Var(_)
5397        | ReturnExpr::Prop(_)
5398        | ReturnExpr::Lit(_)
5399        | ReturnExpr::HasLabel(..)
5400        | ReturnExpr::PatternPredicate(..)
5401        // Same opaque treatment as `contains_aggregate`'s own arm above --
5402        // a pattern comprehension's projection is checked once it's
5403        // actually evaluated per match, not searched into ahead of time.
5404        | ReturnExpr::PatternComprehension { .. }
5405        | ReturnExpr::ExistsPattern { .. }
5406        | ReturnExpr::ExistsSubquery(_) => false,
5407    }
5408}
5409
5410/// `RETURN *`/`RETURN DISTINCT *` resolved into the equivalent concrete
5411/// item list -- one bare-`Var` item per currently-bound name, sorted
5412/// alphabetically (real Cypher's own `RETURN *` column order, confirmed
5413/// against the TCK's own multi-variable scenarios, not introduction
5414/// order). Shared by `semantic.rs` (`scope.keys()`) and this file's own
5415/// `execute_match` (`carried_vars`) -- each already has its own accurate
5416/// bound-name set on hand at the point `Tail::ReturnStar` is reached, so
5417/// resolving it there (rather than via a separate whole-AST-mutation
5418/// pass before execution) needs no `&mut Statement` ripple through
5419/// `Executor::execute`'s public signature. Real Cypher's own
5420/// `NoVariablesInScope` compile-time error when nothing is bound at all
5421/// (TCK's Return7 `[2]`, `MATCH () RETURN *`). `WITH *` doesn't share this
5422/// restriction -- an empty `WITH *` is a legal, if useless, "carry forward
5423/// nothing" no-op (TCK's Create3 `[2]`/`[3]`: `MATCH () CREATE () WITH *
5424/// CREATE ()`, every token anonymous) -- see `with_star_items` below.
5425pub(crate) fn return_star_items(
5426    names: impl Iterator<Item = String>,
5427) -> Result<Vec<ReturnItem>, QueryError> {
5428    let names: Vec<String> = names.collect();
5429    if names.is_empty() {
5430        return Err(QueryError::Semantic(
5431            "RETURN * needs at least one variable in scope".into(),
5432        ));
5433    }
5434    Ok(star_items(names))
5435}
5436
5437/// `WITH *`'s own version of `return_star_items` -- same alphabetical
5438/// `Var`-per-name expansion, but tolerates an empty name set instead of
5439/// erroring (see that function's docs for why the two differ).
5440pub(crate) fn with_star_items(names: impl Iterator<Item = String>) -> Vec<ReturnItem> {
5441    star_items(names.collect())
5442}
5443
5444fn star_items(mut names: Vec<String>) -> Vec<ReturnItem> {
5445    names.sort();
5446    names
5447        .into_iter()
5448        .map(|name| ReturnItem {
5449            expr: ReturnExpr::Var(name),
5450            alias: None,
5451        })
5452        .collect()
5453}
5454
5455/// Validates a RETURN/WITH item list before any row is processed. Two
5456/// checks, both real Cypher compile-time errors:
5457///
5458/// - Every aggregate call (found anywhere -- not just a return item's
5459///   whole top-level expression, since `RETURN a, count(a) + 3`-style
5460///   composition is real Cypher, TCK's Return6 `[2]` etc) has the right
5461///   number of arguments, doesn't nest another aggregate inside its own
5462///   argument (`NestedAggregation`), and isn't given a non-deterministic
5463///   argument like `rand()` (`NonConstantExpression`).
5464/// - Once *any* item aggregates, every other item's own non-aggregate
5465///   leaf (a bare `Var`/`Prop` used outside any aggregate call) must
5466///   match some *other* item's whole top-level expression verbatim
5467///   (`AmbiguousAggregationExpression`, TCK's Return6 `[20]`/`[21]`) --
5468///   real Cypher's rule that a value used alongside an aggregate must
5469///   itself be an explicit grouping key, not just something that happens
5470///   to be in scope. A literal/param is always fine (same value on every
5471///   row, nothing to group by). This is checked by recursing into every
5472///   item whose expression contains an aggregate anywhere, stopping at
5473///   each aggregate-bearing subexpression itself (its own argument
5474///   doesn't need to be grouping-key-safe -- it's folded per row).
5475pub(crate) fn validate_return_items(items: &[ReturnItem]) -> Result<(), QueryError> {
5476    for item in items {
5477        if contains_aggregate(&item.expr) {
5478            validate_composed_expr(&item.expr, items)?;
5479        }
5480    }
5481    Ok(())
5482}
5483
5484/// Whether `expr` (a leaf found inside some *other* composed expression)
5485/// refers to `item` -- either structurally (`item.expr == *expr`) or, for
5486/// a bare `Var`, by `item`'s own output *alias* (`RETURN me.age AS age
5487/// ... ORDER BY age + count(...)`, TCK's ReturnOrderBy6 `[2]`: `age`
5488/// alone doesn't structurally equal `me.age`, but it's still exactly
5489/// item `age`'s value). Shared by `validate_composed_expr`'s compile-time
5490/// check and `Executor::rewrite_composed_item`'s matching runtime lookup
5491/// -- both need to agree on what counts as "the same grouping key,"
5492/// including this by-alias case, or one would accept what the other
5493/// can't actually evaluate.
5494pub(crate) fn item_matches_leaf(expr: &ReturnExpr, index: usize, item: &ReturnItem) -> bool {
5495    item.expr == *expr
5496        || matches!(expr, ReturnExpr::Var(name) if *name == with_item_output_name((index, item)))
5497}
5498
5499pub(crate) fn validate_composed_expr(
5500    expr: &ReturnExpr,
5501    items: &[ReturnItem],
5502) -> Result<(), QueryError> {
5503    if matches!(expr, ReturnExpr::CountStar) {
5504        return Ok(());
5505    }
5506    if let ReturnExpr::Call { name, args, .. } = expr {
5507        if is_aggregate_name(name) {
5508            // `percentileCont`/`percentileDisc` take a second argument
5509            // (the percentile) alongside the value being aggregated —
5510            // every other aggregate takes exactly one.
5511            let expected_args = if is_percentile_name(name) { 2 } else { 1 };
5512            if args.len() != expected_args {
5513                return Err(QueryError::Semantic(if expected_args == 2 {
5514                    format!("{name}() takes exactly two arguments (the value, then the percentile)")
5515                } else {
5516                    format!(
5517                        "{name}() takes exactly one argument (use count(*) for a row count with no argument)"
5518                    )
5519                }));
5520            }
5521            for arg in args {
5522                if contains_aggregate(arg) {
5523                    return Err(QueryError::Semantic(format!(
5524                        "aggregate function '{name}' can't take another aggregate as an argument"
5525                    )));
5526                }
5527                // `count(rand())` etc -- an aggregate's argument must be
5528                // deterministic per row for grouping/re-execution to have
5529                // well-defined semantics, which `rand()` (a fresh value on
5530                // every call, see its own docs) fundamentally breaks. Real
5531                // Cypher rejects this at compile time (TCK's Return6
5532                // [15], `NonConstantExpression`), not just "whatever value
5533                // it happens to produce."
5534                if contains_rand_call(arg) {
5535                    return Err(QueryError::Semantic(format!(
5536                        "aggregate function '{name}' can't take a non-deterministic expression \
5537                         (e.g. rand()) as an argument"
5538                    )));
5539                }
5540            }
5541            return Ok(());
5542        }
5543    }
5544    if matches!(expr, ReturnExpr::Var(_) | ReturnExpr::Prop(_)) {
5545        let is_grouping_key = items
5546            .iter()
5547            .enumerate()
5548            .any(|(i, it)| item_matches_leaf(expr, i, it) && !contains_aggregate(&it.expr));
5549        return if is_grouping_key {
5550            Ok(())
5551        } else {
5552            Err(QueryError::Semantic(format!(
5553                "{expr:?} is used alongside an aggregate function but isn't itself one of this \
5554                 RETURN/WITH's own items -- once any item aggregates, every other value used \
5555                 with it must be listed as its own explicit grouping key"
5556            )))
5557        };
5558    }
5559    // `Lit`/`HasLabel`/`PatternPredicate`/`PatternComprehension` need no
5560    // check here: a literal is the same value on every row (nothing to
5561    // group by), and the other three are opaque leaves for this same
5562    // reason `contains_aggregate`/`collect_agg_nodes` treat them that way
5563    // (see their own docs) -- not reachable with real content to check
5564    // since none can themselves contain an aggregate.
5565    match expr {
5566        ReturnExpr::Case { test, whens, else_ } => {
5567            if let Some(t) = test.as_deref() {
5568                validate_composed_expr(t, items)?;
5569            }
5570            for (w, t) in whens {
5571                validate_composed_expr(w, items)?;
5572                validate_composed_expr(t, items)?;
5573            }
5574            if let Some(e) = else_.as_deref() {
5575                validate_composed_expr(e, items)?;
5576            }
5577        }
5578        ReturnExpr::Call { args, .. } => {
5579            for arg in args {
5580                validate_composed_expr(arg, items)?;
5581            }
5582        }
5583        ReturnExpr::Arith(l, _, r) => {
5584            validate_composed_expr(l, items)?;
5585            validate_composed_expr(r, items)?;
5586        }
5587        ReturnExpr::Neg(e) => validate_composed_expr(e, items)?,
5588        ReturnExpr::ListLit(list_items) => {
5589            for item in list_items {
5590                validate_composed_expr(item, items)?;
5591            }
5592        }
5593        ReturnExpr::Index(base, index) => {
5594            validate_composed_expr(base, items)?;
5595            validate_composed_expr(index, items)?;
5596        }
5597        ReturnExpr::PropOf(base, _) => validate_composed_expr(base, items)?,
5598        ReturnExpr::Slice(base, start, end) => {
5599            validate_composed_expr(base, items)?;
5600            if let Some(s) = start.as_deref() {
5601                validate_composed_expr(s, items)?;
5602            }
5603            if let Some(e) = end.as_deref() {
5604                validate_composed_expr(e, items)?;
5605            }
5606        }
5607        // `source` may itself be a (possibly composed) aggregate --
5608        // `[x IN collect(p) | head(nodes(x))]` aggregates once per group
5609        // to build the list, then the comprehension iterates its result
5610        // normally (TCK's List12 [4]/[5], real and required) -- recursed
5611        // into below via the generic `Call`/`Arith`/etc. machinery, same
5612        // as any other composed leaf. `project`, in contrast, runs once
5613        // *per element* of that already-built list -- an aggregate
5614        // there has no defined semantics at all (real Cypher flatly
5615        // rejects it, TCK's List12 [7], "Fail when using aggregation in
5616        // list comprehension") and `resolve_grouped_rows` has no
5617        // "fold once per group, then run per element" fold shape for it
5618        // anyway, so it's checked directly here rather than falling
5619        // through to the generic recursion below, which would otherwise
5620        // validate (and `rewrite_composed_item` would then evaluate) a
5621        // nested aggregate as if it were an ordinary composed leaf.
5622        ReturnExpr::ListComp {
5623            source,
5624            project,
5625            where_clause,
5626            ..
5627        } => {
5628            if project.as_deref().is_some_and(contains_aggregate) {
5629                return Err(QueryError::Semantic(
5630                    "an aggregate function can't be used inside a list comprehension's projection"
5631                        .into(),
5632                ));
5633            }
5634            validate_composed_expr(source, items)?;
5635            // `where_clause` isn't checked -- same scope limitation as
5636            // `contains_aggregate`'s own matching arm.
5637            let _ = where_clause;
5638        }
5639        ReturnExpr::Quantifier { source, .. } => validate_composed_expr(source, items)?,
5640        ReturnExpr::MapLit(entries) => {
5641            for (_, v) in entries {
5642                validate_composed_expr(v, items)?;
5643            }
5644        }
5645        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
5646            validate_composed_expr(l, items)?;
5647            validate_composed_expr(r, items)?;
5648        }
5649        ReturnExpr::Not(e) => validate_composed_expr(e, items)?,
5650        ReturnExpr::Compare(l, _, r) => {
5651            validate_composed_expr(l, items)?;
5652            validate_composed_expr(r, items)?;
5653        }
5654        ReturnExpr::IsNull(e) => validate_composed_expr(e, items)?,
5655        ReturnExpr::In(needle, haystack) => {
5656            validate_composed_expr(needle, items)?;
5657            validate_composed_expr(haystack, items)?;
5658        }
5659        ReturnExpr::CountStar
5660        | ReturnExpr::Var(_)
5661        | ReturnExpr::Prop(_)
5662        | ReturnExpr::Lit(_)
5663        | ReturnExpr::HasLabel(..)
5664        | ReturnExpr::PatternPredicate(..)
5665        | ReturnExpr::PatternComprehension { .. }
5666        | ReturnExpr::ExistsPattern { .. }
5667        | ReturnExpr::ExistsSubquery(_) => {}
5668    }
5669    Ok(())
5670}
5671
5672/// Same rules as `validate_composed_expr` (reused directly, first), plus
5673/// one more real Cypher only enforces for an ORDER BY key specifically,
5674/// not for a RETURN/WITH item's own composed expression: every
5675/// aggregate-bearing subexpression found anywhere in it must itself
5676/// verbatim/alias-match some existing RETURN/WITH item (TCK's
5677/// WithOrderBy4 `[14]`, "Fail on sorting by a non-projected aggregation
5678/// on an expression" -- `ORDER BY sum(x)` when the WITH only computes
5679/// `min(x)`, a *different* aggregate over the same argument, is a real
5680/// compile-time error, not "just fold it separately"). A RETURN/WITH
5681/// item's own composed expression has no such restriction -- `RETURN a,
5682/// count(a) + sum(b)` folds both `count(a)` and `sum(b)` fresh as part of
5683/// evaluating that one item, with nothing else either needs to match.
5684pub(crate) fn validate_order_by_composed_expr(
5685    expr: &ReturnExpr,
5686    items: &[ReturnItem],
5687) -> Result<(), QueryError> {
5688    validate_composed_expr(expr, items)?;
5689    let mut agg_nodes = Vec::new();
5690    collect_agg_nodes(expr, &mut agg_nodes);
5691    for node in agg_nodes {
5692        let matches_item = items
5693            .iter()
5694            .enumerate()
5695            .any(|(i, it)| item_matches_leaf(node, i, it));
5696        if !matches_item {
5697            return Err(QueryError::Semantic(
5698                "ORDER BY aggregate does not match any RETURN/WITH item".into(),
5699            ));
5700        }
5701    }
5702    Ok(())
5703}
5704
5705/// Grouping-key hashing — deliberately at the `Binding` level (`NodeId`/
5706/// `EdgeId`/`PropertyValue`), not `Value`: cheaper (no `GraphStore` fetch
5707/// just to compute) and the correct semantics (two `Binding::Node`s are
5708/// the same group iff the same node **identity**, not equal-by-struct-
5709/// contents). `Binding::List`'s elements are `Value`s already, so those
5710/// delegate to `value_hash_key` directly.
5711fn binding_hash_key(b: &Binding) -> Result<HashKey, QueryError> {
5712    Ok(match b {
5713        Binding::Node(id) => HashKey::Node(*id),
5714        Binding::Edge(id) => HashKey::Edge(*id),
5715        Binding::Value(pv) => property_value_hash_key(pv),
5716        Binding::List(items) => HashKey::List(
5717            items
5718                .iter()
5719                .map(value_hash_key)
5720                .collect::<Result<Vec<_>, _>>()?,
5721        ),
5722        // A path's identity is its exact node/edge sequence, in order --
5723        // same graph-identity-by-id convention as the `Node`/`Edge` arms
5724        // above, just walked element-by-element (found via TCK's
5725        // Pattern2 [8]: `WITH [p = (n)-->() | p] AS ps, count(b) AS c`
5726        // makes `ps` -- a list of paths -- an implicit GROUP BY key,
5727        // real Cypher's own rule that every non-aggregate WITH/RETURN
5728        // item groups by).
5729        Binding::Path(elems) => HashKey::List(
5730            elems
5731                .iter()
5732                .map(|e| match e {
5733                    PathBinding::Node(id) => HashKey::Node(*id),
5734                    PathBinding::Edge(id) => HashKey::Edge(*id),
5735                })
5736                .collect(),
5737        ),
5738        // Same canonical-sorted-entries encoding as `value_hash_key`'s
5739        // matching `Value::Map` arm (a `BTreeMap` already iterates in
5740        // sorted key order).
5741        Binding::Map(m) => HashKey::List(
5742            m.iter()
5743                .map(|(k, v)| -> Result<HashKey, QueryError> {
5744                    Ok(HashKey::List(vec![
5745                        HashKey::Str(k.clone()),
5746                        value_hash_key(v)?,
5747                    ]))
5748                })
5749                .collect::<Result<Vec<_>, _>>()?,
5750        ),
5751    })
5752}
5753
5754/// Projects one of `ProcedureProvider::call`'s raw output rows (positional,
5755/// `sig.outputs.len()` values in that order) down to whatever `yield_items`
5756/// actually asked for -- `YIELD *` keeps every output under its own name;
5757/// an explicit item list picks out just those (by the procedure's own
5758/// declared name, not any rename yet) and pairs each with its `AS` alias
5759/// if it had one, same output order the `YIELD` itself was written in
5760/// (TCK's Call5 `[3]`: order is irrelevant to the *result*, but this still
5761/// preserves whatever order was written, which `materialize_return`-style
5762/// column ordering downstream expects to already be correct).
5763fn project_call_row(
5764    sig: &ProcedureSignature,
5765    proc_row: &[Value],
5766    yield_items: &CallYield,
5767) -> Result<Vec<Value>, QueryError> {
5768    match yield_items {
5769        CallYield::Star => Ok(proc_row.to_vec()),
5770        CallYield::Items(items, _) => items
5771            .iter()
5772            .map(|(name, _)| {
5773                let idx = sig.outputs.iter().position(|o| o == name).ok_or_else(|| {
5774                    QueryError::Semantic(format!(
5775                        "'{name}' isn't a declared output of this procedure"
5776                    ))
5777                })?;
5778                Ok(proc_row[idx].clone())
5779            })
5780            .collect(),
5781    }
5782}
5783
5784/// Coarse compile-time-shaped argument-type check (TCK's Call2
5785/// `[5]`/`[6]`: passing a `BOOLEAN` where `INTEGER` is declared must
5786/// error, even against an empty mock table that would otherwise just
5787/// silently return zero rows). `Value::Null` always matches regardless of
5788/// declared type -- every signature this codebase's own callers declare
5789/// is nullable (`INTEGER?` etc, TCK's Call4), and there's no dedicated
5790/// non-null marker to check against anyway. An unrecognized type name is
5791/// tolerated (accepts anything) rather than rejected -- this is a coarse
5792/// sanity check for the handful of type names TCK's own procedures
5793/// actually declare (`INTEGER`/`FLOAT`/`NUMBER`/`STRING`/`BOOLEAN`), not a
5794/// full type system.
5795fn value_matches_declared_type(value: &Value, declared: &str) -> bool {
5796    if matches!(value, Value::Null) {
5797        return true;
5798    }
5799    let is_int = matches!(
5800        value,
5801        Value::Literal(Literal::Int(_)) | Value::Property(PropertyValue::Int(_))
5802    );
5803    let is_float = matches!(
5804        value,
5805        Value::Literal(Literal::Float(_)) | Value::Property(PropertyValue::Float(_))
5806    );
5807    match declared.trim_end_matches('?').to_ascii_uppercase().as_str() {
5808        "INTEGER" => is_int,
5809        "FLOAT" | "NUMBER" => is_int || is_float,
5810        "STRING" => matches!(
5811            value,
5812            Value::Literal(Literal::String(_)) | Value::Property(PropertyValue::String(_))
5813        ),
5814        "BOOLEAN" => matches!(
5815            value,
5816            Value::Literal(Literal::Bool(_)) | Value::Property(PropertyValue::Bool(_))
5817        ),
5818        _ => true,
5819    }
5820}
5821
5822/// Converts a finished `AggAcc::finish()` result to the `Binding` it's
5823/// carried as through a `WITH` boundary — `collect()`'s `Value::List`
5824/// needs `Binding::List`, not `Binding::Value(PropertyValue::List(_))`:
5825/// `Binding::List` carries full `Value` elements (a `Node`/`Edge`'s real
5826/// id, restorable graph identity), while `PropertyValue::List` is the
5827/// flatter, storage-format shape (scalar elements only) -- collapsing a
5828/// `collect()` of nodes down to that would lose the ability to keep
5829/// traversing from them after the `WITH`. Everything else collapses to
5830/// `Binding::Value` same as any other computed WITH item.
5831fn value_to_binding(v: Value) -> Binding {
5832    match v {
5833        Value::List(items) => Binding::List(items),
5834        Value::Map(m) => Binding::Map(m),
5835        other => Binding::Value(value_to_property_value(&other)),
5836    }
5837}
5838
5839/// `UNWIND`'s counterpart to `value_to_binding` — restores graph identity
5840/// from a `collect()`'d element instead of collapsing it. `Value::Node`/
5841/// `Edge` carry their full `id`, so this isn't lossy the way carrying only
5842/// a display value would be: a `MATCH` after the `UNWIND` can keep
5843/// traversing from the restored `Binding::Node`/`Edge`, exactly as if it
5844/// had been bound by a fresh scan/expand. See `Binding::List`'s docs,
5845/// which anticipated this exact restoration.
5846fn value_to_binding_restore(v: &Value) -> Binding {
5847    match v {
5848        Value::Node(n) => Binding::Node(n.id),
5849        Value::Edge(e) => Binding::Edge(e.id),
5850        Value::Property(pv) => Binding::Value(pv.clone()),
5851        Value::Literal(lit) => Binding::Value(literal_to_value(lit)),
5852        Value::List(items) => Binding::List(items.clone()),
5853        Value::Map(m) => Binding::Map(m.clone()),
5854        Value::Path(elems) => Binding::Path(elems.iter().map(path_elem_to_binding).collect()),
5855        Value::Null => Binding::Value(PropertyValue::Null),
5856    }
5857}
5858
5859fn path_elem_to_binding(elem: &PathElem) -> PathBinding {
5860    match elem {
5861        PathElem::Node(n) => PathBinding::Node(n.id),
5862        PathElem::Edge(e) => PathBinding::Edge(e.id),
5863    }
5864}
5865
5866/// When a path is being captured, every hop's rel/node needs a trackable
5867/// binding even if the user left it anonymous — `Expand` only inserts a
5868/// `rel_var` into the row `if let Some(rv) = rel_var`, silently dropping
5869/// anonymous rels, which is fine for ordinary matching but loses exactly
5870/// the information path assembly needs. Returns a clone of `pattern` with
5871/// every position named (synthesizing `__path_elemN` for anything
5872/// anonymous), plus the set of names that were synthesized so
5873/// `execute_match` can strip them from the row again after `assemble_path`
5874/// runs — they were never something the user could reference. Only this
5875/// renamed clone is used for plan-building/OPTIONAL-MATCH null-padding
5876/// bookkeeping *within this one clause*; `carried_vars` (what's exposed to
5877/// later clauses) is still computed from the original `part.pattern`
5878/// elsewhere, so synthesized names never leak past this function's caller.
5879fn name_pattern_for_path(pattern: &Pattern) -> (Pattern, HashSet<String>) {
5880    fn fresh(counter: &mut usize, synthesized: &mut HashSet<String>) -> String {
5881        *counter += 1;
5882        let name = format!("__path_elem{counter}");
5883        synthesized.insert(name.clone());
5884        name
5885    }
5886    let mut counter = 0usize;
5887    let mut synthesized = HashSet::new();
5888    let mut start = pattern.start.clone();
5889    if start.var.is_none() {
5890        start.var = Some(fresh(&mut counter, &mut synthesized));
5891    }
5892    let hops = pattern
5893        .hops
5894        .iter()
5895        .map(|(rel, node)| {
5896            let mut rel = rel.clone();
5897            if rel.hop_range.is_some() {
5898                // A variable-length hop's own internally-traversed edges
5899                // are exposed via a fresh synthesized binding name (same
5900                // `fresh()` mechanism as every other anonymous token
5901                // here, so multiple variable-length hops in one pattern
5902                // each get their own, no collision -- TCK's Match6
5903                // `[17]`), read by `planner::build_match_plan` (its
5904                // `VarExpand`'s `path_segment_var`) and `assemble_path`.
5905                // The user's own real rel-list variable, if this hop had
5906                // one (`p = (a)-[r*1..3]->(b)`, TCK's Match9 `[9]`), is
5907                // preserved separately in `rel_list_var` rather than lost
5908                // to this overwrite -- `var` itself is always this hop's
5909                // internal path-segment bookkeeping name from here on.
5910                rel.rel_list_var = rel.var.take();
5911                rel.var = Some(fresh(&mut counter, &mut synthesized));
5912                rel.capture_path_segment = true;
5913            } else if rel.var.is_none() {
5914                rel.var = Some(fresh(&mut counter, &mut synthesized));
5915            }
5916            let mut node = node.clone();
5917            if node.var.is_none() {
5918                node.var = Some(fresh(&mut counter, &mut synthesized));
5919            }
5920            (rel, node)
5921        })
5922        .collect();
5923    (Pattern { start, hops }, synthesized)
5924}
5925
5926/// Assembles a `Binding::Path` from `pattern`'s (fully-named, via
5927/// `name_pattern_for_path`) start/hop variables, in pattern order. Falls
5928/// back to `Binding::Value(Null)` — never errors — if any position isn't a
5929/// real node/edge binding, which only happens when this row came from
5930/// `OPTIONAL MATCH` null-padding (every position `name_pattern_for_path`
5931/// named is guaranteed present in the row either way, as a real binding or
5932/// as `Binding::Value(Null)`, so "missing key" isn't a case this needs to
5933/// handle) — same "no match survives as Null, not a dropped row" outcome
5934/// `OPTIONAL MATCH` already gives every other variable.
5935fn assemble_path(pattern: &Pattern, row: &BindingRow) -> Binding {
5936    let Some(start_id) = path_node_id(pattern.start.var.as_deref(), row) else {
5937        return Binding::Value(PropertyValue::Null);
5938    };
5939    let mut elems = vec![PathBinding::Node(start_id)];
5940    for (rel, node) in &pattern.hops {
5941        if rel.capture_path_segment {
5942            // A variable-length hop's own segment, deposited by
5943            // `expand_variable_row` under this hop's own synthesized
5944            // `rel.var` -- already the exact alternating Edge/Node/.../
5945            // Node sequence this hop contributes, ending at `node`'s own
5946            // binding (so no separate `path_node_id(node.var, ...)` read
5947            // is needed after this).
5948            let Some(Binding::Path(segment)) = rel.var.as_deref().and_then(|v| row.get(v)) else {
5949                return Binding::Value(PropertyValue::Null);
5950            };
5951            elems.extend(segment.iter().cloned());
5952            continue;
5953        }
5954        let Some(edge_id) = path_edge_id(rel.var.as_deref(), row) else {
5955            return Binding::Value(PropertyValue::Null);
5956        };
5957        let Some(node_id) = path_node_id(node.var.as_deref(), row) else {
5958            return Binding::Value(PropertyValue::Null);
5959        };
5960        elems.push(PathBinding::Edge(edge_id));
5961        elems.push(PathBinding::Node(node_id));
5962    }
5963    Binding::Path(elems)
5964}
5965
5966/// `[r:TYPE*1..3]`'s own `r` -- real Cypher binds the traversed
5967/// relationships as a *list*, fully materialized (not just ids the way
5968/// `path_segment_var`'s cheaper `Binding::Path` segment stays), since
5969/// `Binding::List` -- like every other post-projection value shape --
5970/// only ever holds already-resolved `Value`s (TCK's Match4 `[1]`/`[6]`).
5971fn segment_edges_to_list(txn: Txn, segment: &[PathBinding]) -> Result<Binding, QueryError> {
5972    let edges = segment
5973        .iter()
5974        .filter_map(|elem| match elem {
5975            PathBinding::Edge(id) => Some(*id),
5976            PathBinding::Node(_) => None,
5977        })
5978        .map(|id| {
5979            let edge = deleted_entity_access(GraphStore::get_edge_in_txn(txn, id)?)?;
5980            Ok(Value::Edge(edge))
5981        })
5982        .collect::<Result<Vec<_>, QueryError>>()?;
5983    Ok(Binding::List(edges))
5984}
5985
5986fn path_node_id(var: Option<&str>, row: &BindingRow) -> Option<NodeId> {
5987    match var.and_then(|v| row.get(v)) {
5988        Some(Binding::Node(id)) => Some(*id),
5989        _ => None,
5990    }
5991}
5992
5993fn path_edge_id(var: Option<&str>, row: &BindingRow) -> Option<EdgeId> {
5994    match var.and_then(|v| row.get(v)) {
5995        Some(Binding::Edge(id)) => Some(*id),
5996        _ => None,
5997    }
5998}
5999
6000fn require_bound_node(row: &BindingRow, var: &str) -> Result<NodeId, QueryError> {
6001    match row.get(var) {
6002        Some(Binding::Node(id)) => Ok(*id),
6003        _ => Err(QueryError::UnboundVariable(format!(
6004            "'{var}' must already be bound to a node before shortestPath() — match it in a preceding MATCH"
6005        ))),
6006    }
6007}
6008
6009/// Walks `parent` (populated by `shortest_path_between`'s BFS) backward
6010/// from `end` to `start`, then reverses — `parent` only ever needs to
6011/// answer "how did BFS first reach this node," not support any other
6012/// traversal, so a plain `HashMap` (not a `LogicalPlan`/adjacency
6013/// structure) is enough.
6014fn reconstruct_path(
6015    parent: &HashMap<NodeId, (NodeId, EdgeId)>,
6016    start: NodeId,
6017    end: NodeId,
6018) -> Vec<PathBinding> {
6019    let mut hops = Vec::new();
6020    let mut current = end;
6021    while current != start {
6022        let (prev, edge_id) = parent[&current];
6023        hops.push((edge_id, current));
6024        current = prev;
6025    }
6026    hops.reverse();
6027    let mut elems = vec![PathBinding::Node(start)];
6028    for (edge_id, node) in hops {
6029        elems.push(PathBinding::Edge(edge_id));
6030        elems.push(PathBinding::Node(node));
6031    }
6032    elems
6033}
6034
6035/// Coerces a materialized `Value` down to a `PropertyValue` for storing in
6036/// `Binding::Value` — used by `item_binding` for a computed (non-bare-var)
6037/// WITH/RETURN item. `Value::Node`/`Edge` can't occur here in practice (no
6038/// non-aggregate `ReturnExpr` form produces one except `Var`, which takes
6039/// the bare-variable path instead), and a bare `collect()` result is
6040/// routed to `Binding::List` before reaching here (see `has_aggregate`) --
6041/// both still fall back to `Null` rather than needing a fallible signature
6042/// for an unreachable case. `Value::List` genuinely *can* reach here now,
6043/// though (`WITH n.numbers + [4] AS x` -- a real computed list expression,
6044/// not a bare `collect()`, once list-valued properties round-trip through
6045/// `lookup_prop_value` as real `Value::List`s) -- recurses per-element,
6046/// same as `value_to_storable_property`'s own list handling.
6047fn value_to_property_value(v: &Value) -> PropertyValue {
6048    match v {
6049        Value::Null => PropertyValue::Null,
6050        Value::Property(pv) => pv.clone(),
6051        Value::Literal(lit) => literal_to_value(lit),
6052        Value::List(items) => {
6053            PropertyValue::List(items.iter().map(value_to_property_value).collect())
6054        }
6055        Value::Node(_) | Value::Edge(_) | Value::Map(_) | Value::Path(_) => PropertyValue::Null,
6056    }
6057}
6058
6059/// `eval_props_to_values`'s stricter cousin of `value_to_property_value`
6060/// above -- a CREATE/SET prop value that evaluates to a node/edge/path/map
6061/// is a real, reportable error (`None` here), not a silent `Null`.
6062/// `value_to_property_value`'s silent-`Null` fallback is correct at *its*
6063/// call sites (a WITH-projected scalar, where those shapes genuinely can't
6064/// occur — see its own doc comment) but was never meant for CREATE/SET's
6065/// prop value, where writing one of those is a real, everyday mistake
6066/// (`CREATE (n {tags: some_node})`) that should say so, not silently store
6067/// `null`. `Value::List` *is* storable (`PropertyValue::List`, real
6068/// Cypher/Neo4j's own "homogeneous array property" shape) -- recurses
6069/// per-element, so a list containing something unstorable (a nested list
6070/// isn't rejected here, since no TCK scenario tests that restriction and
6071/// nothing about `PropertyValue::List`'s own storage format requires it,
6072/// but a node/edge/path/map element still correctly fails the whole list).
6073fn value_to_storable_property(v: &Value) -> Option<PropertyValue> {
6074    match v {
6075        Value::Null => Some(PropertyValue::Null),
6076        Value::Property(pv) => Some(pv.clone()),
6077        Value::Literal(lit) => Some(literal_to_value(lit)),
6078        Value::List(items) => Some(PropertyValue::List(
6079            items
6080                .iter()
6081                .map(value_to_storable_property)
6082                .collect::<Option<Vec<_>>>()?,
6083        )),
6084        Value::Node(_) | Value::Edge(_) | Value::Map(_) | Value::Path(_) => None,
6085    }
6086}
6087
6088/// `value_to_storable_property`'s inverse -- turns a raw stored/bound
6089/// `PropertyValue` back into a real `Value`, the read-time counterpart
6090/// every property-access site (`lookup_prop_value`, `binding_to_value`,
6091/// `eval_projected_expr`'s node/edge prop arms) needs. A scalar wraps as
6092/// `Value::Property` exactly as before; `PropertyValue::List` becomes a
6093/// genuine `Value::List` (not `Value::Property(PropertyValue::List(_))`)
6094/// so every existing list operation (`size()`, `tail()`, indexing, `IN`,
6095/// `UNWIND`, ...) -- all of which pattern-match on `Value::List`
6096/// specifically -- works transparently on a property-sourced list the
6097/// same as a list literal/`collect()` result, with no special-casing
6098/// needed anywhere else. `PropertyValue::Null` collapses to `Value::Null`,
6099/// matching every other property-read site's existing null convention.
6100fn property_value_to_value(pv: PropertyValue) -> Value {
6101    match pv {
6102        PropertyValue::Null => Value::Null,
6103        PropertyValue::List(items) => {
6104            Value::List(items.into_iter().map(property_value_to_value).collect())
6105        }
6106        other => Value::Property(other),
6107    }
6108}
6109
6110/// A bound `NodeId`/`EdgeId` whose record is no longer in the store means
6111/// exactly one thing within a single statement's transaction: it was
6112/// deleted earlier in this same statement (e.g. `MATCH (n) DELETE n RETURN
6113/// n.num` -- real Cypher's `DeletedEntityAccess` error, TCK's Return2
6114/// scenarios [15]/[16]/[17]). Nothing else can cause a `None` here --
6115/// there's no concurrent deletion mid-statement, and a `Binding::Node`/
6116/// `Edge` only ever gets constructed from an id a prior MATCH/CREATE/MERGE
6117/// in this same transaction actually found or made. Centralized here
6118/// (rather than each of `binding_to_value`/`resolve_path_elems`/
6119/// `lookup_prop` re-deriving the message) so the wording stays one place.
6120fn deleted_entity_access<T>(record: Option<T>) -> Result<T, QueryError> {
6121    record.ok_or_else(|| {
6122        QueryError::UnboundVariable(
6123            "refers to a node/relationship that no longer exists — it was deleted earlier in this statement".into(),
6124        )
6125    })
6126}
6127
6128pub(crate) fn literal_to_value(lit: &Literal) -> PropertyValue {
6129    match lit {
6130        Literal::Int(i) => PropertyValue::Int(*i),
6131        Literal::Float(f) => PropertyValue::Float(*f),
6132        Literal::String(s) => PropertyValue::String(s.clone()),
6133        Literal::Bool(b) => PropertyValue::Bool(*b),
6134        Literal::Null => PropertyValue::Null,
6135        Literal::Param(name) => {
6136            unreachable!("param ${name} must be substituted before execution — see params::substitute_params")
6137        }
6138    }
6139}
6140
6141fn tag_merge_created(mut row: BindingRow, created: bool) -> BindingRow {
6142    row.insert(
6143        MERGE_CREATED_KEY.to_string(),
6144        Binding::Value(PropertyValue::Bool(created)),
6145    );
6146    row
6147}
6148
6149/// `Either` (undirected `-[r:TYPE]-`) has no single storage-level call —
6150/// query both directions and dedupe by `edge_id` (a self-loop would
6151/// otherwise appear twice, once from each direction's adjacency table).
6152/// Multiple `rel_labels` (`[:A|B]`) has no single storage-level call
6153/// either — `GraphStore::neighbors_in_txn` only ever filters by one label
6154/// at a time, so this makes one call per type (per direction) and
6155/// dedupes by `edge_id` across all of them, same technique as `Either`
6156/// above (an edge whose type is in `rel_labels` is only ever returned by
6157/// exactly one of those per-type calls, so the only real duplication risk
6158/// is the same direction-crossing one `Either` already handles). Empty
6159/// `rel_labels` means untyped — matches any relationship, same as
6160/// `neighbors_in_txn`'s own `None` behavior.
6161fn neighbors_for_direction(
6162    txn: Txn,
6163    node: NodeId,
6164    direction: ExpandDirection,
6165    rel_labels: &[String],
6166) -> Result<Vec<AdjEntry>, QueryError> {
6167    let dirs: &[Direction] = match direction {
6168        ExpandDirection::Out => &[Direction::Out],
6169        ExpandDirection::In => &[Direction::In],
6170        ExpandDirection::Either => &[Direction::Out, Direction::In],
6171    };
6172    let mut out = Vec::new();
6173    let mut seen: HashSet<EdgeId> = HashSet::new();
6174    let label_filters: Vec<Option<&str>> = if rel_labels.is_empty() {
6175        vec![None]
6176    } else {
6177        rel_labels.iter().map(|l| Some(l.as_str())).collect()
6178    };
6179    for label in label_filters {
6180        for &dir in dirs {
6181            for entry in GraphStore::neighbors_in_txn(txn, node, dir, label)? {
6182                if seen.insert(entry.edge_id) {
6183                    out.push(entry);
6184                }
6185            }
6186        }
6187    }
6188    Ok(out)
6189}
6190
6191/// Three-valued: `None` is Cypher's "unknown", not `false` -- any
6192/// comparison touching a null (a missing property, or a literal `null` on
6193/// either side) is unknown, always, regardless of operator -- including
6194/// `Eq` (`x = null` is unknown, never true, same as real Cypher; it is
6195/// *not* how `x`'s own missing-ness is tested -- there's no `IS NULL`
6196/// operator yet). Callers combine this with `and3`/`or3`/`Option::map`
6197/// (for `NOT`) rather than unwrapping early, so unknown propagates
6198/// correctly through `AND`/`OR`/`NOT` instead of collapsing to `false`.
6199fn compare(prop: &Option<PropertyValue>, op: CompareOp, lit: &Literal) -> Option<bool> {
6200    let Some(prop) = prop else { return None };
6201    if matches!(prop, PropertyValue::Null) || matches!(lit, Literal::Null) {
6202        return None;
6203    }
6204    compare_property_pair(prop, op, &literal_to_value(lit))
6205}
6206
6207/// Same null-handling as `compare()`, but both sides are a looked-up
6208/// property (`Expr::PropCompare` -- `a.id = b.id`) instead of one side
6209/// being a fixed `Literal`.
6210fn compare_property_pair_opt(
6211    a: &Option<PropertyValue>,
6212    op: CompareOp,
6213    b: &Option<PropertyValue>,
6214) -> Option<bool> {
6215    let (Some(a), Some(b)) = (a, b) else {
6216        return None;
6217    };
6218    if matches!(a, PropertyValue::Null) || matches!(b, PropertyValue::Null) {
6219        return None;
6220    }
6221    compare_property_pair(a, op, b)
6222}
6223
6224/// The actual per-type comparison rules, shared by `compare()`
6225/// (`PropertyValue` vs a `Literal`, reduced to a `PropertyValue` via
6226/// `literal_to_value`) and `compare_values` (two arbitrary `Value`s,
6227/// each reduced to a `PropertyValue` via `value_to_property_value`) --
6228/// both callers have already handled the "either side is null" case
6229/// before reaching here. Returns `Option<bool>`, not `bool` -- a
6230/// type-mismatched pair (`1 < 'a'`) isn't a uniform "false" the way an
6231/// earlier version of this function had it: real Cypher's `=`/`<>` on
6232/// mismatched types is a definite `false`/`true` (never equal, so
6233/// "not equal" is true), but ordering (`<`/`<=`/`>`/`>=`) on mismatched
6234/// types is `null` (no defined ordering exists to be definite about) --
6235/// confirmed against real TCK scenarios (`'1.0' < 1.0` is `null`, not
6236/// `false`; `NaN <> 'a'` is `true`, not `false`), not assumed.
6237fn compare_property_pair(a: &PropertyValue, op: CompareOp, b: &PropertyValue) -> Option<bool> {
6238    match (a, b) {
6239        (PropertyValue::Int(a), PropertyValue::Int(b)) => Some(cmp_ord(op, *a, *b)),
6240        (PropertyValue::Int(a), PropertyValue::Float(b)) => Some(cmp_f64(op, *a as f64, *b)),
6241        (PropertyValue::Float(a), PropertyValue::Float(b)) => Some(cmp_f64(op, *a, *b)),
6242        (PropertyValue::Float(a), PropertyValue::Int(b)) => Some(cmp_f64(op, *a, *b as f64)),
6243        (PropertyValue::String(a), PropertyValue::String(b)) => Some(match op {
6244            CompareOp::StartsWith => a.starts_with(b.as_str()),
6245            CompareOp::EndsWith => a.ends_with(b.as_str()),
6246            CompareOp::Contains => a.contains(b.as_str()),
6247            _ => cmp_ord(op, a.as_str(), b.as_str()),
6248        }),
6249        // Real Cypher defines boolean ordering (`false < true`), same as
6250        // Rust's own `bool: PartialOrd` -- confirmed via a real TCK
6251        // scenario (`Quantifier7 :: [3]`) that specifically compares two
6252        // boolean expressions with `<=`.
6253        (PropertyValue::Bool(a), PropertyValue::Bool(b)) => Some(cmp_ord(op, *a, *b)),
6254        // `Date` had no arm here at all before -- fell through to the
6255        // generic mismatch fallback below, which always answers
6256        // `Eq -> false`/`Ne -> true` regardless of the actual values, so
6257        // `WHERE a.date = b.date` on two genuinely-equal stored dates
6258        // incorrectly evaluated to `false`. A real, pre-existing gap,
6259        // fixed here rather than left alongside the new temporal types.
6260        (PropertyValue::Date(a), PropertyValue::Date(b)) => Some(cmp_ord(op, *a, *b)),
6261        (PropertyValue::LocalTime(a), PropertyValue::LocalTime(b)) => Some(cmp_ord(op, *a, *b)),
6262        // Compares the UTC-equivalent instant-of-day, not the raw
6263        // wall-clock fields -- see `PropertyValue::Time`'s doc comment.
6264        (
6265            PropertyValue::Time {
6266                nanos_of_day: na,
6267                offset_seconds: oa,
6268            },
6269            PropertyValue::Time {
6270                nanos_of_day: nb,
6271                offset_seconds: ob,
6272            },
6273        ) => Some(cmp_ord(
6274            op,
6275            na - *oa as i64 * 1_000_000_000,
6276            nb - *ob as i64 * 1_000_000_000,
6277        )),
6278        (
6279            PropertyValue::LocalDateTime {
6280                epoch_seconds: sa,
6281                nanos: na,
6282            },
6283            PropertyValue::LocalDateTime {
6284                epoch_seconds: sb,
6285                nanos: nb,
6286            },
6287        ) => Some(cmp_ord(op, (*sa, *na), (*sb, *nb))),
6288        // Instant-only, `offset_seconds` ignored -- see
6289        // `PropertyValue::DateTime`'s doc comment.
6290        (
6291            PropertyValue::DateTime {
6292                epoch_seconds: sa,
6293                nanos: na,
6294                ..
6295            },
6296            PropertyValue::DateTime {
6297                epoch_seconds: sb,
6298                nanos: nb,
6299                ..
6300            },
6301        ) => Some(cmp_ord(op, (*sa, *na), (*sb, *nb))),
6302        // `Duration` has no defined *ordering* (see its own doc comment)
6303        // but `=`/`<>` are still real, component-wise comparisons (the
6304        // same bug `Date` had above -- the generic mismatch fallback's
6305        // unconditional `Eq -> false` would otherwise make two
6306        // genuinely-equal durations compare unequal).
6307        (PropertyValue::Duration { .. }, PropertyValue::Duration { .. }) => match op {
6308            CompareOp::Eq => Some(a == b),
6309            CompareOp::Ne => Some(a != b),
6310            _ => None,
6311        },
6312        _ => match op {
6313            CompareOp::Eq => Some(false),
6314            CompareOp::Ne => Some(true),
6315            // A string predicate on a non-null, non-string operand has no
6316            // defined answer (undefined, not "definitely false") -- same
6317            // "type mismatch -> null" stance as ordering, confirmed via a
6318            // real TCK scenario (`'abc' STARTS WITH true` must be `null`,
6319            // not `false`, so `(x STARTS WITH true) <> (x STARTS WITH
6320            // true)` correctly stays `null` rather than folding to a
6321            // spurious `false`/`true`).
6322            CompareOp::StartsWith
6323            | CompareOp::EndsWith
6324            | CompareOp::Contains
6325            | CompareOp::Lt
6326            | CompareOp::Le
6327            | CompareOp::Gt
6328            | CompareOp::Ge => None,
6329        },
6330    }
6331}
6332
6333/// A `ReturnExpr` boolean operand -- `Null` is "unknown" (`None`), a real
6334/// bool passes through, anything else is a genuine type error (real
6335/// Cypher: `1 AND true` doesn't silently coerce).
6336fn value_to_bool3(v: &Value) -> Result<Option<bool>, QueryError> {
6337    match v {
6338        Value::Null => Ok(None),
6339        Value::Literal(Literal::Bool(b)) | Value::Property(PropertyValue::Bool(b)) => Ok(Some(*b)),
6340        other => Err(QueryError::Type(format!(
6341            "expected a boolean, got {other:?}"
6342        ))),
6343    }
6344}
6345
6346fn bool3_to_value(b: Option<bool>) -> Value {
6347    match b {
6348        Some(b) => Value::Literal(Literal::Bool(b)),
6349        None => Value::Null,
6350    }
6351}
6352
6353/// `None`/`None` (both unknown) combines to unknown, matching Cypher's
6354/// `AND` truth table -- `false` wins over `unknown` (`false AND unknown =
6355/// false`), but `true AND unknown = unknown`, not `true`.
6356fn and3(a: Option<bool>, b: Option<bool>) -> Option<bool> {
6357    match (a, b) {
6358        (Some(false), _) | (_, Some(false)) => Some(false),
6359        (Some(true), Some(true)) => Some(true),
6360        _ => None,
6361    }
6362}
6363
6364/// Mirrors `and3` for `OR` -- `true` wins over `unknown`.
6365fn or3(a: Option<bool>, b: Option<bool>) -> Option<bool> {
6366    match (a, b) {
6367        (Some(true), _) | (_, Some(true)) => Some(true),
6368        (Some(false), Some(false)) => Some(false),
6369        _ => None,
6370    }
6371}
6372
6373/// `XOR` has no "one side already decides it" shortcut the way `AND`/`OR`
6374/// do -- either operand being unknown makes the whole result unknown,
6375/// since flipping the unknown side could flip the answer either way.
6376fn xor3(a: Option<bool>, b: Option<bool>) -> Option<bool> {
6377    match (a, b) {
6378        (Some(a), Some(b)) => Some(a != b),
6379        _ => None,
6380    }
6381}
6382
6383fn cmp_f64(op: CompareOp, a: f64, b: f64) -> bool {
6384    match op {
6385        CompareOp::Eq => a == b,
6386        CompareOp::Ne => a != b,
6387        CompareOp::Lt => a < b,
6388        CompareOp::Le => a <= b,
6389        CompareOp::Gt => a > b,
6390        CompareOp::Ge => a >= b,
6391        // Only meaningful for String/String, handled separately in
6392        // `compare()` before reaching here -- a numeric operand with one
6393        // of these ops is a type mismatch, same as any other.
6394        CompareOp::StartsWith | CompareOp::EndsWith | CompareOp::Contains => false,
6395    }
6396}
6397
6398fn cmp_ord<T: PartialOrd>(op: CompareOp, a: T, b: T) -> bool {
6399    match op {
6400        CompareOp::Eq => a == b,
6401        CompareOp::Ne => a != b,
6402        CompareOp::Lt => a < b,
6403        CompareOp::Le => a <= b,
6404        CompareOp::Gt => a > b,
6405        CompareOp::Ge => a >= b,
6406        CompareOp::StartsWith | CompareOp::EndsWith | CompareOp::Contains => false,
6407    }
6408}
6409
6410/// Value equality for CASE's WHEN-comparison (and, elsewhere, DISTINCT
6411/// dedup within an aggregate). Null == Null -> true here deliberately,
6412/// unlike `compare()`'s three-valued `WHERE`-filter semantics -- CASE and
6413/// DISTINCT need a definite yes/no ("is this the same value as a value
6414/// already collected", "does this WHEN branch match") rather than
6415/// "unknown", so plain equality is the correct, separate choice here, not
6416/// an oversight. `Node`/`Edge` compare by id (graph identity), not
6417/// full-struct contents — cheaper, and the correct semantics regardless
6418/// (two bindings are "the same node" iff the same node, not iff their
6419/// label/prop snapshots happen to match).
6420pub(crate) fn value_eq(a: &Value, b: &Value) -> bool {
6421    match (a, b) {
6422        (Value::Null, Value::Null) => true,
6423        (Value::Null, _) | (_, Value::Null) => false,
6424        (Value::Property(pa), Value::Property(pb)) => property_value_eq(pa, pb),
6425        (Value::Literal(la), Value::Literal(lb)) => la == lb,
6426        (Value::Property(pa), Value::Literal(lb)) => *pa == literal_to_value(lb),
6427        (Value::Literal(la), Value::Property(pb)) => literal_to_value(la) == *pb,
6428        (Value::Node(na), Value::Node(nb)) => na.id == nb.id,
6429        (Value::Edge(ea), Value::Edge(eb)) => ea.id == eb.id,
6430        (Value::List(la), Value::List(lb)) => {
6431            la.len() == lb.len() && la.iter().zip(lb).all(|(x, y)| value_eq(x, y))
6432        }
6433        // Two paths are equal iff they visit the same nodes/relationships
6434        // in the same order (real Cypher's own path-equality rule) --
6435        // element-wise identity, same `.id` comparison `Value::Node`/
6436        // `Value::Edge` above already use. Previously fell through to the
6437        // catch-all `_ => false` (any two paths were unconditionally
6438        // unequal, even two bindings of the identical path) -- unreachable
6439        // until two independently-MATCHed paths could be compared via `=`
6440        // in one statement (TCK's Comparison1 [14]).
6441        (Value::Path(pa), Value::Path(pb)) => {
6442            pa.len() == pb.len()
6443                && pa.iter().zip(pb).all(|(x, y)| match (x, y) {
6444                    (PathElem::Node(na), PathElem::Node(nb)) => na.id == nb.id,
6445                    (PathElem::Edge(ea), PathElem::Edge(eb)) => ea.id == eb.id,
6446                    _ => false,
6447                })
6448        }
6449        _ => false,
6450    }
6451}
6452
6453/// `PropertyValue`'s derived `PartialEq` is structural (every field must
6454/// match), which is wrong for `Time`/`DateTime`: two values at the same
6455/// instant but different offsets must compare equal (see their own doc
6456/// comments -- same rule `compare_property_pair`/`compare_non_null`/
6457/// `comparable_ordering` already apply for `<`/`>`/ORDER BY/min/max).
6458/// Everything else keeps plain structural equality.
6459fn property_value_eq(a: &PropertyValue, b: &PropertyValue) -> bool {
6460    match (a, b) {
6461        (
6462            PropertyValue::Time {
6463                nanos_of_day: na,
6464                offset_seconds: oa,
6465            },
6466            PropertyValue::Time {
6467                nanos_of_day: nb,
6468                offset_seconds: ob,
6469            },
6470        ) => na - *oa as i64 * 1_000_000_000 == nb - *ob as i64 * 1_000_000_000,
6471        (
6472            PropertyValue::DateTime {
6473                epoch_seconds: sa,
6474                nanos: na,
6475                ..
6476            },
6477            PropertyValue::DateTime {
6478                epoch_seconds: sb,
6479                nanos: nb,
6480                ..
6481            },
6482        ) => sa == sb && na == nb,
6483        _ => a == b,
6484    }
6485}
6486
6487/// A number coerced out of a `Value`, for `apply_arith` below -- separate
6488/// from `PropertyValue`/`Literal` since either could hold the operand
6489/// (`n.price + 1` mixes a stored property with a literal).
6490enum ArithNum {
6491    Int(i64),
6492    Float(f64),
6493}
6494
6495fn as_arith_num(v: &Value) -> Option<ArithNum> {
6496    match v {
6497        Value::Property(PropertyValue::Int(i)) | Value::Literal(Literal::Int(i)) => {
6498            Some(ArithNum::Int(*i))
6499        }
6500        Value::Property(PropertyValue::Float(f)) | Value::Literal(Literal::Float(f)) => {
6501            Some(ArithNum::Float(*f))
6502        }
6503        _ => None,
6504    }
6505}
6506
6507/// `datetime.fromepoch(seconds, nanos)`/`datetime.fromepochmillis(millis)`'s
6508/// own argument check -- both take a required, definite integer, not the
6509/// wider "any arithmetic-ish value" `as_arith_num` allows (no float
6510/// coercion for a raw epoch count) and not optional (missing/null isn't a
6511/// documented no-op the way it is for e.g. `date()`'s own no-arg form).
6512fn require_int_arg(v: Option<&Value>, fn_name: &str) -> Result<i64, QueryError> {
6513    match v {
6514        Some(Value::Property(PropertyValue::Int(i))) | Some(Value::Literal(Literal::Int(i))) => {
6515            Ok(*i)
6516        }
6517        other => Err(QueryError::Type(format!(
6518            "{fn_name}() expects an integer argument, got {other:?}"
6519        ))),
6520    }
6521}
6522
6523fn as_arith_str(v: &Value) -> Option<&str> {
6524    match v {
6525        Value::Property(PropertyValue::String(s)) | Value::Literal(Literal::String(s)) => {
6526            Some(s.as_str())
6527        }
6528        _ => None,
6529    }
6530}
6531
6532/// `-x` for `ReturnExpr::Neg` -- a negative numeric *literal* (`-3`)
6533/// never reaches this (see `cypher.pest`'s `unary_minus_expr` docs), so
6534/// this only ever handles negating a genuinely computed/bound value
6535/// (`-n.prop`, `-(1+2)`, ...). Same null-propagation/numeric-only
6536/// convention as `apply_arith`.
6537fn apply_neg(v: &Value) -> Result<Value, QueryError> {
6538    if matches!(v, Value::Null) {
6539        return Ok(Value::Null);
6540    }
6541    Ok(match as_arith_num(v) {
6542        Some(ArithNum::Int(i)) => {
6543            Value::Property(PropertyValue::Int(i.checked_neg().ok_or_else(|| {
6544                QueryError::Type("integer arithmetic overflow".into())
6545            })?))
6546        }
6547        Some(ArithNum::Float(f)) => Value::Property(PropertyValue::Float(-f)),
6548        None => {
6549            return Err(QueryError::Type(format!(
6550                "unary minus needs a number -- got {v:?}"
6551            )))
6552        }
6553    })
6554}
6555
6556/// `lhs op rhs` for `ReturnExpr::Arith`. Null propagates (matches every
6557/// other operator's null-handling convention in this file). `+` also
6558/// concatenates two strings, real Cypher's other overload for that
6559/// operator; every other combination of non-numeric operands is a real
6560/// type error, not a silent `Null`/`false` fallback -- an arithmetic
6561/// expression that can't be evaluated should say so, not produce a
6562/// plausible-looking wrong answer.
6563fn apply_arith(op: ArithOp, a: &Value, b: &Value) -> Result<Value, QueryError> {
6564    if matches!(a, Value::Null) || matches!(b, Value::Null) {
6565        return Ok(Value::Null);
6566    }
6567    if op == ArithOp::Add {
6568        // Real Cypher's list concatenation/append/prepend via `+` --
6569        // `[1,2] + [3]` concatenates, `[1,2] + 3`/`3 + [1,2]` appends/
6570        // prepends the scalar. Only `+` has this meaning for a list;
6571        // every other `ArithOp` still rejects one via the numeric-only
6572        // fallback below (and at compile time, `semantic.rs`'s own
6573        // `ReturnExpr::Arith` check).
6574        match (a, b) {
6575            (Value::List(xs), Value::List(ys)) => {
6576                let mut combined = xs.clone();
6577                combined.extend(ys.iter().cloned());
6578                return Ok(Value::List(combined));
6579            }
6580            (Value::List(xs), scalar) => {
6581                let mut combined = xs.clone();
6582                combined.push(scalar.clone());
6583                return Ok(Value::List(combined));
6584            }
6585            (scalar, Value::List(ys)) => {
6586                let mut combined = vec![scalar.clone()];
6587                combined.extend(ys.iter().cloned());
6588                return Ok(Value::List(combined));
6589            }
6590            _ => {}
6591        }
6592        if let (Some(sa), Some(sb)) = (as_arith_str(a), as_arith_str(b)) {
6593            return Ok(Value::Property(PropertyValue::String(format!("{sa}{sb}"))));
6594        }
6595    }
6596    if let Some(result) = apply_temporal_arith(op, a, b)? {
6597        return Ok(result);
6598    }
6599    let (Some(na), Some(nb)) = (as_arith_num(a), as_arith_num(b)) else {
6600        return Err(QueryError::Type(format!(
6601            "arithmetic needs two numbers (or, for +, two strings) -- got {a:?} and {b:?}"
6602        )));
6603    };
6604    // `^` always produces a Float, even for two Ints (real Cypher's own
6605    // rule) -- handled up front, separately from the Int/Int-stays-Int
6606    // branch below, rather than folding it into that match's own `op`
6607    // dispatch.
6608    if op == ArithOp::Pow {
6609        let to_f64 = |n: ArithNum| match n {
6610            ArithNum::Int(i) => i as f64,
6611            ArithNum::Float(f) => f,
6612        };
6613        return Ok(Value::Property(PropertyValue::Float(
6614            to_f64(na).powf(to_f64(nb)),
6615        )));
6616    }
6617    // Int/Int stays Int (truncating division/modulo, matching Rust's `/`/
6618    // `%` on integers) -- any Float operand promotes the whole expression
6619    // to Float, same numeric-promotion rule `compare()` already follows.
6620    Ok(match (na, nb) {
6621        (ArithNum::Int(x), ArithNum::Int(y)) => {
6622            if matches!(op, ArithOp::Div | ArithOp::Mod) && y == 0 {
6623                return Err(QueryError::Type("division by zero".into()));
6624            }
6625            let value = match op {
6626                ArithOp::Add => x.checked_add(y),
6627                ArithOp::Sub => x.checked_sub(y),
6628                ArithOp::Mul => x.checked_mul(y),
6629                ArithOp::Div => x.checked_div(y),
6630                ArithOp::Mod => x.checked_rem(y),
6631                ArithOp::Pow => unreachable!("handled above"),
6632            }
6633            .ok_or_else(|| QueryError::Type("integer arithmetic overflow".into()))?;
6634            Value::Property(PropertyValue::Int(value))
6635        }
6636        (x, y) => {
6637            let x = match x {
6638                ArithNum::Int(i) => i as f64,
6639                ArithNum::Float(f) => f,
6640            };
6641            let y = match y {
6642                ArithNum::Int(i) => i as f64,
6643                ArithNum::Float(f) => f,
6644            };
6645            Value::Property(PropertyValue::Float(match op {
6646                ArithOp::Add => x + y,
6647                ArithOp::Sub => x - y,
6648                ArithOp::Mul => x * y,
6649                ArithOp::Div => x / y,
6650                ArithOp::Mod => x % y,
6651                ArithOp::Pow => unreachable!("handled above"),
6652            }))
6653        }
6654    })
6655}
6656
6657fn as_date(v: &Value) -> Option<i32> {
6658    match v {
6659        Value::Property(PropertyValue::Date(d)) => Some(*d),
6660        _ => None,
6661    }
6662}
6663
6664fn as_duration(v: &Value) -> Option<temporal::DurationParts> {
6665    match v {
6666        Value::Property(PropertyValue::Duration {
6667            months,
6668            days,
6669            seconds,
6670            nanos,
6671        }) => Some((*months, *days, *seconds, *nanos)),
6672        _ => None,
6673    }
6674}
6675
6676fn duration_value((months, days, seconds, nanos): temporal::DurationParts) -> Value {
6677    Value::Property(PropertyValue::Duration {
6678        months,
6679        days,
6680        seconds,
6681        nanos,
6682    })
6683}
6684
6685fn as_local_time(v: &Value) -> Option<i64> {
6686    match v {
6687        Value::Property(PropertyValue::LocalTime(n)) => Some(*n),
6688        _ => None,
6689    }
6690}
6691
6692fn as_time(v: &Value) -> Option<(i64, i32)> {
6693    match v {
6694        Value::Property(PropertyValue::Time {
6695            nanos_of_day,
6696            offset_seconds,
6697        }) => Some((*nanos_of_day, *offset_seconds)),
6698        _ => None,
6699    }
6700}
6701
6702fn as_local_date_time(v: &Value) -> Option<(i64, i32)> {
6703    match v {
6704        Value::Property(PropertyValue::LocalDateTime {
6705            epoch_seconds,
6706            nanos,
6707        }) => Some((*epoch_seconds, *nanos)),
6708        _ => None,
6709    }
6710}
6711
6712fn as_date_time(v: &Value) -> Option<(i64, i32, temporal::TzId)> {
6713    match v {
6714        Value::Property(PropertyValue::DateTime {
6715            epoch_seconds,
6716            nanos,
6717            zone,
6718        }) => Some((*epoch_seconds, *nanos, tz_from_graph(zone))),
6719        _ => None,
6720    }
6721}
6722
6723/// `marsdb_graph::TzId` <-> `temporal::TzId` -- two independent, same-
6724/// shaped types (`temporal.rs` deliberately doesn't depend on
6725/// `marsdb_graph`, see its own module doc comment), converted at this
6726/// storage/query-layer boundary.
6727fn tz_from_graph(zone: &GraphTzId) -> temporal::TzId {
6728    match zone {
6729        GraphTzId::Offset(o) => temporal::TzId::Offset(*o),
6730        GraphTzId::Named(name) => temporal::TzId::Named(name.clone()),
6731    }
6732}
6733
6734fn tz_to_graph(zone: temporal::TzId) -> GraphTzId {
6735    match zone {
6736        temporal::TzId::Offset(o) => GraphTzId::Offset(o),
6737        temporal::TzId::Named(name) => GraphTzId::Named(name),
6738    }
6739}
6740
6741/// The `Date`/`Duration`/`LocalTime`/`Time`/`LocalDateTime`/`DateTime`
6742/// cases of `+`/`-`/`*`/`/` -- tried before `apply_arith`'s generic
6743/// numeric path, since none of these are ever an `ArithNum`. Returns
6744/// `Ok(None)` (not an error) for any operand-type combination it doesn't
6745/// recognize, so `apply_arith` falls through to its own "not two
6746/// numbers" error with the *original* operands in the message, rather
6747/// than this function needing to duplicate that error text.
6748///
6749/// `<temporal> - <temporal>` (real Cypher's `duration.between(...)` is
6750/// the actual spelling for that, itself out of scope -- see the README)
6751/// is deliberately *not* handled for any of the 5 non-Duration types,
6752/// falling through to the same "not two numbers" error a truly
6753/// nonsensical subtraction would already get.
6754fn apply_temporal_arith(op: ArithOp, a: &Value, b: &Value) -> Result<Option<Value>, QueryError> {
6755    let date_plus_duration =
6756        |d: i32, dur: temporal::DurationParts, negate: bool| -> Result<Value, QueryError> {
6757            let (months, days, seconds, nanos) = dur;
6758            temporal::add_duration_to_date(d, months, days, seconds, nanos, negate)
6759                .map(|d| Value::Property(PropertyValue::Date(d)))
6760                .ok_or_else(|| {
6761                    QueryError::Type("date +/- duration produced an out-of-range date".into())
6762                })
6763        };
6764    let local_time_plus_duration = |t: i64, dur: temporal::DurationParts, negate: bool| -> Value {
6765        let (_, _, seconds, nanos) = dur;
6766        Value::Property(PropertyValue::LocalTime(temporal::add_duration_to_time(
6767            t, seconds, nanos, negate,
6768        )))
6769    };
6770    let time_plus_duration =
6771        |(t, offset): (i64, i32), dur: temporal::DurationParts, negate: bool| -> Value {
6772            let (_, _, seconds, nanos) = dur;
6773            Value::Property(PropertyValue::Time {
6774                nanos_of_day: temporal::add_duration_to_time(t, seconds, nanos, negate),
6775                offset_seconds: offset,
6776            })
6777        };
6778    let local_date_time_plus_duration = |(epoch_seconds, existing_nanos): (i64, i32),
6779                                         dur: temporal::DurationParts,
6780                                         negate: bool|
6781     -> Result<Value, QueryError> {
6782        let (months, days, seconds, nanos) = dur;
6783        temporal::add_duration_to_local_date_time(
6784            epoch_seconds,
6785            existing_nanos,
6786            months,
6787            days,
6788            seconds,
6789            nanos,
6790            negate,
6791        )
6792        .map(|(epoch_seconds, nanos)| {
6793            Value::Property(PropertyValue::LocalDateTime {
6794                epoch_seconds,
6795                nanos,
6796            })
6797        })
6798        .ok_or_else(|| {
6799            QueryError::Type("local date-time +/- duration produced an out-of-range value".into())
6800        })
6801    };
6802    // `Named` zone arithmetic is only ever a single fixed-offset op, not
6803    // a full DST-crossing re-resolution -- the offset is resolved once
6804    // (at the *pre*-arithmetic instant) via `resolve_offset` and carried
6805    // through unchanged, same as `Offset`'s own behavior; no TCK scenario
6806    // exercises arithmetic on a `Named`-zone `DateTime` at all, so this
6807    // is a real, deliberately narrow scope, not silently wrong for a
6808    // tested case.
6809    let date_time_plus_duration =
6810        |(epoch_seconds, existing_nanos, zone): (i64, i32, temporal::TzId),
6811         dur: temporal::DurationParts,
6812         negate: bool|
6813         -> Result<Value, QueryError> {
6814            let (months, days, seconds, nanos) = dur;
6815            let offset_seconds = temporal::resolve_offset(&zone, epoch_seconds);
6816            temporal::add_duration_to_local_date_time(
6817                epoch_seconds + offset_seconds as i64,
6818                existing_nanos,
6819                months,
6820                days,
6821                seconds,
6822                nanos,
6823                negate,
6824            )
6825            .map(|(local_epoch_seconds, nanos)| {
6826                Value::Property(PropertyValue::DateTime {
6827                    epoch_seconds: local_epoch_seconds - offset_seconds as i64,
6828                    nanos,
6829                    zone: tz_to_graph(zone),
6830                })
6831            })
6832            .ok_or_else(|| {
6833                QueryError::Type("date-time +/- duration produced an out-of-range value".into())
6834            })
6835        };
6836    Ok(match op {
6837        ArithOp::Add => {
6838            if let (Some(d), Some(dur)) = (as_date(a), as_duration(b)) {
6839                Some(date_plus_duration(d, dur, false)?)
6840            } else if let (Some(dur), Some(d)) = (as_duration(a), as_date(b)) {
6841                Some(date_plus_duration(d, dur, false)?)
6842            } else if let (Some(t), Some(dur)) = (as_local_time(a), as_duration(b)) {
6843                Some(local_time_plus_duration(t, dur, false))
6844            } else if let (Some(dur), Some(t)) = (as_duration(a), as_local_time(b)) {
6845                Some(local_time_plus_duration(t, dur, false))
6846            } else if let (Some(t), Some(dur)) = (as_time(a), as_duration(b)) {
6847                Some(time_plus_duration(t, dur, false))
6848            } else if let (Some(dur), Some(t)) = (as_duration(a), as_time(b)) {
6849                Some(time_plus_duration(t, dur, false))
6850            } else if let (Some(dt), Some(dur)) = (as_local_date_time(a), as_duration(b)) {
6851                Some(local_date_time_plus_duration(dt, dur, false)?)
6852            } else if let (Some(dur), Some(dt)) = (as_duration(a), as_local_date_time(b)) {
6853                Some(local_date_time_plus_duration(dt, dur, false)?)
6854            } else if let (Some(dt), Some(dur)) = (as_date_time(a), as_duration(b)) {
6855                Some(date_time_plus_duration(dt, dur, false)?)
6856            } else if let (Some(dur), Some(dt)) = (as_duration(a), as_date_time(b)) {
6857                Some(date_time_plus_duration(dt, dur, false)?)
6858            } else if let (Some(x), Some(y)) = (as_duration(a), as_duration(b)) {
6859                Some(duration_value(temporal::add_duration(x, y).ok_or_else(
6860                    || QueryError::Type("duration addition overflow".into()),
6861                )?))
6862            } else {
6863                None
6864            }
6865        }
6866        ArithOp::Sub => {
6867            if let (Some(d), Some(dur)) = (as_date(a), as_duration(b)) {
6868                Some(date_plus_duration(d, dur, true)?)
6869            } else if let (Some(t), Some(dur)) = (as_local_time(a), as_duration(b)) {
6870                Some(local_time_plus_duration(t, dur, true))
6871            } else if let (Some(t), Some(dur)) = (as_time(a), as_duration(b)) {
6872                Some(time_plus_duration(t, dur, true))
6873            } else if let (Some(dt), Some(dur)) = (as_local_date_time(a), as_duration(b)) {
6874                Some(local_date_time_plus_duration(dt, dur, true)?)
6875            } else if let (Some(dt), Some(dur)) = (as_date_time(a), as_duration(b)) {
6876                Some(date_time_plus_duration(dt, dur, true)?)
6877            } else if let (Some(x), Some(y)) = (as_duration(a), as_duration(b)) {
6878                Some(duration_value(temporal::sub_duration(x, y).ok_or_else(
6879                    || QueryError::Type("duration subtraction overflow".into()),
6880                )?))
6881            } else {
6882                None
6883            }
6884        }
6885        ArithOp::Mul => {
6886            if let (Some(dur), Some(f)) = (as_duration(a), value_as_f64(b)) {
6887                Some(duration_value(temporal::scale_duration(dur, f)))
6888            } else if let (Some(f), Some(dur)) = (value_as_f64(a), as_duration(b)) {
6889                Some(duration_value(temporal::scale_duration(dur, f)))
6890            } else {
6891                None
6892            }
6893        }
6894        ArithOp::Div => {
6895            if let (Some(dur), Some(f)) = (as_duration(a), value_as_f64(b)) {
6896                if f == 0.0 {
6897                    return Err(QueryError::Type("division by zero".into()));
6898                }
6899                Some(duration_value(temporal::scale_duration(dur, 1.0 / f)))
6900            } else {
6901                None
6902            }
6903        }
6904        ArithOp::Mod => None,
6905        // `^` is never meaningful for a date/duration/etc operand --
6906        // real Cypher has no temporal exponentiation, so this always
6907        // falls through to `apply_arith`'s own numeric-only rejection.
6908        ArithOp::Pow => None,
6909    })
6910}
6911
6912/// `list[index]` -- a negative index counts from the end (`-1` is the
6913/// last element). Out of bounds either way is `Null`, not an error --
6914/// matches real Cypher (`[1,2,3][10]` is `null`, not a failure), and is
6915/// the only sane behavior for an index that's itself a runtime expression
6916/// rather than a literal a human could sanity-check up front.
6917fn apply_index(list: &Value, index: &Value) -> Result<Value, QueryError> {
6918    if matches!(list, Value::Null) || matches!(index, Value::Null) {
6919        return Ok(Value::Null);
6920    }
6921    // `map[key]` -- real Cypher's dynamic map-field access (`map['name']`,
6922    // as opposed to `map.name`'s static form -- `lookup_prop`/`ReturnExpr
6923    // ::Prop` above). Unlike `.prop`, this can return a full nested
6924    // `Value` (a list/map field value), not just a scalar `PropertyValue`
6925    // -- `apply_index`'s return type already allows that, no narrowing
6926    // needed the way `map_value_as_property` has to for `.prop`.
6927    if let Value::Map(entries) = list {
6928        let Some(key) = as_arith_str(index) else {
6929            return Err(QueryError::Type(format!(
6930                "a map index must be a string, got {index:?}"
6931            )));
6932        };
6933        return Ok(entries.get(key).cloned().unwrap_or(Value::Null));
6934    }
6935    // `n['name']` -- dynamic property access on a node/relationship/
6936    // temporal value, same as `n.name`'s static form but with a computed
6937    // key (TCK's Graph7 `[1]`-`[3]`). Reuses `property_of_value` exactly
6938    // -- the only actual difference from `.prop` is where the key string
6939    // comes from.
6940    if matches!(list, Value::Node(_) | Value::Edge(_) | Value::Property(_)) {
6941        let Some(key) = as_arith_str(index) else {
6942            return Err(QueryError::Type(format!(
6943                "a property index must be a string, got {index:?}"
6944            )));
6945        };
6946        return property_of_value(list, key);
6947    }
6948    let Value::List(items) = list else {
6949        return Err(QueryError::Type(format!(
6950            "[] indexing needs a list or map, got {list:?}"
6951        )));
6952    };
6953    let Some(ArithNum::Int(i)) = as_arith_num(index) else {
6954        return Err(QueryError::Type(format!(
6955            "a list index must be an integer, got {index:?}"
6956        )));
6957    };
6958    let len = items.len() as i64;
6959    let i = if i < 0 { i + len } else { i };
6960    if i < 0 || i >= len {
6961        return Ok(Value::Null);
6962    }
6963    Ok(items[i as usize].clone())
6964}
6965
6966/// `list[start..end]` -- same negative-counts-from-end rule as
6967/// `apply_index`, but bounds clamp to `[0, len]` instead of nulling out
6968/// (`[1,2,3][-5..5]` is the whole list, not `null`), and a start at or
6969/// past the (clamped) end yields `[]` rather than erroring
6970/// (`[1,2,3][3..1]` is `[]`) -- both match real Cypher, and both were
6971/// real TCK scenarios, not guessed behavior.
6972fn apply_slice(
6973    list: &Value,
6974    start: Option<&Value>,
6975    end: Option<&Value>,
6976) -> Result<Value, QueryError> {
6977    if matches!(list, Value::Null) {
6978        return Ok(Value::Null);
6979    }
6980    let Value::List(items) = list else {
6981        return Err(QueryError::Type(format!(
6982            "[..] slicing needs a list, got {list:?}"
6983        )));
6984    };
6985    let len = items.len() as i64;
6986    let clamp = |i: i64| -> i64 {
6987        let i = if i < 0 { i + len } else { i };
6988        i.clamp(0, len)
6989    };
6990    let bound_index = |v: Option<&Value>, default: i64| -> Result<Option<i64>, QueryError> {
6991        match v {
6992            None => Ok(Some(default)),
6993            Some(Value::Null) => Ok(None),
6994            Some(other) => match as_arith_num(other) {
6995                Some(ArithNum::Int(i)) => Ok(Some(clamp(i))),
6996                _ => Err(QueryError::Type(format!(
6997                    "a slice bound must be an integer, got {other:?}"
6998                ))),
6999            },
7000        }
7001    };
7002    // A null bound (as opposed to an *omitted* one, already handled by
7003    // `start`/`end` being `None` at the AST level) propagates -- same
7004    // null-handling convention as every other operator here.
7005    let (Some(start_idx), Some(end_idx)) = (bound_index(start, 0)?, bound_index(end, len)?) else {
7006        return Ok(Value::Null);
7007    };
7008    if start_idx >= end_idx {
7009        return Ok(Value::List(Vec::new()));
7010    }
7011    Ok(Value::List(
7012        items[start_idx as usize..end_idx as usize].to_vec(),
7013    ))
7014}
7015
7016fn call_builtin(
7017    name: &str,
7018    args: &[Value],
7019    now: temporal::NowSnapshot,
7020) -> Result<Value, QueryError> {
7021    match name.to_ascii_lowercase().as_str() {
7022        "coalesce" => Ok(args
7023            .iter()
7024            .find(|v| !matches!(v, Value::Null))
7025            .cloned()
7026            .unwrap_or(Value::Null)),
7027        "tointeger" => match args.first() {
7028            Some(v) => to_integer(v),
7029            None => Ok(Value::Null),
7030        },
7031        "tostring" => match args.first() {
7032            Some(v) => to_string_value(v),
7033            None => Ok(Value::Null),
7034        },
7035        "date" => date_builtin(args, now),
7036        "date.transaction" | "date.statement" | "date.realtime" => Ok(now_or_null(args, || {
7037            Value::Property(PropertyValue::Date(now.epoch_day))
7038        })),
7039        "duration" => duration_builtin(args),
7040        "localtime" => local_time_builtin(args, now),
7041        "localtime.transaction" | "localtime.statement" | "localtime.realtime" => {
7042            Ok(now_or_null(args, || {
7043                Value::Property(PropertyValue::LocalTime(now.nanos_of_day))
7044            }))
7045        }
7046        "time" => time_builtin(args, now),
7047        "time.transaction" | "time.statement" | "time.realtime" => {
7048            // No-arg time() defaults to UTC offset (real Cypher's statement default timezone)
7049            Ok(now_or_null(args, || {
7050                Value::Property(PropertyValue::Time {
7051                    nanos_of_day: now.nanos_of_day,
7052                    offset_seconds: 0,
7053                })
7054            }))
7055        }
7056        "localdatetime" => local_date_time_builtin(args, now),
7057        "localdatetime.transaction" | "localdatetime.statement" | "localdatetime.realtime" => {
7058            Ok(now_or_null(args, || {
7059                Value::Property(PropertyValue::LocalDateTime {
7060                    epoch_seconds: now.epoch_seconds,
7061                    nanos: now.nanos,
7062                })
7063            }))
7064        }
7065        "datetime" => date_time_builtin(args, now),
7066        "datetime.transaction" | "datetime.statement" | "datetime.realtime" => {
7067            // No-arg datetime() defaults to UTC offset (real Cypher's statement default timezone)
7068            Ok(now_or_null(args, || {
7069                Value::Property(PropertyValue::DateTime {
7070                    epoch_seconds: now.epoch_seconds,
7071                    nanos: now.nanos,
7072                    zone: GraphTzId::Offset(0),
7073                })
7074            }))
7075        }
7076        "datetime.fromepoch" => {
7077            let seconds = require_int_arg(args.first(), "datetime.fromepoch")?;
7078            let nanos = require_int_arg(args.get(1), "datetime.fromepoch")?;
7079            Ok(Value::Property(PropertyValue::DateTime {
7080                epoch_seconds: seconds,
7081                nanos: nanos as i32,
7082                zone: GraphTzId::Offset(0),
7083            }))
7084        }
7085        "datetime.fromepochmillis" => {
7086            let millis = require_int_arg(args.first(), "datetime.fromepochmillis")?;
7087            Ok(Value::Property(PropertyValue::DateTime {
7088                epoch_seconds: millis.div_euclid(1000),
7089                nanos: (millis.rem_euclid(1000) * 1_000_000) as i32,
7090                zone: GraphTzId::Offset(0),
7091            }))
7092        }
7093        "duration.between" => {
7094            duration_between_builtin("duration.between", args, temporal::duration_between)
7095        }
7096        "duration.inmonths" => {
7097            duration_between_builtin("duration.inMonths", args, temporal::duration_in_months)
7098        }
7099        "duration.indays" => {
7100            duration_between_builtin("duration.inDays", args, temporal::duration_in_days)
7101        }
7102        "duration.inseconds" => {
7103            duration_between_builtin("duration.inSeconds", args, temporal::duration_in_seconds)
7104        }
7105        "date.truncate" => date_truncate_builtin(args),
7106        "localtime.truncate" => local_time_truncate_builtin(args),
7107        "time.truncate" => time_truncate_builtin(args),
7108        "localdatetime.truncate" => local_date_time_truncate_builtin(args),
7109        "datetime.truncate" => date_time_truncate_builtin(args),
7110        // The dominant real-world use of shortestPath() is measuring it
7111        // (degrees-of-separation queries), not returning/rendering the
7112        // raw path object — path elements alternate node/edge/.../node,
7113        // so edge count is (elements.len() - 1) / 2.
7114        "length" => Ok(match args.first() {
7115            Some(Value::Path(elems)) => {
7116                Value::Property(PropertyValue::Int(((elems.len().max(1) - 1) / 2) as i64))
7117            }
7118            Some(Value::Null) | None => Value::Null,
7119            Some(other) => {
7120                return Err(QueryError::Type(format!(
7121                    "length() expects a path, got {other:?}"
7122                )))
7123            }
7124        }),
7125        "keys" => keys_builtin(args.first()),
7126        "labels" => labels_builtin(args.first()),
7127        "type" => type_builtin(args.first()),
7128        "properties" => properties_builtin(args.first()),
7129        "id" => id_builtin(args.first()),
7130        "size" => size_builtin(args.first()),
7131        "nodes" => nodes_builtin(args.first()),
7132        "relationships" => relationships_builtin(args.first()),
7133        "head" => list_edge_builtin(args.first(), "head", |items| items.first().cloned()),
7134        "last" => list_edge_builtin(args.first(), "last", |items| items.last().cloned()),
7135        "tail" => match args.first() {
7136            Some(Value::List(items)) => Ok(Value::List(
7137                items.iter().skip(1).cloned().collect::<Vec<_>>(),
7138            )),
7139            Some(Value::Null) | None => Ok(Value::Null),
7140            Some(other) => Err(QueryError::Type(format!(
7141                "tail() expects a list, got {other:?}"
7142            ))),
7143        },
7144        "range" => range_builtin(args),
7145        "exists" => Ok(Value::Literal(Literal::Bool(!matches!(
7146            args.first(),
7147            None | Some(Value::Null)
7148        )))),
7149        "toupper" | "upper" => string_transform(args.first(), "toUpper", str::to_uppercase),
7150        "tolower" | "lower" => string_transform(args.first(), "toLower", str::to_lowercase),
7151        "trim" => string_transform(args.first(), "trim", |s| s.trim().to_string()),
7152        "ltrim" => string_transform(args.first(), "ltrim", |s| s.trim_start().to_string()),
7153        "rtrim" => string_transform(args.first(), "rtrim", |s| s.trim_end().to_string()),
7154        "reverse" => reverse_builtin(args.first()),
7155        "replace" => replace_builtin(args),
7156        "split" => split_builtin(args),
7157        "substring" => substring_builtin(args),
7158        "left" => left_right_builtin(args, true),
7159        "right" => left_right_builtin(args, false),
7160        "tofloat" => match args.first() {
7161            Some(v) => to_float(v),
7162            None => Ok(Value::Null),
7163        },
7164        "toboolean" => match args.first() {
7165            Some(v) => to_boolean(v),
7166            None => Ok(Value::Null),
7167        },
7168        "abs" => match args.first() {
7169            Some(Value::Property(PropertyValue::Int(i)))
7170            | Some(Value::Literal(Literal::Int(i))) => {
7171                Ok(Value::Property(PropertyValue::Int(i.abs())))
7172            }
7173            Some(Value::Null) | None => Ok(Value::Null),
7174            Some(other) => match value_as_f64(other) {
7175                Some(f) => Ok(Value::Property(PropertyValue::Float(f.abs()))),
7176                None => Err(QueryError::Type(format!(
7177                    "abs() expects a number, got {other:?}"
7178                ))),
7179            },
7180        },
7181        "ceil" => float_math_fn(args.first(), "ceil", f64::ceil),
7182        "floor" => float_math_fn(args.first(), "floor", f64::floor),
7183        "round" => float_math_fn(args.first(), "round", f64::round),
7184        "sqrt" => float_math_fn(args.first(), "sqrt", f64::sqrt),
7185        "sign" => match args.first() {
7186            Some(Value::Null) | None => Ok(Value::Null),
7187            Some(other) => match value_as_f64(other) {
7188                Some(f) => Ok(Value::Property(PropertyValue::Int(if f > 0.0 {
7189                    1
7190                } else if f < 0.0 {
7191                    -1
7192                } else {
7193                    0
7194                }))),
7195                None => Err(QueryError::Type(format!(
7196                    "sign() expects a number, got {other:?}"
7197                ))),
7198            },
7199        },
7200        "rand" => Ok(Value::Property(PropertyValue::Float(rand_f64()))),
7201        other => Err(QueryError::Semantic(format!("unknown function: {other}"))),
7202    }
7203}
7204
7205/// `rand()` -- a fresh pseudo-random `f64` in `[0, 1)` on every call (no
7206/// memoization like `now()`/`date()`'s `NowSnapshot` -- real Cypher's
7207/// `rand()` is independently random each time it's evaluated, even
7208/// multiple times in the same query). No external RNG crate: combines an
7209/// atomic per-process counter with `RandomState`'s own already-randomized
7210/// per-construction seed (the same source `HashMap`'s DoS-resistant
7211/// default hasher draws from), good enough for a general-purpose
7212/// `rand()` without pulling in a dependency for one function.
7213fn rand_f64() -> f64 {
7214    use std::collections::hash_map::RandomState;
7215    use std::hash::{BuildHasher, Hasher};
7216    use std::sync::atomic::{AtomicU64, Ordering};
7217    static COUNTER: AtomicU64 = AtomicU64::new(0);
7218    let mut hasher = RandomState::new().build_hasher();
7219    hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed));
7220    let bits = hasher.finish();
7221    (bits >> 11) as f64 / (1u64 << 53) as f64
7222}
7223
7224fn keys_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7225    Ok(match arg {
7226        Some(Value::Node(n)) => Value::List(
7227            n.props
7228                .keys()
7229                .map(|k| Value::Property(PropertyValue::String(k.clone())))
7230                .collect(),
7231        ),
7232        Some(Value::Edge(e)) => Value::List(
7233            e.props
7234                .keys()
7235                .map(|k| Value::Property(PropertyValue::String(k.clone())))
7236                .collect(),
7237        ),
7238        Some(Value::Map(m)) => Value::List(
7239            m.keys()
7240                .map(|k| Value::Property(PropertyValue::String(k.clone())))
7241                .collect(),
7242        ),
7243        Some(Value::Null) | None => Value::Null,
7244        Some(other) => {
7245            return Err(QueryError::Type(format!(
7246                "keys() expects a node, relationship, or map, got {other:?}"
7247            )))
7248        }
7249    })
7250}
7251
7252fn labels_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7253    Ok(match arg {
7254        Some(Value::Node(n)) => Value::List(
7255            n.labels
7256                .iter()
7257                .map(|l| Value::Property(PropertyValue::String(l.clone())))
7258                .collect(),
7259        ),
7260        Some(Value::Null) | None => Value::Null,
7261        Some(other) => {
7262            return Err(QueryError::Type(format!(
7263                "labels() expects a node, got {other:?}"
7264            )))
7265        }
7266    })
7267}
7268
7269fn type_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7270    Ok(match arg {
7271        Some(Value::Edge(e)) => Value::Property(PropertyValue::String(e.label.clone())),
7272        Some(Value::Null) | None => Value::Null,
7273        Some(other) => {
7274            return Err(QueryError::Type(format!(
7275                "type() expects a relationship, got {other:?}"
7276            )))
7277        }
7278    })
7279}
7280
7281fn properties_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7282    Ok(match arg {
7283        Some(Value::Node(n)) => Value::Map(
7284            n.props
7285                .iter()
7286                .map(|(k, v)| (k.clone(), property_value_to_value(v.clone())))
7287                .collect(),
7288        ),
7289        Some(Value::Edge(e)) => Value::Map(
7290            e.props
7291                .iter()
7292                .map(|(k, v)| (k.clone(), property_value_to_value(v.clone())))
7293                .collect(),
7294        ),
7295        Some(Value::Map(m)) => Value::Map(m.clone()),
7296        Some(Value::Null) | None => Value::Null,
7297        Some(other) => {
7298            return Err(QueryError::Type(format!(
7299                "properties() expects a node, relationship, or map, got {other:?}"
7300            )))
7301        }
7302    })
7303}
7304
7305fn id_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7306    Ok(match arg {
7307        Some(Value::Node(n)) => Value::Property(PropertyValue::Int(n.id.0 as i64)),
7308        Some(Value::Edge(e)) => Value::Property(PropertyValue::Int(e.id.0 as i64)),
7309        Some(Value::Null) | None => Value::Null,
7310        Some(other) => {
7311            return Err(QueryError::Type(format!(
7312                "id() expects a node or relationship, got {other:?}"
7313            )))
7314        }
7315    })
7316}
7317
7318fn size_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7319    Ok(match arg {
7320        Some(Value::List(items)) => Value::Property(PropertyValue::Int(items.len() as i64)),
7321        Some(Value::Null) | None => Value::Null,
7322        Some(other) => match as_arith_str(other) {
7323            Some(s) => Value::Property(PropertyValue::Int(s.chars().count() as i64)),
7324            None => {
7325                return Err(QueryError::Type(format!(
7326                    "size() expects a list or string, got {other:?}"
7327                )))
7328            }
7329        },
7330    })
7331}
7332
7333fn nodes_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7334    Ok(match arg {
7335        Some(Value::Path(elems)) => Value::List(
7336            elems
7337                .iter()
7338                .filter_map(|e| match e {
7339                    PathElem::Node(n) => Some(Value::Node(n.clone())),
7340                    PathElem::Edge(_) => None,
7341                })
7342                .collect(),
7343        ),
7344        Some(Value::Null) | None => Value::Null,
7345        Some(other) => {
7346            return Err(QueryError::Type(format!(
7347                "nodes() expects a path, got {other:?}"
7348            )))
7349        }
7350    })
7351}
7352
7353fn relationships_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7354    Ok(match arg {
7355        Some(Value::Path(elems)) => Value::List(
7356            elems
7357                .iter()
7358                .filter_map(|e| match e {
7359                    PathElem::Edge(e) => Some(Value::Edge(e.clone())),
7360                    PathElem::Node(_) => None,
7361                })
7362                .collect(),
7363        ),
7364        Some(Value::Null) | None => Value::Null,
7365        Some(other) => {
7366            return Err(QueryError::Type(format!(
7367                "relationships() expects a path, got {other:?}"
7368            )))
7369        }
7370    })
7371}
7372
7373/// Shared shape for `head()`/`last()` -- `[]` (an empty list) is `null`,
7374/// same as any other out-of-bounds list access in this codebase
7375/// (`apply_index`'s docs), not an error.
7376fn list_edge_builtin(
7377    arg: Option<&Value>,
7378    fn_name: &str,
7379    pick: impl Fn(&[Value]) -> Option<Value>,
7380) -> Result<Value, QueryError> {
7381    Ok(match arg {
7382        Some(Value::List(items)) => pick(items).unwrap_or(Value::Null),
7383        Some(Value::Null) | None => Value::Null,
7384        Some(other) => {
7385            return Err(QueryError::Type(format!(
7386                "{fn_name}() expects a list, got {other:?}"
7387            )))
7388        }
7389    })
7390}
7391
7392/// `range(start, end[, step])` -- both bounds inclusive (real Cypher's own
7393/// convention, unlike Rust's exclusive-end ranges), `step` defaults to 1
7394/// and may be negative for a descending range. A zero step has no
7395/// sensible iteration direction -- a real error, not an infinite/empty
7396/// silent result.
7397fn range_builtin(args: &[Value]) -> Result<Value, QueryError> {
7398    let int_arg = |v: &Value, which: &str| -> Result<i64, QueryError> {
7399        value_as_i64(v).ok_or_else(|| {
7400            QueryError::Type(format!("range()'s {which} must be an integer, got {v:?}"))
7401        })
7402    };
7403    let start = int_arg(
7404        args.first()
7405            .ok_or_else(|| QueryError::Semantic("range() requires at least 2 arguments".into()))?,
7406        "start",
7407    )?;
7408    let end = int_arg(
7409        args.get(1)
7410            .ok_or_else(|| QueryError::Semantic("range() requires at least 2 arguments".into()))?,
7411        "end",
7412    )?;
7413    let step = match args.get(2) {
7414        Some(v) => int_arg(v, "step")?,
7415        None => 1,
7416    };
7417    if step == 0 {
7418        return Err(QueryError::Type("range()'s step can't be 0".into()));
7419    }
7420    let mut out = Vec::new();
7421    let mut i = start;
7422    if step > 0 {
7423        while i <= end {
7424            out.push(Value::Property(PropertyValue::Int(i)));
7425            i += step;
7426        }
7427    } else {
7428        while i >= end {
7429            out.push(Value::Property(PropertyValue::Int(i)));
7430            i += step;
7431        }
7432    }
7433    Ok(Value::List(out))
7434}
7435
7436fn string_transform(
7437    arg: Option<&Value>,
7438    fn_name: &str,
7439    f: impl FnOnce(&str) -> String,
7440) -> Result<Value, QueryError> {
7441    Ok(match arg {
7442        Some(Value::Null) | None => Value::Null,
7443        Some(other) => match as_arith_str(other) {
7444            Some(s) => Value::Property(PropertyValue::String(f(s))),
7445            None => {
7446                return Err(QueryError::Type(format!(
7447                    "{fn_name}() expects a string, got {other:?}"
7448                )))
7449            }
7450        },
7451    })
7452}
7453
7454fn reverse_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7455    Ok(match arg {
7456        Some(Value::Null) | None => Value::Null,
7457        Some(Value::List(items)) => Value::List(items.iter().rev().cloned().collect()),
7458        Some(other) => match as_arith_str(other) {
7459            Some(s) => Value::Property(PropertyValue::String(s.chars().rev().collect())),
7460            None => {
7461                return Err(QueryError::Type(format!(
7462                    "reverse() expects a string or list, got {other:?}"
7463                )))
7464            }
7465        },
7466    })
7467}
7468
7469/// A closure can't express `str_arg`'s independent-lifetimes signature
7470/// (the returned `&str` borrows from `v`, not `which`) the way a real
7471/// `fn` item can -- see `replace_builtin`'s only caller of this.
7472fn replace_str_arg<'a>(v: &'a Value, which: &str) -> Result<&'a str, QueryError> {
7473    as_arith_str(v)
7474        .ok_or_else(|| QueryError::Type(format!("replace()'s {which} must be a string, got {v:?}")))
7475}
7476
7477fn replace_builtin(args: &[Value]) -> Result<Value, QueryError> {
7478    if args.iter().any(|v| matches!(v, Value::Null)) {
7479        return Ok(Value::Null);
7480    }
7481    let original = replace_str_arg(
7482        args.first()
7483            .ok_or_else(|| QueryError::Semantic("replace() requires 3 arguments".into()))?,
7484        "original",
7485    )?;
7486    let search = replace_str_arg(
7487        args.get(1)
7488            .ok_or_else(|| QueryError::Semantic("replace() requires 3 arguments".into()))?,
7489        "search",
7490    )?;
7491    let replacement = replace_str_arg(
7492        args.get(2)
7493            .ok_or_else(|| QueryError::Semantic("replace() requires 3 arguments".into()))?,
7494        "replacement",
7495    )?;
7496    Ok(Value::Property(PropertyValue::String(
7497        original.replace(search, replacement),
7498    )))
7499}
7500
7501fn split_builtin(args: &[Value]) -> Result<Value, QueryError> {
7502    if args.iter().any(|v| matches!(v, Value::Null)) {
7503        return Ok(Value::Null);
7504    }
7505    let s = args
7506        .first()
7507        .and_then(as_arith_str)
7508        .ok_or_else(|| QueryError::Type("split()'s first argument must be a string".into()))?;
7509    let delim = args
7510        .get(1)
7511        .and_then(as_arith_str)
7512        .ok_or_else(|| QueryError::Type("split()'s second argument must be a string".into()))?;
7513    let parts = if delim.is_empty() {
7514        s.split("").filter(|p| !p.is_empty()).collect::<Vec<_>>()
7515    } else {
7516        s.split(delim).collect::<Vec<_>>()
7517    };
7518    Ok(Value::List(
7519        parts
7520            .into_iter()
7521            .map(|p| Value::Property(PropertyValue::String(p.to_string())))
7522            .collect(),
7523    ))
7524}
7525
7526/// `substring(s, start[, length])` -- 0-indexed, both `start` and
7527/// `length` clamp to the string's bounds rather than erroring (matches
7528/// real Cypher: an out-of-range `substring` call is well-defined, not a
7529/// failure). Indexes by Unicode scalar (`char`), not byte offset, so a
7530/// multi-byte character never gets split.
7531fn substring_builtin(args: &[Value]) -> Result<Value, QueryError> {
7532    if matches!(args.first(), Some(Value::Null)) {
7533        return Ok(Value::Null);
7534    }
7535    let s = args
7536        .first()
7537        .and_then(as_arith_str)
7538        .ok_or_else(|| QueryError::Type("substring()'s first argument must be a string".into()))?;
7539    let chars: Vec<char> = s.chars().collect();
7540    let start = args
7541        .get(1)
7542        .and_then(value_as_i64)
7543        .ok_or_else(|| QueryError::Type("substring()'s start must be an integer".into()))?
7544        .max(0) as usize;
7545    let start = start.min(chars.len());
7546    let end = match args.get(2) {
7547        Some(v) => {
7548            let len = value_as_i64(v)
7549                .ok_or_else(|| QueryError::Type("substring()'s length must be an integer".into()))?
7550                .max(0) as usize;
7551            (start + len).min(chars.len())
7552        }
7553        None => chars.len(),
7554    };
7555    Ok(Value::Property(PropertyValue::String(
7556        chars[start..end].iter().collect(),
7557    )))
7558}
7559
7560/// `left(s, n)`/`right(s, n)` -- the first/last `n` characters, clamped
7561/// to the string's length rather than erroring on an over-long `n`.
7562fn left_right_builtin(args: &[Value], from_left: bool) -> Result<Value, QueryError> {
7563    if matches!(args.first(), Some(Value::Null)) {
7564        return Ok(Value::Null);
7565    }
7566    let fn_name = if from_left { "left" } else { "right" };
7567    let s = args.first().and_then(as_arith_str).ok_or_else(|| {
7568        QueryError::Type(format!("{fn_name}()'s first argument must be a string"))
7569    })?;
7570    let n = args
7571        .get(1)
7572        .and_then(value_as_i64)
7573        .ok_or_else(|| {
7574            QueryError::Type(format!("{fn_name}()'s second argument must be an integer"))
7575        })?
7576        .max(0) as usize;
7577    let chars: Vec<char> = s.chars().collect();
7578    let n = n.min(chars.len());
7579    let slice = if from_left {
7580        &chars[..n]
7581    } else {
7582        &chars[chars.len() - n..]
7583    };
7584    Ok(Value::Property(PropertyValue::String(
7585        slice.iter().collect(),
7586    )))
7587}
7588
7589fn float_math_fn(
7590    arg: Option<&Value>,
7591    fn_name: &str,
7592    f: impl FnOnce(f64) -> f64,
7593) -> Result<Value, QueryError> {
7594    Ok(match arg {
7595        Some(Value::Null) | None => Value::Null,
7596        Some(other) => match value_as_f64(other) {
7597            Some(x) => Value::Property(PropertyValue::Float(f(x))),
7598            None => {
7599                return Err(QueryError::Type(format!(
7600                    "{fn_name}() expects a number, got {other:?}"
7601                )))
7602            }
7603        },
7604    })
7605}
7606
7607fn to_float(v: &Value) -> Result<Value, QueryError> {
7608    Ok(match v {
7609        Value::Property(PropertyValue::Int(i)) => Value::Property(PropertyValue::Float(*i as f64)),
7610        Value::Property(PropertyValue::Float(f)) => Value::Property(PropertyValue::Float(*f)),
7611        Value::Literal(Literal::Int(i)) => Value::Property(PropertyValue::Float(*i as f64)),
7612        Value::Literal(Literal::Float(f)) => Value::Property(PropertyValue::Float(*f)),
7613        Value::Property(PropertyValue::String(s)) | Value::Literal(Literal::String(s)) => {
7614            match s.trim().parse::<f64>() {
7615                Ok(f) => Value::Property(PropertyValue::Float(f)),
7616                Err(_) => Value::Null,
7617            }
7618        }
7619        Value::Property(PropertyValue::Null) | Value::Literal(Literal::Null) | Value::Null => {
7620            Value::Null
7621        }
7622        Value::Literal(Literal::Param(name)) => {
7623            unreachable!("param ${name} must be substituted before execution — see params::substitute_params")
7624        }
7625        // `Bool` is a real, deliberate type error, not `null` -- unlike
7626        // an unparseable *string*, which real Cypher does treat as
7627        // `null` (a string always at least plausibly *could* be numeric
7628        // text), a boolean never could be (TCK's TypeConversion3 [6]).
7629        other => {
7630            return Err(QueryError::Type(format!(
7631                "toFloat() cannot convert {other:?} to a float"
7632            )))
7633        }
7634    })
7635}
7636
7637fn to_boolean(v: &Value) -> Result<Value, QueryError> {
7638    Ok(match v {
7639        Value::Property(PropertyValue::Bool(b)) | Value::Literal(Literal::Bool(b)) => {
7640            Value::Literal(Literal::Bool(*b))
7641        }
7642        Value::Property(PropertyValue::String(s)) | Value::Literal(Literal::String(s)) => {
7643            match s.trim().to_ascii_lowercase().as_str() {
7644                "true" => Value::Literal(Literal::Bool(true)),
7645                "false" => Value::Literal(Literal::Bool(false)),
7646                _ => Value::Null,
7647            }
7648        }
7649        Value::Property(PropertyValue::Null) | Value::Literal(Literal::Null) | Value::Null => {
7650            Value::Null
7651        }
7652        Value::Literal(Literal::Param(name)) => {
7653            unreachable!("param ${name} must be substituted before execution — see params::substitute_params")
7654        }
7655        other => {
7656            return Err(QueryError::Type(format!(
7657                "toBoolean() cannot convert {other:?} to a boolean"
7658            )))
7659        }
7660    })
7661}
7662
7663/// A quantifier's own per-element truthiness check when it has no `WHERE`
7664/// at all (`ANY(x IN list)`, not `ANY(x IN list WHERE ...)`) -- three-
7665/// valued, same as a real `WHERE` predicate: `null` propagates as
7666/// "unknown" (`None`), a literal bool passes through, anything else
7667/// (non-bool, non-null) is definitely-false, same convention `CASE`'s
7668/// subject-less `WHEN` branch already uses for a non-bool test value.
7669fn item_truthy(v: &Value) -> Option<bool> {
7670    match v {
7671        Value::Null => None,
7672        Value::Literal(Literal::Bool(b)) | Value::Property(PropertyValue::Bool(b)) => Some(*b),
7673        _ => Some(false),
7674    }
7675}
7676
7677/// Real Cypher quantifiers use three-valued logic, not a simple count --
7678/// a single definite `true`/`false` among the elements can already decide
7679/// the answer even in the presence of other `null` elements, and only
7680/// "no definite answer, but at least one unknown" actually yields `null`.
7681/// Confirmed against the real TCK scenarios (Quantifier1-4, scenario 10,
7682/// "... on lists containing nulls") rather than assumed -- a first version
7683/// of this collapsed `null` predicates to `false`, which silently passed
7684/// every non-null-list scenario but produced 19 real wrong answers on
7685/// exactly these null-list cases.
7686fn eval_quantifier(kind: QuantifierKind, preds: &[Option<bool>]) -> Option<bool> {
7687    let true_count = preds.iter().filter(|p| **p == Some(true)).count();
7688    let any_false = preds.contains(&Some(false));
7689    let any_null = preds.iter().any(|p| p.is_none());
7690    match kind {
7691        QuantifierKind::Any => {
7692            if true_count > 0 {
7693                Some(true)
7694            } else if any_null {
7695                None
7696            } else {
7697                Some(false)
7698            }
7699        }
7700        QuantifierKind::None => {
7701            if true_count > 0 {
7702                Some(false)
7703            } else if any_null {
7704                None
7705            } else {
7706                Some(true)
7707            }
7708        }
7709        QuantifierKind::All => {
7710            if any_false {
7711                Some(false)
7712            } else if any_null {
7713                None
7714            } else {
7715                Some(true)
7716            }
7717        }
7718        QuantifierKind::Single => {
7719            if true_count >= 2 {
7720                Some(false)
7721            } else if any_null {
7722                None
7723            } else {
7724                Some(true_count == 1)
7725            }
7726        }
7727    }
7728}
7729
7730fn to_integer(v: &Value) -> Result<Value, QueryError> {
7731    // A float-formatted string ('1.7', '2.9') isn't an i64, but real
7732    // Cypher's toInteger() still accepts it -- parse as a float and
7733    // truncate, same as the Float arm below, rather than failing straight
7734    // to null the way a bare `i64::parse` would (found via a real TCK
7735    // scenario: `toInteger('1.7')` must be `1`, not `null`).
7736    let as_str_parse = |s: &str| match s.trim().parse::<i64>() {
7737        Ok(i) => Value::Property(PropertyValue::Int(i)),
7738        Err(_) => match s.trim().parse::<f64>() {
7739            Ok(f) => Value::Property(PropertyValue::Int(f as i64)),
7740            Err(_) => Value::Null,
7741        },
7742    };
7743    Ok(match v {
7744        Value::Property(PropertyValue::Int(i)) => Value::Property(PropertyValue::Int(*i)),
7745        Value::Property(PropertyValue::Float(f)) => Value::Property(PropertyValue::Int(*f as i64)),
7746        Value::Property(PropertyValue::String(s)) => as_str_parse(s),
7747        Value::Literal(Literal::Int(i)) => Value::Property(PropertyValue::Int(*i)),
7748        Value::Literal(Literal::Float(f)) => Value::Property(PropertyValue::Int(*f as i64)),
7749        Value::Literal(Literal::String(s)) => as_str_parse(s),
7750        Value::Property(PropertyValue::Bool(_) | PropertyValue::Null)
7751        | Value::Literal(Literal::Bool(_) | Literal::Null)
7752        | Value::Null => Value::Null,
7753        Value::Literal(Literal::Param(name)) => {
7754            unreachable!("param ${name} must be substituted before execution — see params::substitute_params")
7755        }
7756        // A node/edge/list/map/path has no numeric conversion at all -- a
7757        // real error (found via a real TCK scenario expecting exactly
7758        // this), not a silent null the way an out-of-range/unparseable
7759        // scalar is.
7760        Value::Property(
7761            PropertyValue::Date(_)
7762            | PropertyValue::Duration { .. }
7763            | PropertyValue::LocalTime(_)
7764            | PropertyValue::Time { .. }
7765            | PropertyValue::LocalDateTime { .. }
7766            | PropertyValue::DateTime { .. }
7767            | PropertyValue::List(_)
7768            | PropertyValue::Map(_),
7769        )
7770        | Value::Node(_)
7771        | Value::Edge(_)
7772        | Value::List(_)
7773        | Value::Map(_)
7774        | Value::Path(_) => {
7775            return Err(QueryError::Type(format!(
7776                "toInteger() cannot convert {v:?} to an integer"
7777            )))
7778        }
7779    })
7780}
7781
7782/// `toString(...)` — Int/Float/Bool render the same as their `Display`
7783/// impl already does elsewhere (`marsdb-cli`'s `format_property`/
7784/// `format_literal`); `Date`/`Duration` go through `temporal::format_*`.
7785/// Null propagates, while graph, collection, map, and path values are a
7786/// runtime type error rather than silently becoming null (TypeConversion4
7787/// scenario [10]).
7788fn to_string_value(v: &Value) -> Result<Value, QueryError> {
7789    let s = match v {
7790        Value::Property(PropertyValue::String(s)) | Value::Literal(Literal::String(s)) => s.clone(),
7791        Value::Property(PropertyValue::Int(i)) | Value::Literal(Literal::Int(i)) => i.to_string(),
7792        Value::Property(PropertyValue::Float(f)) | Value::Literal(Literal::Float(f)) => {
7793            f.to_string()
7794        }
7795        Value::Property(PropertyValue::Bool(b)) | Value::Literal(Literal::Bool(b)) => b.to_string(),
7796        Value::Property(PropertyValue::Date(d)) => temporal::format_date(*d),
7797        Value::Property(PropertyValue::Duration {
7798            months,
7799            days,
7800            seconds,
7801            nanos,
7802        }) => temporal::format_duration(*months, *days, *seconds, *nanos),
7803        Value::Property(PropertyValue::LocalTime(nanos_of_day)) => {
7804            temporal::format_local_time(*nanos_of_day)
7805        }
7806        Value::Property(PropertyValue::Time {
7807            nanos_of_day,
7808            offset_seconds,
7809        }) => temporal::format_time(*nanos_of_day, *offset_seconds),
7810        Value::Property(PropertyValue::LocalDateTime {
7811            epoch_seconds,
7812            nanos,
7813        }) => temporal::format_local_date_time(*epoch_seconds, *nanos),
7814        Value::Property(PropertyValue::DateTime {
7815            epoch_seconds,
7816            nanos,
7817            zone,
7818        }) => temporal::format_date_time(*epoch_seconds, *nanos, &tz_from_graph(zone)),
7819        Value::Property(PropertyValue::Null) | Value::Literal(Literal::Null) | Value::Null => {
7820            return Ok(Value::Null);
7821        }
7822        Value::Literal(Literal::Param(name)) => {
7823            unreachable!("param ${name} must be substituted before execution — see params::substitute_params")
7824        }
7825        Value::Property(PropertyValue::List(_) | PropertyValue::Map(_))
7826        | Value::Node(_)
7827        | Value::Edge(_)
7828        | Value::List(_)
7829        | Value::Map(_)
7830        | Value::Path(_) => {
7831            return Err(QueryError::Type(format!(
7832                "toString() cannot convert {v:?} to a string"
7833            )))
7834        }
7835    };
7836    Ok(Value::Property(PropertyValue::String(s)))
7837}
7838
7839/// `date()` — zero args (today, UTC, from the `Executor`-cached
7840/// `temporal::NowSnapshot` — see its docs for why every no-arg temporal
7841/// call within one query shares the same captured instant), a string
7842/// (`date('2015-07-21')`, the calendar forms `temporal::
7843/// parse_date` supports), a map (`date({year: 1984, month: 10, day:
7844/// 11})`, calendar construction only), or another `Date` (identity —
7845/// `date(d)` where `d` is already a `Date`, e.g. from `toString`
7846/// round-tripping through `date(toString(d))`). Deliberately does *not*
7847/// support the week-date/ordinal-date/quarter map or string construction
7848/// forms real Cypher also has (`date({year: 2015, week: 1})`,
7849/// `date('2015-W30-2')`, ...) — a real, documented gap (see the README),
7850/// not a silent wrong answer: both `parse_date` and `date_from_map`
7851/// return a clear error/`None` for those rather than guessing.
7852/// `date.transaction()`/`.statement()`/`.realtime()` and their siblings
7853/// for the other 4 temporal types conceptually take no argument (they
7854/// always return the current transaction/statement/realtime instant) --
7855/// but real Cypher still requires them to propagate a `null` argument
7856/// (TCK's Temporal4 [13] "Should propagate null"), same as every other
7857/// temporal constructor. Found via the TCK: pest's own grammar couldn't
7858/// parse these namespaced calls with an argument at all, so this always-
7859/// ignore-args behavior was untested until ANTLR's grammar (which does
7860/// support it) newly exposed it as a silent wrong answer instead of null.
7861fn now_or_null(args: &[Value], now_value: impl FnOnce() -> Value) -> Value {
7862    if matches!(args.first(), Some(Value::Null)) {
7863        Value::Null
7864    } else {
7865        now_value()
7866    }
7867}
7868
7869fn date_builtin(args: &[Value], now: temporal::NowSnapshot) -> Result<Value, QueryError> {
7870    if args.len() > 1 {
7871        return Err(QueryError::Semantic(format!(
7872            "date() expects zero or one argument, got {}",
7873            args.len()
7874        )));
7875    }
7876    let Some(arg) = args.first() else {
7877        return Ok(Value::Property(PropertyValue::Date(now.epoch_day)));
7878    };
7879    if matches!(arg, Value::Null) {
7880        return Ok(Value::Null);
7881    }
7882    if let Value::Property(PropertyValue::Date(d)) = arg {
7883        return Ok(Value::Property(PropertyValue::Date(*d)));
7884    }
7885    // `date(otherTemporal)` -- a bare `LocalDateTime`/`DateTime` argument
7886    // projects its own date part, same as `date({date: otherTemporal})`
7887    // (TCK's Temporal3 [1]).
7888    if matches!(
7889        arg,
7890        Value::Property(PropertyValue::LocalDateTime { .. } | PropertyValue::DateTime { .. })
7891    ) {
7892        let epoch_day = extract_date_base_epoch_day("date() argument", arg)?;
7893        return Ok(Value::Property(PropertyValue::Date(epoch_day)));
7894    }
7895    if let Some(s) = as_arith_str(arg) {
7896        let d = temporal::parse_date(s).ok_or_else(|| {
7897            QueryError::Type(format!(
7898                "'{s}' isn't a date string MarsDB can parse -- only the calendar forms YYYY-MM-DD/YYYYMMDD/\
7899                 YYYY-MM/YYYYMM/YYYY, week-date forms YYYY-Www[-D]/YYYYWww[D], and ordinal-date forms \
7900                 YYYY-DDD/YYYYDDD are supported"
7901            ))
7902        })?;
7903        return Ok(Value::Property(PropertyValue::Date(d)));
7904    }
7905    if let Value::Map(m) = arg {
7906        return Ok(Value::Property(PropertyValue::Date(date_from_map(m)?)));
7907    }
7908    Err(QueryError::Type(format!(
7909        "date() doesn't support this argument: {arg:?}"
7910    )))
7911}
7912
7913/// Pulls the local (offset-adjusted for `DateTime`) epoch-day out of a
7914/// `Date`/`LocalDateTime`/`DateTime` value -- the "base" a `date`/
7915/// `datetime` map key projects its calendar fields from
7916/// (`date({date: other, day: 5})`, `localdatetime({date: other, hour:
7917/// 10, ...})`, ...). Returns the raw epoch-day (not a pre-split
7918/// `(year, month, day)`) so a caller can read *any* calendar component
7919/// off it (`weekYear`/`week`/`dayOfWeek`/`quarter`/`dayOfQuarter`/
7920/// `ordinalDay` via `date_component`), for defaulting the alternate
7921/// week/ordinal/quarter-date map-construction forms (see
7922/// `calendar_fields_from_map`).
7923fn extract_date_base_epoch_day(key: &str, v: &Value) -> Result<i32, QueryError> {
7924    match v {
7925        Value::Property(PropertyValue::Date(d)) => Ok(*d),
7926        Value::Property(PropertyValue::LocalDateTime { epoch_seconds, .. }) => {
7927            Ok(temporal::split_epoch_seconds(*epoch_seconds).0)
7928        }
7929        Value::Property(PropertyValue::DateTime {
7930            epoch_seconds,
7931            zone,
7932            ..
7933        }) => {
7934            let offset_seconds = temporal::resolve_offset(&tz_from_graph(zone), *epoch_seconds);
7935            Ok(temporal::split_epoch_seconds(epoch_seconds + offset_seconds as i64).0)
7936        }
7937        other => Err(QueryError::Type(format!(
7938            "'{key}' must be a Date, LocalDateTime, or DateTime, got {other:?}"
7939        ))),
7940    }
7941}
7942
7943/// `(hour, minute, second, nanos, zone)` pulled out of a `LocalTime`/
7944/// `Time`/`LocalDateTime`/`DateTime` value -- the "base" a `time`/
7945/// `datetime` map key projects its clock fields from. `nanos` here is
7946/// just the nanosecond-of-second remainder (not the whole nanos-of-day),
7947/// matching the map constructors' own `nanosecond` field. `zone` is
7948/// `Some((original_zone, resolved_offset_seconds))` only for `Time`/
7949/// `DateTime` sources -- both are kept, not just the resolved number, so
7950/// a caller that projects this base *without* an explicit `timezone`
7951/// override (`{time: t}`, `{datetime: dt}`) can preserve the source's
7952/// own zone *identity* (a `Named` zone stays `Named`, TCK's Temporal3
7953/// [9]/[11] `{datetime: other}` rows), while a caller that only ever
7954/// needs a plain number (`time_builtin`'s cross-type conversion, `TIME`
7955/// structurally can't hold a name) uses the resolved half directly.
7956type ClockBase = (i64, i64, i64, i64, Option<(temporal::TzId, i32)>);
7957
7958fn extract_time_base(key: &str, v: &Value) -> Result<ClockBase, QueryError> {
7959    let hms_nanos = |nanos_of_day: i64| {
7960        (
7961            temporal::local_time_component(nanos_of_day, "hour").unwrap(),
7962            temporal::local_time_component(nanos_of_day, "minute").unwrap(),
7963            temporal::local_time_component(nanos_of_day, "second").unwrap(),
7964            temporal::local_time_component(nanos_of_day, "nanosecond").unwrap(),
7965        )
7966    };
7967    match v {
7968        Value::Property(PropertyValue::LocalTime(n)) => {
7969            let (h, m, s, ns) = hms_nanos(*n);
7970            Ok((h, m, s, ns, None))
7971        }
7972        Value::Property(PropertyValue::Time {
7973            nanos_of_day,
7974            offset_seconds,
7975        }) => {
7976            let (h, m, s, ns) = hms_nanos(*nanos_of_day);
7977            Ok((
7978                h,
7979                m,
7980                s,
7981                ns,
7982                Some((temporal::TzId::Offset(*offset_seconds), *offset_seconds)),
7983            ))
7984        }
7985        Value::Property(PropertyValue::LocalDateTime {
7986            epoch_seconds,
7987            nanos,
7988        }) => {
7989            let (_, nanos_of_day) = temporal::split_epoch_seconds(*epoch_seconds);
7990            let (h, m, s, _) = hms_nanos(nanos_of_day);
7991            Ok((h, m, s, *nanos as i64, None))
7992        }
7993        Value::Property(PropertyValue::DateTime {
7994            epoch_seconds,
7995            nanos,
7996            zone,
7997        }) => {
7998            let tz = tz_from_graph(zone);
7999            let offset_seconds = temporal::resolve_offset(&tz, *epoch_seconds);
8000            let local = epoch_seconds + offset_seconds as i64;
8001            let (_, nanos_of_day) = temporal::split_epoch_seconds(local);
8002            let (h, m, s, _) = hms_nanos(nanos_of_day);
8003            Ok((h, m, s, *nanos as i64, Some((tz, offset_seconds))))
8004        }
8005        other => Err(QueryError::Type(format!(
8006            "'{key}' must be a LocalTime, Time, LocalDateTime, or DateTime, got {other:?}"
8007        ))),
8008    }
8009}
8010
8011const DATE_ALLOWED_KEYS: &[&str] = &[
8012    "year",
8013    "month",
8014    "day",
8015    "week",
8016    "dayOfWeek",
8017    "ordinalDay",
8018    "quarter",
8019    "dayOfQuarter",
8020    "date",
8021];
8022
8023fn date_from_map(m: &BTreeMap<String, Value>) -> Result<i32, QueryError> {
8024    let (year, month, day) = calendar_fields_from_map("date", m, DATE_ALLOWED_KEYS)?;
8025    temporal::epoch_day_from_ymd(year, month, day).ok_or_else(|| {
8026        QueryError::Type(format!(
8027            "{year:04}-{month:02}-{day:02} isn't a valid calendar date"
8028        ))
8029    })
8030}
8031
8032/// Computes `(year, month, day)` from a map that specifies one of four
8033/// mutually exclusive ways to pin a calendar day -- the plain calendar
8034/// form (`year`/`month`/`day`, each optionally defaulted from a `date`/
8035/// `datetime` base's own value), ISO week-date (`week`/`dayOfWeek`,
8036/// defaulted from the base's `weekYear`/`week`/`dayOfWeek`), ordinal-date
8037/// (`ordinalDay`, year defaulted from the base's `year`), or quarter-date
8038/// (`quarter`/`dayOfQuarter`, defaulted from the base's `quarter`/
8039/// `dayOfQuarter`) -- real Cypher's four alternate ways to construct a
8040/// date, all reducible to the same `(year, month, day)` triple
8041/// `epoch_day_from_ymd` needs. Shared by `date()`'s own map form and
8042/// `localdatetime()`/`datetime()`'s map forms (`allowed` differs only in
8043/// whether clock/timezone keys are also permitted in the same map -- this
8044/// function only ever looks at the date-shaped keys).
8045fn calendar_fields_from_map(
8046    caller: &str,
8047    m: &BTreeMap<String, Value>,
8048    allowed: &[&str],
8049) -> Result<(i32, u32, u32), QueryError> {
8050    if let Some(bad) = m.keys().find(|k| !allowed.contains(&k.as_str())) {
8051        return Err(QueryError::Type(format!(
8052            "{caller}({{...}}) key '{bad}' isn't a recognized field"
8053        )));
8054    }
8055    let int_field = |key: &str, value: &Value| {
8056        value_as_i64(value).ok_or_else(|| {
8057            QueryError::Type(format!("{caller}({{...}})'s '{key}' must be an integer"))
8058        })
8059    };
8060    let base_epoch_day = m
8061        .get("date")
8062        .map(|v| ("date", v))
8063        .or_else(|| m.get("datetime").map(|v| ("datetime", v)))
8064        .map(|(k, v)| extract_date_base_epoch_day(k, v))
8065        .transpose()?;
8066    let epoch_day_from_component =
8067        |prop: &str| base_epoch_day.map(|ed| temporal::date_component(ed, prop).unwrap());
8068
8069    if m.contains_key("week") || m.contains_key("dayOfWeek") {
8070        let week_year = match m.get("year") {
8071            Some(v) => i32::try_from(int_field("year", v)?).map_err(|_| {
8072                QueryError::Type(format!("{caller}({{...}})'s 'year' is out of range"))
8073            })?,
8074            None => i32::try_from(epoch_day_from_component("weekYear").ok_or_else(|| {
8075                QueryError::Type(format!("{caller}({{...}}) requires a 'year' key"))
8076            })?)
8077            .unwrap(),
8078        };
8079        let week = match m.get("week") {
8080            Some(v) => u32::try_from(int_field("week", v)?).map_err(|_| {
8081                QueryError::Type(format!("{caller}({{...}})'s 'week' is out of range"))
8082            })?,
8083            None => u32::try_from(epoch_day_from_component("week").ok_or_else(|| {
8084                QueryError::Type(format!("{caller}({{...}}) requires a 'week' key"))
8085            })?)
8086            .unwrap(),
8087        };
8088        let day_of_week = match m.get("dayOfWeek") {
8089            Some(v) => int_field("dayOfWeek", v)?,
8090            None => epoch_day_from_component("dayOfWeek").unwrap_or(1),
8091        };
8092        let epoch_day = temporal::epoch_day_from_week_fields(week_year, week, day_of_week)
8093            .ok_or_else(|| {
8094                QueryError::Type(format!(
8095                    "{caller}({{...}}) has an out-of-range week-date field"
8096                ))
8097            })?;
8098        return Ok((
8099            temporal::date_component(epoch_day, "year").unwrap() as i32,
8100            temporal::date_component(epoch_day, "month").unwrap() as u32,
8101            temporal::date_component(epoch_day, "day").unwrap() as u32,
8102        ));
8103    }
8104
8105    if m.contains_key("ordinalDay") {
8106        let year = match m.get("year") {
8107            Some(v) => i32::try_from(int_field("year", v)?).map_err(|_| {
8108                QueryError::Type(format!("{caller}({{...}})'s 'year' is out of range"))
8109            })?,
8110            None => i32::try_from(epoch_day_from_component("year").ok_or_else(|| {
8111                QueryError::Type(format!("{caller}({{...}}) requires a 'year' key"))
8112            })?)
8113            .unwrap(),
8114        };
8115        let ordinal_raw = int_field("ordinalDay", m.get("ordinalDay").unwrap())?;
8116        let ordinal_day = u32::try_from(ordinal_raw).map_err(|_| {
8117            QueryError::Type(format!("{caller}({{...}})'s 'ordinalDay' is out of range"))
8118        })?;
8119        let epoch_day =
8120            temporal::epoch_day_from_ordinal_fields(year, ordinal_day).ok_or_else(|| {
8121                QueryError::Type(format!(
8122                    "{caller}({{...}}) has an out-of-range ordinalDay field"
8123                ))
8124            })?;
8125        return Ok((
8126            year,
8127            temporal::date_component(epoch_day, "month").unwrap() as u32,
8128            temporal::date_component(epoch_day, "day").unwrap() as u32,
8129        ));
8130    }
8131
8132    if m.contains_key("quarter") || m.contains_key("dayOfQuarter") {
8133        let year = match m.get("year") {
8134            Some(v) => i32::try_from(int_field("year", v)?).map_err(|_| {
8135                QueryError::Type(format!("{caller}({{...}})'s 'year' is out of range"))
8136            })?,
8137            None => i32::try_from(epoch_day_from_component("year").ok_or_else(|| {
8138                QueryError::Type(format!("{caller}({{...}}) requires a 'year' key"))
8139            })?)
8140            .unwrap(),
8141        };
8142        let quarter = match m.get("quarter") {
8143            Some(v) => u32::try_from(int_field("quarter", v)?).map_err(|_| {
8144                QueryError::Type(format!("{caller}({{...}})'s 'quarter' is out of range"))
8145            })?,
8146            None => u32::try_from(epoch_day_from_component("quarter").ok_or_else(|| {
8147                QueryError::Type(format!("{caller}({{...}}) requires a 'quarter' key"))
8148            })?)
8149            .unwrap(),
8150        };
8151        let day_of_quarter = match m.get("dayOfQuarter") {
8152            Some(v) => int_field("dayOfQuarter", v)?,
8153            None => epoch_day_from_component("dayOfQuarter").unwrap_or(1),
8154        };
8155        let epoch_day = temporal::epoch_day_from_quarter_fields(year, quarter, day_of_quarter)
8156            .ok_or_else(|| {
8157                QueryError::Type(format!(
8158                    "{caller}({{...}}) has an out-of-range quarter-date field"
8159                ))
8160            })?;
8161        return Ok((
8162            year,
8163            temporal::date_component(epoch_day, "month").unwrap() as u32,
8164            temporal::date_component(epoch_day, "day").unwrap() as u32,
8165        ));
8166    }
8167
8168    let year_raw = match m.get("year") {
8169        Some(v) => int_field("year", v)?,
8170        None => epoch_day_from_component("year")
8171            .ok_or_else(|| QueryError::Type(format!("{caller}({{...}}) requires a 'year' key")))?,
8172    };
8173    let year = i32::try_from(year_raw).map_err(|_| {
8174        QueryError::Type(format!(
8175            "{caller}({{...}})'s 'year' is out of range: {year_raw}"
8176        ))
8177    })?;
8178    let month_raw = match m.get("month") {
8179        Some(v) => int_field("month", v)?,
8180        None => epoch_day_from_component("month").unwrap_or(1),
8181    };
8182    let month = u32::try_from(month_raw).map_err(|_| {
8183        QueryError::Type(format!(
8184            "{caller}({{...}})'s 'month' is out of range: {month_raw}"
8185        ))
8186    })?;
8187    let day_raw = match m.get("day") {
8188        Some(v) => int_field("day", v)?,
8189        None => epoch_day_from_component("day").unwrap_or(1),
8190    };
8191    let day = u32::try_from(day_raw).map_err(|_| {
8192        QueryError::Type(format!(
8193            "{caller}({{...}})'s 'day' is out of range: {day_raw}"
8194        ))
8195    })?;
8196    Ok((year, month, day))
8197}
8198
8199/// `duration(...)` — a string (ISO-8601 `'P...'` text, `temporal::
8200/// parse_duration`) or a map (`duration({days: 14, hours: 16})`,
8201/// `temporal::normalize_duration`). No zero-arg form (real Cypher has
8202/// none either — a duration has no "current" value the way a date/time
8203/// does).
8204fn duration_builtin(args: &[Value]) -> Result<Value, QueryError> {
8205    if args.len() != 1 {
8206        return Err(QueryError::Semantic(format!(
8207            "duration() expects exactly one argument, got {}",
8208            args.len()
8209        )));
8210    }
8211    let arg = &args[0];
8212    if matches!(arg, Value::Null) {
8213        return Ok(Value::Null);
8214    }
8215    let (months, days, seconds, nanos) = if let Some(s) = as_arith_str(arg) {
8216        temporal::parse_duration(s).ok_or_else(|| {
8217            QueryError::Type(format!(
8218                "'{s}' isn't a duration string MarsDB can parse -- only ISO-8601 'PnYnMnWnDTnHnMnS' text is \
8219                 supported, not the alternate combined date-time duration syntax"
8220            ))
8221        })?
8222    } else if let Value::Map(m) = arg {
8223        temporal::normalize_duration(duration_fields_from_map(m)?)
8224    } else {
8225        return Err(QueryError::Type(format!(
8226            "duration() doesn't support this argument: {arg:?}"
8227        )));
8228    };
8229    Ok(Value::Property(PropertyValue::Duration {
8230        months,
8231        days,
8232        seconds,
8233        nanos,
8234    }))
8235}
8236
8237fn duration_fields_from_map(
8238    m: &BTreeMap<String, Value>,
8239) -> Result<temporal::DurationFields, QueryError> {
8240    const ALLOWED: &[&str] = &[
8241        "years",
8242        "months",
8243        "weeks",
8244        "days",
8245        "hours",
8246        "minutes",
8247        "seconds",
8248        "milliseconds",
8249        "microseconds",
8250        "nanoseconds",
8251    ];
8252    if let Some(bad) = m.keys().find(|k| !ALLOWED.contains(&k.as_str())) {
8253        return Err(QueryError::Type(format!(
8254            "duration({{...}}) key '{bad}' isn't a recognized duration unit"
8255        )));
8256    }
8257    let field = |key: &str| -> Result<f64, QueryError> {
8258        match m.get(key) {
8259            None => Ok(0.0),
8260            Some(v) => value_as_f64(v).ok_or_else(|| {
8261                QueryError::Type(format!("duration({{...}})'s '{key}' must be a number"))
8262            }),
8263        }
8264    };
8265    Ok(temporal::DurationFields {
8266        years: field("years")?,
8267        months: field("months")?,
8268        weeks: field("weeks")?,
8269        days: field("days")?,
8270        hours: field("hours")?,
8271        minutes: field("minutes")?,
8272        seconds: field("seconds")?,
8273        milliseconds: field("milliseconds")?,
8274        microseconds: field("microseconds")?,
8275        nanoseconds: field("nanoseconds")?,
8276    })
8277}
8278
8279/// Sums the 3 sub-second map keys (`millisecond`/`microsecond`/
8280/// `nanosecond`) shared by every one-of-day-or-later temporal map
8281/// constructor into one nanosecond count -- each key independently
8282/// *additive* (matching real Cypher's own construction semantics,
8283/// e.g. `{millisecond: 645, nanosecond: 123}` is `645ms + 123ns`, not
8284/// "645ms, ignore the usual nanosecond digit position"), separate from
8285/// `duration`'s own `nanoseconds` field of the same name.
8286///
8287/// `base_fraction_ns` (`0..1_000_000_000`) is the fractional-second
8288/// part of whatever this map is *overriding* (a `time`/`datetime`
8289/// projection key, or a `.truncate()` call's already-truncated value)
8290/// -- `0` for plain from-scratch construction, where there's no base to
8291/// inherit from. Any of the 3 keys the map doesn't set defaults to that
8292/// *digit group* of the base (millisecond/microsecond/nanosecond each
8293/// their own `0..999` slice), not to `0` outright -- found as a real
8294/// bug: `{nanosecond: 2}` alone on a base with a real millisecond value
8295/// was silently dropping that millisecond instead of keeping it, only
8296/// the nanosecond digit was meant to change.
8297fn sub_second_nanos_from_map(
8298    base_fraction_ns: i64,
8299    m: &BTreeMap<String, Value>,
8300) -> Result<i64, QueryError> {
8301    let base_ms = base_fraction_ns / 1_000_000;
8302    let base_us = (base_fraction_ns / 1_000) % 1000;
8303    let base_ns = base_fraction_ns % 1000;
8304    let ms = int_field(m, "millisecond", base_ms)?;
8305    let us = int_field(m, "microsecond", base_us)?;
8306    let ns = int_field(m, "nanosecond", base_ns)?;
8307    Ok(ms * 1_000_000 + us * 1_000 + ns)
8308}
8309
8310fn int_field(m: &BTreeMap<String, Value>, key: &str, default: i64) -> Result<i64, QueryError> {
8311    match m.get(key) {
8312        None => Ok(default),
8313        Some(v) => {
8314            value_as_i64(v).ok_or_else(|| QueryError::Type(format!("'{key}' must be an integer")))
8315        }
8316    }
8317}
8318
8319/// Computes `(hour, minute, second, nanos, offset_seconds)` for a
8320/// `localtime`/`time`/`localdatetime`/`datetime` map constructor -- a
8321/// `time`/`datetime` key (if present) projects its clock fields as the
8322/// default, explicit `hour`/`minute`/`second`/`millisecond`/
8323/// `microsecond`/`nanosecond` keys override individual fields on top of
8324/// that (`{time: other, second: 42}` keeps everything from `other`
8325/// except `second`). No base key falls back to all-zero defaults,
8326/// matching the plain (non-projecting) map form.
8327///
8328/// If the base carries an offset (`Time`/`DateTime`) and an explicit
8329/// `timezone` key names a *different* one, the wall-clock is shifted
8330/// first to preserve the same instant (`{time: other, timezone:
8331/// '+05:00'}` on a `+01:00` base advances the hour by 4) -- real
8332/// Cypher's rule, confirmed against Temporal3's own examples -- and
8333/// only *then* do explicit hour/minute/second overrides apply, on top
8334/// of the shifted result, not the original.
8335/// `epoch_day` is the calendar date the resulting clock fields will be
8336/// combined with -- only needed to resolve a *shift into a named zone*
8337/// (its real, DST-aware offset depends on the date, TCK's Temporal3 [9]
8338/// row: `{time: t+01:00, second: 42, timezone: 'Pacific/Honolulu'}`),
8339/// `None` for callers with no date at all (`time()`'s own map form,
8340/// which can't shift into a named zone regardless -- its caller rejects
8341/// that case itself) or that don't care about the resolved zone
8342/// (`localdatetime()`'s map form, which discards it).
8343/// The 5th element is `Some((effective_zone, effective_offset))` --
8344/// `effective_zone` preserves a `Named` base's identity when no
8345/// explicit `timezone` override is given (needed by `DATETIME`, which
8346/// can hold one); `effective_offset` is always a plain resolved number,
8347/// usable directly by a caller that structurally can't hold a zone name
8348/// (`TIME`) regardless of which case produced it.
8349fn clock_fields_from_map(
8350    m: &BTreeMap<String, Value>,
8351    epoch_day: Option<i32>,
8352) -> Result<ClockBase, QueryError> {
8353    let (base_h, base_m, base_s, base_ns, base_zone) = if let Some(v) = m.get("time") {
8354        extract_time_base("time", v)?
8355    } else if let Some(v) = m.get("datetime") {
8356        extract_time_base("datetime", v)?
8357    } else {
8358        (0, 0, 0, 0, None)
8359    };
8360    let has_explicit_timezone = m.contains_key("timezone");
8361    let effective_zone = match m.get("timezone") {
8362        Some(v) => Some(timezone_value_to_tzid(v)?),
8363        // No explicit override -- preserve the base's own zone
8364        // *identity* (a `Named` zone stays `Named`), not just its
8365        // resolved offset (TCK's Temporal3 [9]/[11] `{datetime: other}`
8366        // rows, where `other` is itself a named-zone value).
8367        None => base_zone.as_ref().map(|(tz, _)| tz.clone()),
8368    };
8369    // The wall-clock is only ever *shifted* by an *explicit* `timezone`
8370    // override that actually changes the zone -- with no override, the
8371    // literal local time passes straight through unchanged even if the
8372    // base's own zone's real offset differs for the (possibly
8373    // day-overridden) new date, e.g. a DST boundary crossed by a `day`
8374    // override (TCK's Temporal3 [10]: a `Named` base carried through
8375    // with no `timezone` key keeps its `12:00` wall-clock as `12:00`,
8376    // just re-displayed with whatever offset that zone now resolves to
8377    // -- it does *not* shift to a different wall-clock hour).
8378    let base_nanos_of_day =
8379        base_h * 3_600_000_000_000 + base_m * 60_000_000_000 + base_s * 1_000_000_000 + base_ns;
8380    let (base_h, base_m, base_s, base_ns, effective_offset) = if has_explicit_timezone {
8381        // The base's own offset, re-resolved against the *new* date --
8382        // not its own original instant's offset (`extract_time_base`'s
8383        // `Named` resolution, which used the *source* value's own
8384        // epoch_seconds/date, not necessarily this one -- a `day`
8385        // override can move the result to a different date than the
8386        // base's, potentially across a DST boundary for the *same*
8387        // zone, TCK's Temporal3 [10] row 337).
8388        let from_offset = match base_zone.as_ref() {
8389            Some((temporal::TzId::Offset(o), _)) => Some(*o),
8390            Some((zone @ temporal::TzId::Named(_), resolved)) => Some(match epoch_day {
8391                Some(ed) => temporal::resolve_offset(
8392                    zone,
8393                    temporal::combine_epoch_day_and_nanos_of_day(ed, base_nanos_of_day),
8394                ),
8395                None => *resolved,
8396            }),
8397            None => None,
8398        };
8399        let to_offset = match (from_offset, effective_zone.as_ref(), epoch_day) {
8400            (Some(_), Some(temporal::TzId::Offset(to)), _) => Some(*to),
8401            (Some(from), Some(zone @ temporal::TzId::Named(_)), Some(ed)) => {
8402                let approx_epoch_seconds =
8403                    temporal::combine_epoch_day_and_nanos_of_day(ed, base_nanos_of_day)
8404                        - from as i64;
8405                Some(temporal::resolve_offset(zone, approx_epoch_seconds))
8406            }
8407            _ => None,
8408        };
8409        match (from_offset, to_offset) {
8410            (Some(from), Some(to)) if from != to => {
8411                let shifted = (base_nanos_of_day + (to - from) as i64 * 1_000_000_000)
8412                    .rem_euclid(86_400_000_000_000);
8413                (
8414                    shifted / 3_600_000_000_000,
8415                    (shifted / 60_000_000_000) % 60,
8416                    (shifted / 1_000_000_000) % 60,
8417                    shifted % 1_000_000_000,
8418                    to_offset.unwrap_or(0),
8419                )
8420            }
8421            _ => (base_h, base_m, base_s, base_ns, to_offset.unwrap_or(0)),
8422        }
8423    } else {
8424        // No override -- the resolved offset is just the base's own
8425        // (unchanged, no re-resolution -- a caller that can't hold a
8426        // zone name, `TIME`, degrades a `Named` base to this number
8427        // silently, TCK's Temporal3 [3] row 125: `{time: t}` where `t`
8428        // is a named-zone `DateTime` -> the plain offset, no error).
8429        (
8430            base_h,
8431            base_m,
8432            base_s,
8433            base_ns,
8434            base_zone.as_ref().map_or(0, |(_, o)| *o),
8435        )
8436    };
8437    Ok((
8438        int_field(m, "hour", base_h)?,
8439        int_field(m, "minute", base_m)?,
8440        int_field(m, "second", base_s)?,
8441        sub_second_nanos_from_map(base_ns, m)?,
8442        effective_zone.map(|z| (z, effective_offset)),
8443    ))
8444}
8445
8446/// `localtime(...)` -- zero args (now, UTC), a string (`temporal::
8447/// parse_local_time`), a map (`localtime({hour: 21, minute: 40, ...})`,
8448/// optionally projected from another temporal value via a `time` key),
8449/// or another `LocalTime` (identity, e.g. round-tripping through
8450/// `toString`).
8451fn local_time_builtin(args: &[Value], now: temporal::NowSnapshot) -> Result<Value, QueryError> {
8452    if args.len() > 1 {
8453        return Err(QueryError::Semantic(format!(
8454            "localtime() expects zero or one argument, got {}",
8455            args.len()
8456        )));
8457    }
8458    let Some(arg) = args.first() else {
8459        return Ok(Value::Property(PropertyValue::LocalTime(now.nanos_of_day)));
8460    };
8461    if matches!(arg, Value::Null) {
8462        return Ok(Value::Null);
8463    }
8464    if let Value::Property(PropertyValue::LocalTime(t)) = arg {
8465        return Ok(Value::Property(PropertyValue::LocalTime(*t)));
8466    }
8467    // `localtime(otherTemporal)` -- a bare `Time`/`LocalDateTime`/
8468    // `DateTime` argument projects its own time-of-day part (offset
8469    // dropped, same as `{time: otherTemporal}`), TCK's Temporal3 [2].
8470    if matches!(
8471        arg,
8472        Value::Property(
8473            PropertyValue::Time { .. }
8474                | PropertyValue::LocalDateTime { .. }
8475                | PropertyValue::DateTime { .. }
8476        )
8477    ) {
8478        let (hour, minute, second, nanos, _) = extract_time_base("localtime() argument", arg)?;
8479        let t = temporal::local_time_nanos_from_fields(hour, minute, second, nanos).ok_or_else(
8480            || QueryError::Type("localtime() argument has an out-of-range field".into()),
8481        )?;
8482        return Ok(Value::Property(PropertyValue::LocalTime(t)));
8483    }
8484    if let Some(s) = as_arith_str(arg) {
8485        let t = temporal::parse_local_time(s).ok_or_else(|| {
8486            QueryError::Type(format!("'{s}' isn't a local time string MarsDB can parse"))
8487        })?;
8488        return Ok(Value::Property(PropertyValue::LocalTime(t)));
8489    }
8490    if let Value::Map(m) = arg {
8491        const ALLOWED: &[&str] = &[
8492            "hour",
8493            "minute",
8494            "second",
8495            "millisecond",
8496            "microsecond",
8497            "nanosecond",
8498            "time",
8499        ];
8500        if let Some(bad) = m.keys().find(|k| !ALLOWED.contains(&k.as_str())) {
8501            return Err(QueryError::Type(format!(
8502                "localtime({{...}}) key '{bad}' isn't a recognized field"
8503            )));
8504        }
8505        let (hour, minute, second, nanos, _) = clock_fields_from_map(m, None)?;
8506        let t = temporal::local_time_nanos_from_fields(hour, minute, second, nanos)
8507            .ok_or_else(|| QueryError::Type("localtime({...}) has an out-of-range field".into()))?;
8508        return Ok(Value::Property(PropertyValue::LocalTime(t)));
8509    }
8510    Err(QueryError::Type(format!(
8511        "localtime() doesn't support this argument: {arg:?}"
8512    )))
8513}
8514
8515/// `time(...)` -- same shapes as `localtime(...)`, but every form
8516/// (except identity) requires a `timezone` map key / string offset
8517/// suffix. A bracketed named-zone suffix (`[Europe/Stockholm]`) gets a
8518/// specific "not supported" error rather than the generic parse-failure
8519/// message, since that's a real (if out of scope) Cypher form, not
8520/// malformed input.
8521fn time_builtin(args: &[Value], now: temporal::NowSnapshot) -> Result<Value, QueryError> {
8522    if args.len() > 1 {
8523        return Err(QueryError::Semantic(format!(
8524            "time() expects zero or one argument, got {}",
8525            args.len()
8526        )));
8527    }
8528    let Some(arg) = args.first() else {
8529        return Ok(Value::Property(PropertyValue::Time {
8530            nanos_of_day: now.nanos_of_day,
8531            offset_seconds: 0,
8532        }));
8533    };
8534    if matches!(arg, Value::Null) {
8535        return Ok(Value::Null);
8536    }
8537    if let Value::Property(PropertyValue::Time {
8538        nanos_of_day,
8539        offset_seconds,
8540    }) = arg
8541    {
8542        return Ok(Value::Property(PropertyValue::Time {
8543            nanos_of_day: *nanos_of_day,
8544            offset_seconds: *offset_seconds,
8545        }));
8546    }
8547    // `time(otherTemporal)` -- a bare `LocalTime`/`LocalDateTime`/
8548    // `DateTime` argument projects its own time part, defaulting the
8549    // offset to UTC when the source has none (`LocalTime`/
8550    // `LocalDateTime`), same as `{time: otherTemporal}` (TCK's
8551    // Temporal3 [3]).
8552    if matches!(
8553        arg,
8554        Value::Property(
8555            PropertyValue::LocalTime(_)
8556                | PropertyValue::LocalDateTime { .. }
8557                | PropertyValue::DateTime { .. }
8558        )
8559    ) {
8560        let (hour, minute, second, nanos, zone) = extract_time_base("time() argument", arg)?;
8561        let nanos_of_day = temporal::local_time_nanos_from_fields(hour, minute, second, nanos)
8562            .ok_or_else(|| QueryError::Type("time() argument has an out-of-range field".into()))?;
8563        return Ok(Value::Property(PropertyValue::Time {
8564            nanos_of_day,
8565            // `TIME` structurally can't carry a zone name -- degrades a
8566            // `Named` source to its resolved numeric offset (TCK's
8567            // Temporal3 [3] `datetime({..., timezone: 'Europe/
8568            // Stockholm'})` -> `time(other)` = `'12:00+01:00'`, the
8569            // offset alone, no bracket).
8570            offset_seconds: zone.map_or(0, |(_, o)| o),
8571        }));
8572    }
8573    if let Some(s) = as_arith_str(arg) {
8574        if s.contains('[') {
8575            return Err(QueryError::Type(
8576                "time('...'): named timezones (e.g. '[Europe/Stockholm]') aren't supported, only a fixed UTC \
8577                 offset like '+01:00'"
8578                    .into(),
8579            ));
8580        }
8581        let (nanos_of_day, offset_seconds) = temporal::parse_time(s).ok_or_else(|| {
8582            QueryError::Type(format!("'{s}' isn't a time string MarsDB can parse"))
8583        })?;
8584        return Ok(Value::Property(PropertyValue::Time {
8585            nanos_of_day,
8586            offset_seconds,
8587        }));
8588    }
8589    if let Value::Map(m) = arg {
8590        const ALLOWED: &[&str] = &[
8591            "hour",
8592            "minute",
8593            "second",
8594            "millisecond",
8595            "microsecond",
8596            "nanosecond",
8597            "timezone",
8598            "time",
8599        ];
8600        if let Some(bad) = m.keys().find(|k| !ALLOWED.contains(&k.as_str())) {
8601            return Err(QueryError::Type(format!(
8602                "time({{...}}) key '{bad}' isn't a recognized field"
8603            )));
8604        }
8605        let (hour, minute, second, nanos, zone) = clock_fields_from_map(m, None)?;
8606        let offset_seconds = match zone {
8607            None => 0,
8608            // A `Named` zone reaching here with no *explicit* `timezone`
8609            // key was just carried through from a projected `time`/
8610            // `datetime` base (`{time: namedZoneDateTime}`) -- `TIME`
8611            // can't hold a name, so it silently degrades to the base's
8612            // own resolved offset, same as the cross-type positional
8613            // form already does (TCK's Temporal3 [3] row 125). An
8614            // *explicit* named-zone request, though, is a real error --
8615            // there's no calendar date here to resolve it against.
8616            Some((_, o)) if !m.contains_key("timezone") => o,
8617            Some((temporal::TzId::Offset(o), _)) => o,
8618            Some((temporal::TzId::Named(name), _)) => {
8619                return Err(QueryError::Type(format!(
8620                    "'timezone': '{name}' looks like a named timezone (e.g. 'Europe/Stockholm') -- TIME has \
8621                     no calendar date to resolve a named zone's DST-dependent offset against, only a fixed \
8622                     UTC offset like '+01:00' is supported"
8623                )));
8624            }
8625        };
8626        let nanos_of_day = temporal::local_time_nanos_from_fields(hour, minute, second, nanos)
8627            .ok_or_else(|| QueryError::Type("time({...}) has an out-of-range field".into()))?;
8628        return Ok(Value::Property(PropertyValue::Time {
8629            nanos_of_day,
8630            offset_seconds,
8631        }));
8632    }
8633    Err(QueryError::Type(format!(
8634        "time() doesn't support this argument: {arg:?}"
8635    )))
8636}
8637
8638/// `{timezone: '+01:00'}`'s value -- a fixed UTC offset, or an IANA zone
8639/// name (`'Europe/Stockholm'`). Both forms are always syntactically
8640/// disjoint (an offset always starts with `+`/`-`/`Z`, a zone name never
8641/// does), so there's no ambiguity to resolve between them. A caller that
8642/// can't accept a `Named` zone (`time_builtin`'s map form -- `TIME` has
8643/// no calendar date to resolve a named zone's DST-dependent offset
8644/// against) rejects it itself, after this succeeds.
8645fn timezone_value_to_tzid(v: &Value) -> Result<temporal::TzId, QueryError> {
8646    let s = as_arith_str(v).ok_or_else(|| {
8647        QueryError::Type(
8648            "'timezone' must be a string offset or IANA zone name, e.g. '+01:00' or \
8649             'Europe/Stockholm'"
8650                .into(),
8651        )
8652    })?;
8653    if let Some(offset) = temporal::parse_offset_seconds(s) {
8654        return Ok(temporal::TzId::Offset(offset));
8655    }
8656    if temporal::parse_timezone_name(s).is_some() {
8657        return Ok(temporal::TzId::Named(s.to_string()));
8658    }
8659    Err(QueryError::Type(format!(
8660        "'timezone': '{s}' isn't a valid UTC offset or a recognized IANA zone name"
8661    )))
8662}
8663
8664/// `localdatetime(...)` -- zero args (now, UTC), a string, a map
8665/// (`localdatetime({year, month, day, hour, minute, second, ...})`), or
8666/// another `LocalDateTime` (identity).
8667fn local_date_time_builtin(
8668    args: &[Value],
8669    now: temporal::NowSnapshot,
8670) -> Result<Value, QueryError> {
8671    if args.len() > 1 {
8672        return Err(QueryError::Semantic(format!(
8673            "localdatetime() expects zero or one argument, got {}",
8674            args.len()
8675        )));
8676    }
8677    let Some(arg) = args.first() else {
8678        return Ok(Value::Property(PropertyValue::LocalDateTime {
8679            epoch_seconds: now.epoch_seconds,
8680            nanos: now.nanos,
8681        }));
8682    };
8683    if matches!(arg, Value::Null) {
8684        return Ok(Value::Null);
8685    }
8686    if let Value::Property(PropertyValue::LocalDateTime {
8687        epoch_seconds,
8688        nanos,
8689    }) = arg
8690    {
8691        return Ok(Value::Property(PropertyValue::LocalDateTime {
8692            epoch_seconds: *epoch_seconds,
8693            nanos: *nanos,
8694        }));
8695    }
8696    // `localdatetime(otherTemporal)` -- a bare `DateTime` argument drops
8697    // its offset and keeps its local date+time, same as
8698    // `{datetime: otherTemporal}` (TCK's Temporal3 [7]).
8699    if matches!(arg, Value::Property(PropertyValue::DateTime { .. })) {
8700        let epoch_day = extract_date_base_epoch_day("localdatetime() argument", arg)?;
8701        let year = temporal::date_component(epoch_day, "year").unwrap() as i32;
8702        let month = temporal::date_component(epoch_day, "month").unwrap() as u32;
8703        let day = temporal::date_component(epoch_day, "day").unwrap() as u32;
8704        let (hour, minute, second, nanos, _) = extract_time_base("localdatetime() argument", arg)?;
8705        let (epoch_seconds, nanos) =
8706            temporal::local_date_time_from_fields(temporal::CalendarDateTime {
8707                year,
8708                month,
8709                day,
8710                hour,
8711                minute,
8712                second,
8713                nanos,
8714            })
8715            .ok_or_else(|| {
8716                QueryError::Type("localdatetime() argument has an out-of-range field".into())
8717            })?;
8718        return Ok(Value::Property(PropertyValue::LocalDateTime {
8719            epoch_seconds,
8720            nanos,
8721        }));
8722    }
8723    if let Some(s) = as_arith_str(arg) {
8724        let (epoch_seconds, nanos) = temporal::parse_local_date_time(s).ok_or_else(|| {
8725            QueryError::Type(format!(
8726                "'{s}' isn't a local date-time string MarsDB can parse"
8727            ))
8728        })?;
8729        return Ok(Value::Property(PropertyValue::LocalDateTime {
8730            epoch_seconds,
8731            nanos,
8732        }));
8733    }
8734    if let Value::Map(m) = arg {
8735        let (year, month, day) =
8736            calendar_fields_from_map("localdatetime", m, DATE_TIME_ALLOWED_KEYS)?;
8737        let (hour, minute, second, nanos, _) = clock_fields_from_map(m, None)?;
8738        let (epoch_seconds, nanos) =
8739            temporal::local_date_time_from_fields(temporal::CalendarDateTime {
8740                year,
8741                month,
8742                day,
8743                hour,
8744                minute,
8745                second,
8746                nanos,
8747            })
8748            .ok_or_else(|| {
8749                QueryError::Type("localdatetime({...}) has an out-of-range field".into())
8750            })?;
8751        return Ok(Value::Property(PropertyValue::LocalDateTime {
8752            epoch_seconds,
8753            nanos,
8754        }));
8755    }
8756    Err(QueryError::Type(format!(
8757        "localdatetime() doesn't support this argument: {arg:?}"
8758    )))
8759}
8760
8761const DATE_TIME_ALLOWED_KEYS: &[&str] = &[
8762    "year",
8763    "month",
8764    "day",
8765    "week",
8766    "dayOfWeek",
8767    "ordinalDay",
8768    "quarter",
8769    "dayOfQuarter",
8770    "hour",
8771    "minute",
8772    "second",
8773    "millisecond",
8774    "microsecond",
8775    "nanosecond",
8776    "timezone",
8777    "date",
8778    "time",
8779    "datetime",
8780];
8781
8782/// `datetime(...)` -- zero args (now, UTC), a string, a map
8783/// (`datetime({year, ..., timezone: '+01:00'})` or `{..., timezone:
8784/// 'Europe/Stockholm'}`), or another `DateTime` (identity). Requires a
8785/// `timezone` for every constructed form except identity (defaults to
8786/// UTC, `TzId::Offset(0)`, if the map omits it -- matches `date()`'s own
8787/// "no timezone info -> UTC" convention).
8788fn date_time_builtin(args: &[Value], now: temporal::NowSnapshot) -> Result<Value, QueryError> {
8789    if args.len() > 1 {
8790        return Err(QueryError::Semantic(format!(
8791            "datetime() expects zero or one argument, got {}",
8792            args.len()
8793        )));
8794    }
8795    let Some(arg) = args.first() else {
8796        return Ok(Value::Property(PropertyValue::DateTime {
8797            epoch_seconds: now.epoch_seconds,
8798            nanos: now.nanos,
8799            zone: GraphTzId::Offset(0),
8800        }));
8801    };
8802    if matches!(arg, Value::Null) {
8803        return Ok(Value::Null);
8804    }
8805    if let Value::Property(PropertyValue::DateTime {
8806        epoch_seconds,
8807        nanos,
8808        zone,
8809    }) = arg
8810    {
8811        return Ok(Value::Property(PropertyValue::DateTime {
8812            epoch_seconds: *epoch_seconds,
8813            nanos: *nanos,
8814            zone: zone.clone(),
8815        }));
8816    }
8817    // `datetime(otherLocalDateTime)` -- a bare `LocalDateTime` argument
8818    // has no zone of its own, defaults to UTC, same as `{datetime:
8819    // otherLocalDateTime}` (TCK's Temporal3 [11]).
8820    if let Value::Property(PropertyValue::LocalDateTime {
8821        epoch_seconds,
8822        nanos,
8823    }) = arg
8824    {
8825        return Ok(Value::Property(PropertyValue::DateTime {
8826            epoch_seconds: *epoch_seconds,
8827            nanos: *nanos,
8828            zone: GraphTzId::Offset(0),
8829        }));
8830    }
8831    if let Some(s) = as_arith_str(arg) {
8832        let (epoch_seconds, nanos, zone) = temporal::parse_date_time(s).ok_or_else(|| {
8833            QueryError::Type(format!("'{s}' isn't a date-time string MarsDB can parse"))
8834        })?;
8835        return Ok(Value::Property(PropertyValue::DateTime {
8836            epoch_seconds,
8837            nanos,
8838            zone: tz_to_graph(zone),
8839        }));
8840    }
8841    if let Value::Map(m) = arg {
8842        let (year, month, day) = calendar_fields_from_map("datetime", m, DATE_TIME_ALLOWED_KEYS)?;
8843        let epoch_day = temporal::epoch_day_from_ymd(year, month, day);
8844        let (hour, minute, second, nanos, zone) = clock_fields_from_map(m, epoch_day)?;
8845        let zone = zone.map_or(temporal::TzId::Offset(0), |(z, _)| z);
8846        let (epoch_seconds, nanos) = temporal::date_time_from_fields(
8847            temporal::CalendarDateTime {
8848                year,
8849                month,
8850                day,
8851                hour,
8852                minute,
8853                second,
8854                nanos,
8855            },
8856            &zone,
8857        )
8858        .ok_or_else(|| QueryError::Type("datetime({...}) has an out-of-range field".into()))?;
8859        return Ok(Value::Property(PropertyValue::DateTime {
8860            epoch_seconds,
8861            nanos,
8862            zone: tz_to_graph(zone),
8863        }));
8864    }
8865    Err(QueryError::Type(format!(
8866        "datetime() doesn't support this argument: {arg:?}"
8867    )))
8868}
8869
8870/// Reduces any of the 5 non-`Duration` temporal types to `(epoch_day,
8871/// nanos_of_day, offset_seconds)`, each independently `None` when that
8872/// value has no such component -- e.g. `LocalTime` is `(None, Some(_),
8873/// None)`, bare `Date` is `(Some(_), None, None)`. `DateTime`'s
8874/// date/time components use its *local* (offset-adjusted) reading,
8875/// matching every other `DateTime` component access (see
8876/// `date_time_component`'s docs); its real offset is *also* returned
8877/// (not disregarded) since `duration.between`'s own instant-aware
8878/// reconciliation needs it when both operands carry one -- see
8879/// `temporal::between_components`'s docs for exactly when it applies.
8880fn between_operand(name: &str, v: &Value) -> Result<BetweenOperand, QueryError> {
8881    match v {
8882        Value::Property(PropertyValue::Date(d)) => Ok((Some(*d), None, None)),
8883        Value::Property(PropertyValue::LocalTime(n)) => Ok((None, Some(*n), None)),
8884        Value::Property(PropertyValue::Time {
8885            nanos_of_day,
8886            offset_seconds,
8887        }) => Ok((
8888            None,
8889            Some(*nanos_of_day),
8890            Some(temporal::TzId::Offset(*offset_seconds)),
8891        )),
8892        Value::Property(PropertyValue::LocalDateTime {
8893            epoch_seconds,
8894            nanos,
8895        }) => {
8896            let (d, n) = temporal::split_epoch_seconds(*epoch_seconds);
8897            Ok((Some(d), Some(n + *nanos as i64), None))
8898        }
8899        Value::Property(PropertyValue::DateTime {
8900            epoch_seconds,
8901            nanos,
8902            zone,
8903        }) => {
8904            let tz = tz_from_graph(zone);
8905            let offset_seconds = temporal::resolve_offset(&tz, *epoch_seconds);
8906            let local = epoch_seconds + offset_seconds as i64;
8907            let (d, n) = temporal::split_epoch_seconds(local);
8908            Ok((Some(d), Some(n + *nanos as i64), Some(tz)))
8909        }
8910        other => Err(QueryError::Type(format!(
8911            "{name}() needs a Date, LocalTime, Time, LocalDateTime, or DateTime, got {other:?}"
8912        ))),
8913    }
8914}
8915
8916/// `(epoch_day, nanos_of_day, zone)`, see `between_operand`'s docs.
8917type BetweenOperand = (Option<i32>, Option<i64>, Option<temporal::TzId>);
8918
8919/// `(a_epoch_day, a_nanos_of_day, a_zone, b_epoch_day,
8920/// b_nanos_of_day, b_zone) -> DurationParts` -- the shape
8921/// every `temporal::duration_between`/`duration_in_months`/
8922/// `duration_in_days`/`duration_in_seconds` function shares.
8923type BetweenFn = fn(
8924    Option<i32>,
8925    Option<i64>,
8926    Option<&temporal::TzId>,
8927    Option<i32>,
8928    Option<i64>,
8929    Option<&temporal::TzId>,
8930) -> temporal::DurationParts;
8931
8932/// Shared dispatch for `duration.between`/`.inMonths`/`.inDays`/
8933/// `.inSeconds` -- all 4 take exactly 2 temporal args and differ only
8934/// in which `temporal.rs` decomposition function turns the pair into a
8935/// `Duration`.
8936fn duration_between_builtin(name: &str, args: &[Value], f: BetweenFn) -> Result<Value, QueryError> {
8937    if args.len() != 2 {
8938        return Err(QueryError::Semantic(format!(
8939            "{name}() expects exactly two arguments, got {}",
8940            args.len()
8941        )));
8942    }
8943    if matches!(args[0], Value::Null) || matches!(args[1], Value::Null) {
8944        return Ok(Value::Null);
8945    }
8946    let (a_date, a_time, a_zone) = between_operand(name, &args[0])?;
8947    let (b_date, b_time, b_zone) = between_operand(name, &args[1])?;
8948    Ok(duration_value(f(
8949        a_date,
8950        a_time,
8951        a_zone.as_ref(),
8952        b_date,
8953        b_time,
8954        b_zone.as_ref(),
8955    )))
8956}
8957
8958/// `<type>.truncate(unit, value, map?)`'s first two/three args -- `unit`
8959/// is a string literal, `value` the source temporal value, and the
8960/// trailing map (if present and non-null) carries field overrides
8961/// applied *after* truncation.
8962type TruncateArgs<'a> = (&'a str, &'a Value, Option<&'a BTreeMap<String, Value>>);
8963
8964fn parse_truncate_args<'a>(name: &str, args: &'a [Value]) -> Result<TruncateArgs<'a>, QueryError> {
8965    if args.len() < 2 || args.len() > 3 {
8966        return Err(QueryError::Semantic(format!(
8967            "{name}() expects 2 or 3 arguments, got {}",
8968            args.len()
8969        )));
8970    }
8971    let unit = as_arith_str(&args[0]).ok_or_else(|| {
8972        QueryError::Type(format!("{name}()'s first argument must be a unit string"))
8973    })?;
8974    let map = match args.get(2) {
8975        None | Some(Value::Null) => None,
8976        Some(Value::Map(m)) => Some(m),
8977        Some(other) => {
8978            return Err(QueryError::Type(format!(
8979                "{name}()'s third argument must be a map, got {other:?}"
8980            )))
8981        }
8982    };
8983    Ok((unit, &args[1], map))
8984}
8985
8986/// `year`/`month`/`day`/`dayOfWeek` overrides shared by every
8987/// `.truncate()` builtin's optional trailing map -- any key the map
8988/// doesn't set keeps the truncated base's own value (`date.truncate(
8989/// 'month', d, {day: 5})` keeps the truncated year/month, only `day`
8990/// is overridden). `dayOfWeek` applies *after* year/month/day (moving
8991/// within the resulting date's own ISO week, see `set_iso_weekday`'s
8992/// docs) -- other week/quarter/ordinal-day override keys stay
8993/// unsupported, the same pre-existing construction gap as `date_from_map`.
8994fn apply_date_overrides(
8995    base_epoch_day: i32,
8996    map: Option<&BTreeMap<String, Value>>,
8997) -> Result<i32, QueryError> {
8998    let base_y = temporal::date_component(base_epoch_day, "year").unwrap();
8999    let base_m = temporal::date_component(base_epoch_day, "month").unwrap();
9000    let base_d = temporal::date_component(base_epoch_day, "day").unwrap();
9001    let Some(m) = map else {
9002        return Ok(base_epoch_day);
9003    };
9004    let year_raw = int_field(m, "year", base_y)?;
9005    let year = i32::try_from(year_raw)
9006        .map_err(|_| QueryError::Type(format!("'year' is out of range: {year_raw}")))?;
9007    let month_raw = int_field(m, "month", base_m)?;
9008    let month = u32::try_from(month_raw)
9009        .map_err(|_| QueryError::Type(format!("'month' is out of range: {month_raw}")))?;
9010    let day_raw = int_field(m, "day", base_d)?;
9011    let day = u32::try_from(day_raw)
9012        .map_err(|_| QueryError::Type(format!("'day' is out of range: {day_raw}")))?;
9013    let result = temporal::epoch_day_from_ymd(year, month, day).ok_or_else(|| {
9014        QueryError::Type(format!(
9015            "{year:04}-{month:02}-{day:02} isn't a valid calendar date"
9016        ))
9017    })?;
9018    match m.get("dayOfWeek") {
9019        None => Ok(result),
9020        Some(v) => {
9021            let dow = value_as_i64(v)
9022                .ok_or_else(|| QueryError::Type("'dayOfWeek' must be an integer".into()))?;
9023            temporal::set_iso_weekday(result, dow).ok_or_else(|| {
9024                QueryError::Type(format!(
9025                    "'dayOfWeek' must be 1..7 (Monday..Sunday), got {dow}"
9026                ))
9027            })
9028        }
9029    }
9030}
9031
9032/// `hour`/`minute`/`second`/`millisecond`/`microsecond`/`nanosecond`
9033/// overrides shared by every `.truncate()` builtin's optional trailing
9034/// map -- same "unset key keeps the truncated base's value" rule as
9035/// `apply_date_overrides`.
9036fn apply_time_overrides(
9037    base_nanos_of_day: i64,
9038    map: Option<&BTreeMap<String, Value>>,
9039) -> Result<i64, QueryError> {
9040    let base_h = temporal::local_time_component(base_nanos_of_day, "hour").unwrap();
9041    let base_min = temporal::local_time_component(base_nanos_of_day, "minute").unwrap();
9042    let base_s = temporal::local_time_component(base_nanos_of_day, "second").unwrap();
9043    let base_ns = temporal::local_time_component(base_nanos_of_day, "nanosecond").unwrap();
9044    let Some(m) = map else {
9045        return Ok(base_nanos_of_day);
9046    };
9047    let nanos = sub_second_nanos_from_map(base_ns, m)?;
9048    let hour = int_field(m, "hour", base_h)?;
9049    let minute = int_field(m, "minute", base_min)?;
9050    let second = int_field(m, "second", base_s)?;
9051    temporal::local_time_nanos_from_fields(hour, minute, second, nanos)
9052        .ok_or_else(|| QueryError::Type("truncate(...)'s map has an out-of-range field".into()))
9053}
9054
9055/// Rejects a `.truncate()` map key that this specific target type has
9056/// no field for (e.g. `hour` on `date.truncate`'s result, which is a
9057/// bare `Date`) -- each of the 5 truncate builtins passes its own real
9058/// field list, since `apply_date_overrides`/`apply_time_overrides`
9059/// themselves are shared and don't know which caller's result shape
9060/// makes a given key meaningful.
9061fn validate_truncate_map_keys(
9062    name: &str,
9063    map: Option<&BTreeMap<String, Value>>,
9064    allowed: &[&str],
9065) -> Result<(), QueryError> {
9066    let Some(m) = map else { return Ok(()) };
9067    if let Some(bad) = m.keys().find(|k| !allowed.contains(&k.as_str())) {
9068        return Err(QueryError::Type(format!(
9069            "{name}(...)'s map has an unrecognized field '{bad}'"
9070        )));
9071    }
9072    Ok(())
9073}
9074
9075fn date_truncate_builtin(args: &[Value]) -> Result<Value, QueryError> {
9076    let (unit, other, map) = parse_truncate_args("date.truncate", args)?;
9077    validate_truncate_map_keys("date.truncate", map, &["year", "month", "day", "dayOfWeek"])?;
9078    if matches!(other, Value::Null) {
9079        return Ok(Value::Null);
9080    }
9081    let (base_date, _, _) = between_operand("date.truncate", other)?;
9082    let base_date = base_date.ok_or_else(|| {
9083        QueryError::Type(
9084            "date.truncate() needs a value with a calendar date (Date, LocalDateTime, or DateTime)"
9085                .into(),
9086        )
9087    })?;
9088    let truncated = temporal::truncate_date_unit(base_date, unit).ok_or_else(|| {
9089        QueryError::Type(format!(
9090            "date.truncate(): '{unit}' isn't a recognized date unit"
9091        ))
9092    })?;
9093    Ok(Value::Property(PropertyValue::Date(apply_date_overrides(
9094        truncated, map,
9095    )?)))
9096}
9097
9098const TIME_TRUNCATE_MAP_KEYS: &[&str] = &[
9099    "hour",
9100    "minute",
9101    "second",
9102    "millisecond",
9103    "microsecond",
9104    "nanosecond",
9105];
9106
9107fn local_time_truncate_builtin(args: &[Value]) -> Result<Value, QueryError> {
9108    let (unit, other, map) = parse_truncate_args("localtime.truncate", args)?;
9109    validate_truncate_map_keys("localtime.truncate", map, TIME_TRUNCATE_MAP_KEYS)?;
9110    if matches!(other, Value::Null) {
9111        return Ok(Value::Null);
9112    }
9113    let (_, base_time, _) = between_operand("localtime.truncate", other)?;
9114    let base_time = base_time.ok_or_else(|| {
9115        QueryError::Type(
9116            "localtime.truncate() needs a value with a time-of-day (LocalTime, Time, \
9117             LocalDateTime, or DateTime)"
9118                .into(),
9119        )
9120    })?;
9121    let truncated = temporal::truncate_time_unit(base_time, unit).ok_or_else(|| {
9122        QueryError::Type(format!(
9123            "localtime.truncate(): '{unit}' isn't a recognized time unit"
9124        ))
9125    })?;
9126    Ok(Value::Property(PropertyValue::LocalTime(
9127        apply_time_overrides(truncated, map)?,
9128    )))
9129}
9130
9131fn time_truncate_builtin(args: &[Value]) -> Result<Value, QueryError> {
9132    let (unit, other, map) = parse_truncate_args("time.truncate", args)?;
9133    validate_truncate_map_keys(
9134        "time.truncate",
9135        map,
9136        &[
9137            "hour",
9138            "minute",
9139            "second",
9140            "millisecond",
9141            "microsecond",
9142            "nanosecond",
9143            "timezone",
9144        ],
9145    )?;
9146    if matches!(other, Value::Null) {
9147        return Ok(Value::Null);
9148    }
9149    let (_, base_time, base_offset) = between_operand("time.truncate", other)?;
9150    let base_time = base_time.ok_or_else(|| {
9151        QueryError::Type(
9152            "time.truncate() needs a value with a time-of-day (LocalTime, Time, LocalDateTime, \
9153             or DateTime)"
9154                .into(),
9155        )
9156    })?;
9157    let truncated = temporal::truncate_time_unit(base_time, unit).ok_or_else(|| {
9158        QueryError::Type(format!(
9159            "time.truncate(): '{unit}' isn't a recognized time unit"
9160        ))
9161    })?;
9162    let nanos_of_day = apply_time_overrides(truncated, map)?;
9163    let offset_seconds = match map.and_then(|m| m.get("timezone")) {
9164        Some(v) => match timezone_value_to_tzid(v)? {
9165            temporal::TzId::Offset(o) => o,
9166            temporal::TzId::Named(name) => {
9167                return Err(QueryError::Type(format!(
9168                    "'timezone': '{name}' looks like a named timezone (e.g. 'Europe/Stockholm') -- TIME has \
9169                     no calendar date to resolve a named zone's DST-dependent offset against, only a fixed \
9170                     UTC offset like '+01:00' is supported"
9171                )));
9172            }
9173        },
9174        None => match base_offset {
9175            Some(temporal::TzId::Offset(o)) => o,
9176            _ => 0,
9177        },
9178    };
9179    Ok(Value::Property(PropertyValue::Time {
9180        nanos_of_day,
9181        offset_seconds,
9182    }))
9183}
9184
9185/// Shared by `localdatetime.truncate`/`datetime.truncate`: a calendar-
9186/// scale `unit` (`year`, `month`, ...) truncates the date and resets
9187/// the time-of-day to midnight; a clock-scale `unit` (`hour`,
9188/// `minute`, ...) leaves the date untouched and truncates just the
9189/// time. `day` is both at once (`truncate_date_unit`'s own `day` arm
9190/// already returns the date unchanged), so trying the date-unit path
9191/// first handles it correctly without a separate case.
9192fn truncate_date_time(base_date: i32, base_time: i64, unit: &str) -> Option<(i32, i64)> {
9193    if let Some(d) = temporal::truncate_date_unit(base_date, unit) {
9194        Some((d, 0))
9195    } else {
9196        temporal::truncate_time_unit(base_time, unit).map(|t| (base_date, t))
9197    }
9198}
9199
9200fn local_date_time_truncate_builtin(args: &[Value]) -> Result<Value, QueryError> {
9201    let (unit, other, map) = parse_truncate_args("localdatetime.truncate", args)?;
9202    validate_truncate_map_keys(
9203        "localdatetime.truncate",
9204        map,
9205        &[
9206            "year",
9207            "month",
9208            "day",
9209            "dayOfWeek",
9210            "hour",
9211            "minute",
9212            "second",
9213            "millisecond",
9214            "microsecond",
9215            "nanosecond",
9216        ],
9217    )?;
9218    if matches!(other, Value::Null) {
9219        return Ok(Value::Null);
9220    }
9221    let (base_date, base_time, _) = between_operand("localdatetime.truncate", other)?;
9222    let base_date = base_date.ok_or_else(|| {
9223        QueryError::Type(
9224            "localdatetime.truncate() needs a value with a calendar date (Date, LocalDateTime, \
9225             or DateTime)"
9226                .into(),
9227        )
9228    })?;
9229    let (trunc_date, trunc_time) = truncate_date_time(base_date, base_time.unwrap_or(0), unit)
9230        .ok_or_else(|| {
9231            QueryError::Type(format!(
9232                "localdatetime.truncate(): '{unit}' isn't a recognized unit"
9233            ))
9234        })?;
9235    let final_date = apply_date_overrides(trunc_date, map)?;
9236    let final_time = apply_time_overrides(trunc_time, map)?;
9237    let (epoch_seconds, nanos) = temporal::combine_date_and_time(final_date, final_time);
9238    Ok(Value::Property(PropertyValue::LocalDateTime {
9239        epoch_seconds,
9240        nanos,
9241    }))
9242}
9243
9244fn date_time_truncate_builtin(args: &[Value]) -> Result<Value, QueryError> {
9245    let (unit, other, map) = parse_truncate_args("datetime.truncate", args)?;
9246    validate_truncate_map_keys(
9247        "datetime.truncate",
9248        map,
9249        &[
9250            "year",
9251            "month",
9252            "day",
9253            "dayOfWeek",
9254            "hour",
9255            "minute",
9256            "second",
9257            "millisecond",
9258            "microsecond",
9259            "nanosecond",
9260            "timezone",
9261        ],
9262    )?;
9263    if matches!(other, Value::Null) {
9264        return Ok(Value::Null);
9265    }
9266    let (base_date, base_time, base_offset) = between_operand("datetime.truncate", other)?;
9267    let base_date = base_date.ok_or_else(|| {
9268        QueryError::Type(
9269            "datetime.truncate() needs a value with a calendar date (Date, LocalDateTime, or \
9270             DateTime)"
9271                .into(),
9272        )
9273    })?;
9274    let (trunc_date, trunc_time) = truncate_date_time(base_date, base_time.unwrap_or(0), unit)
9275        .ok_or_else(|| {
9276            QueryError::Type(format!(
9277                "datetime.truncate(): '{unit}' isn't a recognized unit"
9278            ))
9279        })?;
9280    let final_date = apply_date_overrides(trunc_date, map)?;
9281    let final_time = apply_time_overrides(trunc_time, map)?;
9282    let zone = match map.and_then(|m| m.get("timezone")) {
9283        Some(v) => timezone_value_to_tzid(v)?,
9284        None => base_offset.unwrap_or(temporal::TzId::Offset(0)),
9285    };
9286    let calendar = temporal::CalendarDateTime {
9287        year: temporal::date_component(final_date, "year").unwrap() as i32,
9288        month: temporal::date_component(final_date, "month").unwrap() as u32,
9289        day: temporal::date_component(final_date, "day").unwrap() as u32,
9290        hour: temporal::local_time_component(final_time, "hour").unwrap(),
9291        minute: temporal::local_time_component(final_time, "minute").unwrap(),
9292        second: temporal::local_time_component(final_time, "second").unwrap(),
9293        nanos: temporal::local_time_component(final_time, "nanosecond").unwrap(),
9294    };
9295    let (epoch_seconds, nanos) =
9296        temporal::date_time_from_fields(calendar, &zone).ok_or_else(|| {
9297            QueryError::Type("datetime.truncate() produced an out-of-range value".into())
9298        })?;
9299    Ok(Value::Property(PropertyValue::DateTime {
9300        epoch_seconds,
9301        nanos,
9302        zone: tz_to_graph(zone),
9303    }))
9304}
9305
9306fn value_as_i64(v: &Value) -> Option<i64> {
9307    match v {
9308        Value::Property(PropertyValue::Int(i)) | Value::Literal(Literal::Int(i)) => Some(*i),
9309        _ => None,
9310    }
9311}
9312
9313fn value_as_f64(v: &Value) -> Option<f64> {
9314    match as_arith_num(v)? {
9315        ArithNum::Int(i) => Some(i as f64),
9316        ArithNum::Float(f) => Some(f),
9317    }
9318}
9319
9320/// Shared `Date`/`Duration` component access for `d.<prop>` — used by
9321/// both `lookup_prop` (a bound row variable, e.g. `WITH v.date AS d ...
9322/// d.year`) and `eval_projected_expr`'s `Prop` arm (the post-projection/
9323/// ORDER BY path). Returns `None` for any property name that isn't a
9324/// recognized component (or a non-temporal `PropertyValue`), the same
9325/// "treat as absent, not an error" convention every other `.prop` access
9326/// already follows for an unknown property.
9327/// True for the 6 `PropertyValue` variants that have a real `.prop`
9328/// component-access interface (`temporal_component`) -- distinguishes
9329/// "a temporal value with an *unrecognized* property name" (still `null`,
9330/// same as a node/edge's own missing-property rule) from "a plain scalar
9331/// with *no* `.prop` interface at all" (a real type error, see
9332/// `lookup_prop_value`'s docs) -- `temporal_component` alone can't tell
9333/// these apart, since it returns `None` for both.
9334fn is_temporal_property_value(pv: &PropertyValue) -> bool {
9335    matches!(
9336        pv,
9337        PropertyValue::Date(_)
9338            | PropertyValue::Duration { .. }
9339            | PropertyValue::LocalTime(_)
9340            | PropertyValue::Time { .. }
9341            | PropertyValue::LocalDateTime { .. }
9342            | PropertyValue::DateTime { .. }
9343    )
9344}
9345
9346/// `<expr>.prop` where `<expr>` isn't a bare row variable (`ReturnExpr::
9347/// PropOf`, e.g. `startNode(r).id`, `head(nodes(p)).name`, `{a: 1}.a`) --
9348/// unlike `lookup_prop_value`'s `Prop(PropAccess)` arm, there's no row/txn
9349/// lookup to do here, `v` already *is* the fully-evaluated base value, so
9350/// this reads straight off it. Same node/edge/map/temporal-value-or-error
9351/// shape as `lookup_prop_value`, minus the "unbound variable" case (there's
9352/// no variable name to report -- a `PropOf` base that evaluates to
9353/// `Value::Null` propagates `Null` here the same way a bound-but-null row
9354/// variable's own `.prop` access already does).
9355fn property_of_value(v: &Value, prop: &str) -> Result<Value, QueryError> {
9356    match v {
9357        Value::Node(n) => Ok(n
9358            .props
9359            .get(prop)
9360            .cloned()
9361            .map(property_value_to_value)
9362            .unwrap_or(Value::Null)),
9363        Value::Edge(e) => Ok(e
9364            .props
9365            .get(prop)
9366            .cloned()
9367            .map(property_value_to_value)
9368            .unwrap_or(Value::Null)),
9369        Value::Map(m) => Ok(m.get(prop).cloned().unwrap_or(Value::Null)),
9370        Value::Null => Ok(Value::Null),
9371        Value::Property(PropertyValue::Null) => Ok(Value::Null),
9372        Value::Property(pv) => match temporal_component(pv, prop) {
9373            Some(component) => Ok(Value::Property(component)),
9374            None if is_temporal_property_value(pv) => Ok(Value::Null),
9375            None => Err(QueryError::Type(
9376                "property access requires a node, relationship, map, or temporal value".into(),
9377            )),
9378        },
9379        Value::List(_) | Value::Path(_) => Err(QueryError::Type(
9380            "property access requires a node, relationship, map, or temporal value, not a list \
9381             or path"
9382                .into(),
9383        )),
9384        Value::Literal(_) => Err(QueryError::Type(
9385            "property access requires a node, relationship, map, or temporal value".into(),
9386        )),
9387    }
9388}
9389
9390fn temporal_component(pv: &PropertyValue, prop: &str) -> Option<PropertyValue> {
9391    match pv {
9392        PropertyValue::Date(d) => temporal::date_component(*d, prop).map(PropertyValue::Int),
9393        PropertyValue::Duration {
9394            months,
9395            days,
9396            seconds,
9397            nanos,
9398        } => temporal::duration_component(*months, *days, *seconds, *nanos, prop)
9399            .map(PropertyValue::Int),
9400        PropertyValue::LocalTime(nanos_of_day) => {
9401            temporal::local_time_component(*nanos_of_day, prop).map(PropertyValue::Int)
9402        }
9403        PropertyValue::Time {
9404            nanos_of_day,
9405            offset_seconds,
9406        } => time_component(*nanos_of_day, *offset_seconds, prop),
9407        PropertyValue::LocalDateTime {
9408            epoch_seconds,
9409            nanos,
9410        } => date_time_component(*epoch_seconds, *nanos, None, prop),
9411        PropertyValue::DateTime {
9412            epoch_seconds,
9413            nanos,
9414            zone,
9415        } => date_time_component(*epoch_seconds, *nanos, Some(&tz_from_graph(zone)), prop),
9416        _ => None,
9417    }
9418}
9419
9420/// `Time`'s own component set: `LocalTime`'s fields plus the offset
9421/// ones (`timezone`/`offset` as text, `offsetSeconds`/`offsetMinutes`
9422/// as integers).
9423fn time_component(nanos_of_day: i64, offset_seconds: i32, prop: &str) -> Option<PropertyValue> {
9424    match prop {
9425        "timezone" | "offset" => Some(PropertyValue::String(temporal::format_offset(
9426            offset_seconds,
9427        ))),
9428        "offsetSeconds" => Some(PropertyValue::Int(offset_seconds as i64)),
9429        "offsetMinutes" => Some(PropertyValue::Int(offset_seconds as i64 / 60)),
9430        _ => temporal::local_time_component(nanos_of_day, prop).map(PropertyValue::Int),
9431    }
9432}
9433
9434/// `LocalDateTime`/`DateTime`'s shared component set: every `Date`
9435/// component, every `LocalTime` component, and (only when
9436/// `offset_seconds` is `Some`, i.e. a real `DateTime`) the same offset/
9437/// epoch fields `Time`/this-function's own `epochSeconds`/`epochMillis`
9438/// add on top.
9439///
9440/// Calendar/clock components (`year`..`nanosecond`) are computed against
9441/// the *local* (offset-adjusted) wall-clock reading, not the stored UTC
9442/// instant -- `datetime({..., hour: 12, timezone: '+01:00'}).hour` must
9443/// answer `12` (what was written/displayed), not `11` (the UTC hour) --
9444/// same "display the local reading" rule `format_date_time` already
9445/// follows. `epochSeconds`/`epochMillis` are the one exception,
9446/// deliberately using the raw (UTC) `epoch_seconds` -- "epoch" always
9447/// means the UTC instant, regardless of offset.
9448fn date_time_component(
9449    epoch_seconds: i64,
9450    nanos: i32,
9451    zone: Option<&temporal::TzId>,
9452    prop: &str,
9453) -> Option<PropertyValue> {
9454    if let Some(zone) = zone {
9455        let offset_seconds = temporal::resolve_offset(zone, epoch_seconds);
9456        match prop {
9457            // `.timezone` is the zone *identifier* as written -- the
9458            // zone name for a `Named` zone, or the offset text itself
9459            // for a fixed `Offset` (there's no separate name); `.offset`
9460            // is always the *resolved* offset text, so the two only
9461            // diverge for a `Named` zone (TCK's Temporal5's `d.timezone`
9462            // = `'Europe/Stockholm'` vs `d.offset` = `'+01:00'`).
9463            "timezone" => {
9464                let text = match zone {
9465                    temporal::TzId::Named(name) => name.clone(),
9466                    temporal::TzId::Offset(_) => temporal::format_offset(offset_seconds),
9467                };
9468                return Some(PropertyValue::String(text));
9469            }
9470            "offset" => {
9471                return Some(PropertyValue::String(temporal::format_offset(
9472                    offset_seconds,
9473                )))
9474            }
9475            "offsetSeconds" => return Some(PropertyValue::Int(offset_seconds as i64)),
9476            "offsetMinutes" => return Some(PropertyValue::Int(offset_seconds as i64 / 60)),
9477            "epochSeconds" => return Some(PropertyValue::Int(epoch_seconds)),
9478            "epochMillis" => {
9479                return Some(PropertyValue::Int(
9480                    temporal::epoch_seconds_and_millis(epoch_seconds, nanos).1,
9481                ))
9482            }
9483            _ => {}
9484        }
9485    }
9486    let offset_seconds = zone.map_or(0, |z| temporal::resolve_offset(z, epoch_seconds));
9487    let local_epoch_seconds = epoch_seconds + offset_seconds as i64;
9488    temporal::date_time_calendar_component(local_epoch_seconds, prop)
9489        .or_else(|| temporal::date_time_clock_component(local_epoch_seconds, nanos, prop))
9490        .map(PropertyValue::Int)
9491}
9492
9493/// Sorts `rows` (already-projected `RETURN`/`WITH` output, `columns`
9494/// aligned by index) by `order_by`, which evaluates against the projected
9495/// column names — never the raw pattern `BindingRow` — since every ORDER BY
9496/// key in practice is a RETURN/WITH alias, not a bare pattern variable.
9497fn apply_order_by(
9498    rows: Vec<Vec<Value>>,
9499    columns: &[String],
9500    order_by: &[(ReturnExpr, SortDir)],
9501    items: Option<&[ReturnItem]>,
9502    skip: Option<i64>,
9503    limit: Option<i64>,
9504) -> Result<Vec<Vec<Value>>, QueryError> {
9505    // An ORDER BY expression that repeats a returned expression verbatim
9506    // (`RETURN n.name, count(*) AS foo ORDER BY n.name`) names a real
9507    // output column by its default name -- match it directly by position
9508    // rather than re-evaluating the expression, which would need bindings
9509    // (e.g. `n`) that only the pre-aggregation rows had and are gone by
9510    // this post-projection point. That name-based match only works for an
9511    // *unaliased* item (its column name literally is its default name) --
9512    // an aliased item repeated verbatim (`RETURN sum(x) AS s ORDER BY
9513    // sum(x)`, TCK's WithOrderBy4 [11]) needs a structural match against
9514    // the item's own expression instead, falling back to position in
9515    // `items` (1:1 with `columns`, one column per return item).
9516    let order_by_col: Vec<Option<usize>> = order_by
9517        .iter()
9518        .map(|(expr, _)| {
9519            columns
9520                .iter()
9521                .position(|c| *c == default_column_name(expr, 0))
9522                .or_else(|| {
9523                    items.and_then(|items| items.iter().position(|item| item.expr == *expr))
9524                })
9525        })
9526        .collect();
9527    let mut keyed: Vec<(Vec<Value>, Vec<Value>)> = Vec::with_capacity(rows.len());
9528    for row in rows {
9529        let row_map: HashMap<String, Value> =
9530            columns.iter().cloned().zip(row.iter().cloned()).collect();
9531        let keys = order_by
9532            .iter()
9533            .zip(&order_by_col)
9534            .map(|((expr, _), col)| match col {
9535                Some(i) => Ok(row[*i].clone()),
9536                None => eval_projected_expr(expr, &row_map),
9537            })
9538            .collect::<Result<Vec<_>, _>>()?;
9539        keyed.push((keys, row));
9540    }
9541    Ok(top_k_by(keyed, order_by, skip, limit)
9542        .into_iter()
9543        .map(|(_, row)| row)
9544        .collect())
9545}
9546
9547/// Same expression shape as `eval_return_expr`, but resolves `Var`/`Prop`
9548/// against already-projected output columns instead of the graph-bound
9549/// `BindingRow` — no `WriteTransaction`/`GraphStore` access needed, since a
9550/// projected `Value::Node`/`Value::Edge` already carries its full record
9551/// (including props) from when it was first materialized.
9552fn eval_projected_expr(
9553    expr: &ReturnExpr,
9554    row: &HashMap<String, Value>,
9555) -> Result<Value, QueryError> {
9556    match expr {
9557        ReturnExpr::Var(name) => row
9558            .get(name)
9559            .cloned()
9560            .ok_or_else(|| QueryError::UnboundVariable(name.clone())),
9561        ReturnExpr::Prop(pa) => {
9562            let base = row
9563                .get(&pa.var)
9564                .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
9565            match base {
9566                Value::Map(m) => Ok(m.get(&pa.prop).cloned().unwrap_or(Value::Null)),
9567                Value::Node(n) => Ok(match n.props.get(&pa.prop).cloned() {
9568                    Some(PropertyValue::Null) | None => Value::Null,
9569                    Some(v) => property_value_to_value(v),
9570                }),
9571                Value::Edge(e) => Ok(match e.props.get(&pa.prop).cloned() {
9572                    Some(PropertyValue::Null) | None => Value::Null,
9573                    Some(v) => property_value_to_value(v),
9574                }),
9575                // `d.year`/`d.months`/etc component access on a `Date`/
9576                // `Duration` in projected/ORDER BY position -- mirrors
9577                // `lookup_prop_value`'s equivalent `Binding::Value(pv)`
9578                // handling for the pre-projection path.
9579                Value::Property(pv) => Ok(match temporal_component(pv, &pa.prop) {
9580                    Some(component) => Value::Property(component),
9581                    None => Value::Null,
9582                }),
9583                _ => Ok(Value::Null),
9584            }
9585        }
9586        ReturnExpr::PropOf(base, prop) => {
9587            let v = eval_projected_expr(base, row)?;
9588            property_of_value(&v, prop)
9589        }
9590        ReturnExpr::Lit(lit) => Ok(match lit {
9591            Literal::Null => Value::Null,
9592            other => Value::Literal(other.clone()),
9593        }),
9594        ReturnExpr::Call { name, args, .. } => {
9595            // Same internal-consistency stance as `eval_return_expr`'s
9596            // `Call` arm: by the time ORDER BY runs, aggregation has
9597            // already resolved into ordinary named output columns
9598            // (referenced here via `Var`), so a raw aggregate `Call`
9599            // reaching this point means it wasn't top-level as
9600            // `validate_return_items` requires.
9601            if is_aggregate_name(name) {
9602                return Err(QueryError::Semantic(format!(
9603                    "aggregate function '{name}' can only be used as a return item's top-level expression"
9604                )));
9605            }
9606            let arg_values = args
9607                .iter()
9608                .map(|a| eval_projected_expr(a, row))
9609                .collect::<Result<Vec<_>, _>>()?;
9610            // No `Executor` (and so no cached `now_snapshot()`) reachable
9611            // from this post-projection/ORDER BY path -- a fresh capture
9612            // here is a real, narrow inconsistency (a no-arg `date()`/
9613            // etc re-evaluated from *inside* an ORDER BY expression could
9614            // in principle read a different instant than the same call
9615            // during `RETURN`'s own projection), but reaching this
9616            // specific shape at all is a rare, arguably degenerate query.
9617            call_builtin(name, &arg_values, temporal::capture_now())
9618        }
9619        ReturnExpr::CountStar => Err(QueryError::Semantic(
9620            "count(*) can only be used as a return item's top-level expression".into(),
9621        )),
9622        ReturnExpr::Case { test, whens, else_ } => {
9623            let test_value = match test {
9624                Some(t) => Some(eval_projected_expr(t, row)?),
9625                None => None,
9626            };
9627            for (when, then) in whens {
9628                let when_value = eval_projected_expr(when, row)?;
9629                let matched = match &test_value {
9630                    Some(tv) => value_eq(tv, &when_value),
9631                    None => matches!(when_value, Value::Literal(Literal::Bool(true))),
9632                };
9633                if matched {
9634                    return eval_projected_expr(then, row);
9635                }
9636            }
9637            match else_ {
9638                Some(e) => eval_projected_expr(e, row),
9639                None => Ok(Value::Null),
9640            }
9641        }
9642        ReturnExpr::Arith(l, op, r) => {
9643            let lv = eval_projected_expr(l, row)?;
9644            let rv = eval_projected_expr(r, row)?;
9645            apply_arith(*op, &lv, &rv)
9646        }
9647        ReturnExpr::Neg(e) => {
9648            let v = eval_projected_expr(e, row)?;
9649            apply_neg(&v)
9650        }
9651        ReturnExpr::ListLit(items) => Ok(Value::List(
9652            items
9653                .iter()
9654                .map(|item| eval_projected_expr(item, row))
9655                .collect::<Result<Vec<_>, _>>()?,
9656        )),
9657        ReturnExpr::Index(base, index) => {
9658            let base_v = eval_projected_expr(base, row)?;
9659            let index_v = eval_projected_expr(index, row)?;
9660            apply_index(&base_v, &index_v)
9661        }
9662        ReturnExpr::Slice(base, start, end) => {
9663            let base_v = eval_projected_expr(base, row)?;
9664            let start_v = start
9665                .as_deref()
9666                .map(|s| eval_projected_expr(s, row))
9667                .transpose()?;
9668            let end_v = end
9669                .as_deref()
9670                .map(|e| eval_projected_expr(e, row))
9671                .transpose()?;
9672            apply_slice(&base_v, start_v.as_ref(), end_v.as_ref())
9673        }
9674        ReturnExpr::ListComp {
9675            var,
9676            source,
9677            where_clause,
9678            project,
9679        } => {
9680            let source_v = eval_projected_expr(source, row)?;
9681            let items = match source_v {
9682                Value::List(items) => items,
9683                Value::Null => return Ok(Value::Null),
9684                other => {
9685                    return Err(QueryError::Type(format!(
9686                        "list comprehension source must be a list, got {other:?}"
9687                    )))
9688                }
9689            };
9690            let mut result = Vec::with_capacity(items.len());
9691            for item in items {
9692                let mut scoped_row = row.clone();
9693                scoped_row.insert(var.clone(), item.clone());
9694                let keep = match where_clause {
9695                    Some(w) => value_to_bool3(&eval_projected_expr(w, &scoped_row)?)? == Some(true),
9696                    None => true,
9697                };
9698                if !keep {
9699                    continue;
9700                }
9701                result.push(match project {
9702                    Some(p) => eval_projected_expr(p, &scoped_row)?,
9703                    None => item,
9704                });
9705            }
9706            Ok(Value::List(result))
9707        }
9708        ReturnExpr::Quantifier {
9709            kind,
9710            var,
9711            source,
9712            where_clause,
9713        } => {
9714            let source_v = eval_projected_expr(source, row)?;
9715            let items = match source_v {
9716                Value::List(items) => items,
9717                Value::Null => return Ok(Value::Null),
9718                other => {
9719                    return Err(QueryError::Type(format!(
9720                        "quantifier source must be a list, got {other:?}"
9721                    )))
9722                }
9723            };
9724            let mut preds = Vec::with_capacity(items.len());
9725            for item in &items {
9726                let mut scoped_row = row.clone();
9727                scoped_row.insert(var.clone(), item.clone());
9728                preds.push(match where_clause {
9729                    Some(w) => value_to_bool3(&eval_projected_expr(w, &scoped_row)?)?,
9730                    None => item_truthy(item),
9731                });
9732            }
9733            Ok(match eval_quantifier(*kind, &preds) {
9734                Some(b) => Value::Literal(Literal::Bool(b)),
9735                None => Value::Null,
9736            })
9737        }
9738        ReturnExpr::MapLit(entries) => {
9739            let mut map = BTreeMap::new();
9740            for (k, v) in entries {
9741                map.insert(k.clone(), eval_projected_expr(v, row)?);
9742            }
9743            Ok(Value::Map(map))
9744        }
9745        ReturnExpr::And(l, r) => Ok(bool3_to_value(and3(
9746            value_to_bool3(&eval_projected_expr(l, row)?)?,
9747            value_to_bool3(&eval_projected_expr(r, row)?)?,
9748        ))),
9749        ReturnExpr::Or(l, r) => Ok(bool3_to_value(or3(
9750            value_to_bool3(&eval_projected_expr(l, row)?)?,
9751            value_to_bool3(&eval_projected_expr(r, row)?)?,
9752        ))),
9753        ReturnExpr::Xor(l, r) => Ok(bool3_to_value(xor3(
9754            value_to_bool3(&eval_projected_expr(l, row)?)?,
9755            value_to_bool3(&eval_projected_expr(r, row)?)?,
9756        ))),
9757        ReturnExpr::Not(e) => Ok(bool3_to_value(
9758            value_to_bool3(&eval_projected_expr(e, row)?)?.map(|b| !b),
9759        )),
9760        ReturnExpr::Compare(l, op, r) => {
9761            let lv = eval_projected_expr(l, row)?;
9762            let rv = eval_projected_expr(r, row)?;
9763            Ok(bool3_to_value(compare_values(&lv, *op, &rv)))
9764        }
9765        ReturnExpr::IsNull(e) => Ok(Value::Literal(Literal::Bool(matches!(
9766            eval_projected_expr(e, row)?,
9767            Value::Null
9768        )))),
9769        ReturnExpr::In(needle, haystack) => {
9770            let nv = eval_projected_expr(needle, row)?;
9771            let hv = eval_projected_expr(haystack, row)?;
9772            Ok(bool3_to_value(list_membership_ternary(&nv, &hv)?))
9773        }
9774        ReturnExpr::HasLabel(var, labels) => {
9775            let binding = row
9776                .get(var)
9777                .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
9778            match binding {
9779                Value::Node(n) => Ok(Value::Literal(Literal::Bool(
9780                    labels.iter().all(|l| n.labels.contains(l)),
9781                ))),
9782                Value::Null => Ok(Value::Null),
9783                other => Err(QueryError::Type(format!(
9784                    "'{var}' isn't a node — (n:Label) needs a node binding, got {other:?}"
9785                ))),
9786            }
9787        }
9788        ReturnExpr::PatternPredicate(_) => Err(QueryError::Semantic(
9789            "a pattern predicate (`(n)-->()` etc) can only be used inside WHERE".into(),
9790        )),
9791        // No `Txn`/`ExecutionGuard` reachable from this post-projection
9792        // path (same "no `Executor`" limitation as the `Call` arm above)
9793        // -- a pattern comprehension needs a real graph traversal to
9794        // re-evaluate, which this function structurally can't do. Only
9795        // reachable for an ORDER BY key that references a pattern
9796        // comprehension *without* repeating a RETURN/WITH item verbatim
9797        // (the verbatim case matches by column position before ever
9798        // reaching here -- see `apply_order_by`'s `order_by_col`) --
9799        // not exercised by any current TCK scenario.
9800        ReturnExpr::PatternComprehension { .. } => Err(QueryError::Semantic(
9801            "a pattern comprehension can only be used in RETURN/WITH position, or as an ORDER BY \
9802             key that repeats one of their items verbatim"
9803                .into(),
9804        )),
9805        ReturnExpr::ExistsPattern { .. } | ReturnExpr::ExistsSubquery(_) => Err(
9806            QueryError::Semantic("an exists {} subquery can only be used inside WHERE".into()),
9807        ),
9808    }
9809}
9810
9811/// `RETURN DISTINCT`'s result-set-level dedup -- structural equality of
9812/// the whole row (same `HashKey` machinery `DISTINCT` inside an aggregate
9813/// call and `resolve_grouped_rows`' grouping already use, not `value_eq`'s
9814/// definite-equality-only comparison, since a `HashSet` needs `Hash` too).
9815/// Keeps the first occurrence of each distinct row, preserving order --
9816/// what every other DB's `DISTINCT` does, and what a human reading the
9817/// query would expect.
9818fn dedup_rows(rows: Vec<Vec<Value>>) -> Result<Vec<Vec<Value>>, QueryError> {
9819    let mut seen: HashSet<Vec<HashKey>> = HashSet::with_capacity(rows.len());
9820    let mut out = Vec::with_capacity(rows.len());
9821    for row in rows {
9822        let key = row
9823            .iter()
9824            .map(value_hash_key)
9825            .collect::<Result<Vec<_>, _>>()?;
9826        if seen.insert(key) {
9827            out.push(row);
9828        }
9829    }
9830    Ok(out)
9831}
9832
9833/// `WITH DISTINCT`'s result-set-level dedup -- same first-occurrence-wins
9834/// structural equality as `dedup_rows` (`RETURN DISTINCT`), but keyed at
9835/// the `Binding` level via `binding_hash_key` (node/edge identity, not
9836/// re-fetched contents) since a `WITH`-projected row can still carry a
9837/// real `Binding::Node`/`Edge` a later clause keeps traversing from,
9838/// unlike `RETURN`'s already-fully-evaluated `Value` rows.
9839fn dedup_binding_rows(
9840    items: &[ReturnItem],
9841    rows: Vec<BindingRow>,
9842) -> Result<Vec<BindingRow>, QueryError> {
9843    let names: Vec<String> = items
9844        .iter()
9845        .enumerate()
9846        .map(with_item_output_name)
9847        .collect();
9848    let mut seen: HashSet<Vec<HashKey>> = HashSet::with_capacity(rows.len());
9849    let mut out = Vec::with_capacity(rows.len());
9850    for row in rows {
9851        let key = names
9852            .iter()
9853            .map(|name| {
9854                binding_hash_key(row.get(name).unwrap_or_else(|| {
9855                    panic!("DISTINCT row missing its own projected column '{name}'")
9856                }))
9857            })
9858            .collect::<Result<Vec<_>, _>>()?;
9859        if seen.insert(key) {
9860            out.push(row);
9861        }
9862    }
9863    Ok(out)
9864}
9865
9866/// Sorts `keyed` (each entry paired with its precomputed per-column sort
9867/// keys) by `order_by`'s directions, keeping only the first `limit` items
9868/// when one is given and smaller than the row count. When it is, uses
9869/// `select_nth_unstable_by` to partition around the k-th smallest element
9870/// (O(n) average) and sorts only that k-sized prefix (O(k log k)), instead
9871/// of a full O(n log n) sort of every row just to immediately discard all
9872/// but the first few -- the "ORDER BY + LIMIT -> TOP-K" rewrite real query
9873/// engines apply. Shared by all three ORDER BY sites (`WITH`'s own,
9874/// non-aggregating `RETURN`'s, and aggregating `RETURN`'s), which otherwise
9875/// each build the identical `keyed`-then-sort shape around a different row
9876/// type.
9877/// Selects the top `skip + limit` elements by `order_by` (the
9878/// `select_nth_unstable_by` partial-selection optimization still applies
9879/// to that combined bound, not just `limit` alone), sorts just that
9880/// prefix, then drops the first `skip` of it — real Cypher's own
9881/// "SKIP applies after ORDER BY, LIMIT applies after SKIP" rule.
9882fn top_k_by<T>(
9883    mut keyed: Vec<(Vec<Value>, T)>,
9884    order_by: &[(ReturnExpr, SortDir)],
9885    skip: Option<i64>,
9886    limit: Option<i64>,
9887) -> Vec<(Vec<Value>, T)> {
9888    let cmp = |a: &(Vec<Value>, T), b: &(Vec<Value>, T)| -> std::cmp::Ordering {
9889        for (i, (_, dir)) in order_by.iter().enumerate() {
9890            let ord = compare_with_dir(&a.0[i], &b.0[i], *dir);
9891            if ord != std::cmp::Ordering::Equal {
9892                return ord;
9893            }
9894        }
9895        std::cmp::Ordering::Equal
9896    };
9897    let skip_n = skip.unwrap_or(0).max(0) as usize;
9898    match limit {
9899        Some(n) => {
9900            let k = skip_n + n.max(0) as usize;
9901            if k == 0 {
9902                keyed.clear();
9903            } else if k < keyed.len() {
9904                keyed.select_nth_unstable_by(k - 1, cmp);
9905                keyed.truncate(k);
9906                keyed.sort_by(cmp);
9907            } else {
9908                keyed.sort_by(cmp);
9909            }
9910        }
9911        None => keyed.sort_by(cmp),
9912    }
9913    if skip_n > 0 {
9914        keyed.drain(0..skip_n.min(keyed.len()));
9915    }
9916    keyed
9917}
9918
9919/// `Null` is just the highest-ranked type in `type_rank`'s total order
9920/// (see its docs), not a special case here -- confirmed via TCK's
9921/// `ReturnOrderBy1 [12]`/`WithOrderBy1 [22]` ("sort distinct types...
9922/// descending"), which expect `null` to sort *first* under `DESC`, not
9923/// last. An earlier version of this function hardcoded nulls-last
9924/// regardless of direction (citing Neo4j's docs); that's wrong per the
9925/// TCK's own evidence -- `DESC` is a real reversal of the whole order,
9926/// `null` included, not just of the non-null comparisons.
9927fn compare_with_dir(a: &Value, b: &Value, dir: SortDir) -> std::cmp::Ordering {
9928    let ord = compare_non_null(a, b);
9929    if dir == SortDir::Desc {
9930        ord.reverse()
9931    } else {
9932        ord
9933    }
9934}
9935
9936/// Real Cypher regards `NaN` as larger than every other number (confirmed
9937/// via TCK's `ReturnOrderBy1 [11]`/`[12]`: `NaN` sorts directly below
9938/// `null`, above every finite float, both ASC and DESC) -- plain
9939/// `f64::partial_cmp` returns `None` for any comparison involving `NaN`,
9940/// which `.unwrap_or(Ordering::Equal)` used to paper over by treating
9941/// `NaN` as *equal* to every number. That's not just cosmetically wrong:
9942/// a stable sort over a comparator that calls two genuinely-different
9943/// values "equal" preserves their original relative order instead of
9944/// actually ordering them, and `DESC`'s blanket `.reverse()` of an
9945/// "equal" result is still "equal" -- so `1.5`/`NaN` kept the same
9946/// relative order under both ASC and DESC, when DESC should have
9947/// swapped them.
9948fn cmp_f64_nan_greatest(x: f64, y: f64) -> std::cmp::Ordering {
9949    use std::cmp::Ordering;
9950    match (x.is_nan(), y.is_nan()) {
9951        (true, true) => Ordering::Equal,
9952        (true, false) => Ordering::Greater,
9953        (false, true) => Ordering::Less,
9954        (false, false) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
9955    }
9956}
9957
9958fn compare_non_null(a: &Value, b: &Value) -> std::cmp::Ordering {
9959    use std::cmp::Ordering;
9960    // Real Cypher orders two lists lexicographically (element-by-element,
9961    // shorter-is-less on a common prefix), a genuinely different rule
9962    // from any single scalar comparison -- delegate to its own recursive
9963    // comparator before reaching the scalar-only match below, which would
9964    // otherwise silently treat every pair of lists as "equal" (found via
9965    // TCK's ReturnOrderBy1 `[10]`/WithOrderBy1 `[10]`: `ORDER BY <list
9966    // column>` produced no reordering at all, ASC and DESC alike -- a
9967    // stable sort over an always-`Equal` comparator is a no-op).
9968    if let (Value::List(_), Value::List(_)) = (a, b) {
9969        return list_cmp_asc(a, b);
9970    }
9971    let pa = value_to_comparable(a);
9972    let pb = value_to_comparable(b);
9973    match (pa, pb) {
9974        (Some(PropertyValue::Int(x)), Some(PropertyValue::Int(y))) => x.cmp(&y),
9975        (Some(PropertyValue::Int(x)), Some(PropertyValue::Float(y))) => {
9976            cmp_f64_nan_greatest(x as f64, y)
9977        }
9978        (Some(PropertyValue::Float(x)), Some(PropertyValue::Int(y))) => {
9979            cmp_f64_nan_greatest(x, y as f64)
9980        }
9981        (Some(PropertyValue::Float(x)), Some(PropertyValue::Float(y))) => {
9982            cmp_f64_nan_greatest(x, y)
9983        }
9984        (Some(PropertyValue::String(x)), Some(PropertyValue::String(y))) => x.cmp(&y),
9985        (Some(PropertyValue::Bool(x)), Some(PropertyValue::Bool(y))) => x.cmp(&y),
9986        (Some(PropertyValue::Date(x)), Some(PropertyValue::Date(y))) => x.cmp(&y),
9987        (Some(PropertyValue::LocalTime(x)), Some(PropertyValue::LocalTime(y))) => x.cmp(&y),
9988        (
9989            Some(PropertyValue::Time {
9990                nanos_of_day: x,
9991                offset_seconds: ox,
9992            }),
9993            Some(PropertyValue::Time {
9994                nanos_of_day: y,
9995                offset_seconds: oy,
9996            }),
9997        ) => (x - ox as i64 * 1_000_000_000).cmp(&(y - oy as i64 * 1_000_000_000)),
9998        (
9999            Some(PropertyValue::LocalDateTime {
10000                epoch_seconds: xs,
10001                nanos: xn,
10002            }),
10003            Some(PropertyValue::LocalDateTime {
10004                epoch_seconds: ys,
10005                nanos: yn,
10006            }),
10007        ) => (xs, xn).cmp(&(ys, yn)),
10008        (
10009            Some(PropertyValue::DateTime {
10010                epoch_seconds: xs,
10011                nanos: xn,
10012                ..
10013            }),
10014            Some(PropertyValue::DateTime {
10015                epoch_seconds: ys,
10016                nanos: yn,
10017                ..
10018            }),
10019        ) => (xs, xn).cmp(&(ys, yn)),
10020        // Cross-type scalars (e.g. a String vs a Number) fall through to
10021        // `type_rank`'s real Cypher orderability rank rather than this
10022        // arm's own `Equal` fallback -- see `list_cmp_asc`, the only
10023        // caller that can actually produce a cross-type pair here (a
10024        // top-level ORDER BY key is already one uniform column in
10025        // practice, but a list's *elements* legitimately mix types, e.g.
10026        // `['a', 1]`).
10027        _ => match (type_rank(a), type_rank(b)) {
10028            (Some(ra), Some(rb)) if ra != rb => ra.cmp(&rb),
10029            _ => Ordering::Equal,
10030        },
10031    }
10032}
10033
10034/// Real Cypher's cross-type "orderability" rank (distinct from
10035/// `WHERE`'s three-valued comparison semantics) -- only covers the types
10036/// that can actually reach here with no same-type match already handling
10037/// them (see `compare_non_null`'s cross-type fallback and `list_cmp_asc`).
10038/// Order confirmed against a real TCK scenario (`ReturnOrderBy1`/
10039/// `WithOrderBy1`'s "sort distinct types" scenarios, only reachable once
10040/// `marsdb-tck`'s own harness could parse a path-shaped expected cell --
10041/// previously these scenarios could never even run): `Map < Node <
10042/// Relationship < List < Path < String < Boolean < Number`, `Null` always
10043/// last regardless (`compare_with_dir`'s own separate check). This is
10044/// also a fix, not just an addition -- `Bool`/`String` were previously
10045/// ranked in the wrong relative order (`Bool` before `String`; real
10046/// Cypher has `String` before `Bool`), and `List` sorting before every
10047/// scalar (confirmed separately, `max()`/`min()` over `[1, 'a', null,
10048/// [1, 2], 0.2, 'b']` picks `1` for max and `[1, 2]` for min) still
10049/// holds with `Map`/`Node`/`Relationship` now ranking below it too.
10050/// Temporal types (`Date`.../`Duration`) have no TCK evidence placing
10051/// them anywhere in this cross-type order -- kept after `Number` in
10052/// their pre-existing relative order among themselves, arbitrarily but
10053/// harmlessly (nothing tests a temporal-vs-Map-shaped ORDER BY column).
10054/// `Null` ranks highest of all -- also TCK-confirmed
10055/// (`ReturnOrderBy1 [11]`'s own expected order ends with `null` last),
10056/// and, critically, ranking it here rather than special-casing it in
10057/// `compare_with_dir` is what makes `DESC` correctly put `null` *first*
10058/// (`ReturnOrderBy1 [12]`/`WithOrderBy1 [22]`) -- a hardcoded
10059/// "nulls always last" rule would get the ascending case right and the
10060/// descending case wrong, since real Cypher's `DESC` is a genuine
10061/// reversal of the total order, not just of the non-null comparisons.
10062fn type_rank(v: &Value) -> Option<u8> {
10063    match v {
10064        Value::Map(_) => Some(0),
10065        Value::Node(_) => Some(1),
10066        Value::Edge(_) => Some(2),
10067        Value::List(_) => Some(3),
10068        Value::Path(_) => Some(4),
10069        Value::Literal(Literal::String(_)) | Value::Property(PropertyValue::String(_)) => Some(5),
10070        Value::Literal(Literal::Bool(_)) | Value::Property(PropertyValue::Bool(_)) => Some(6),
10071        Value::Literal(Literal::Int(_))
10072        | Value::Property(PropertyValue::Int(_))
10073        | Value::Literal(Literal::Float(_))
10074        | Value::Property(PropertyValue::Float(_)) => Some(7),
10075        Value::Property(PropertyValue::Date(_)) => Some(8),
10076        Value::Property(PropertyValue::LocalTime(_)) => Some(9),
10077        Value::Property(PropertyValue::Time { .. }) => Some(10),
10078        Value::Property(PropertyValue::LocalDateTime { .. }) => Some(11),
10079        Value::Property(PropertyValue::DateTime { .. }) => Some(12),
10080        Value::Null | Value::Literal(Literal::Null) | Value::Property(PropertyValue::Null) => {
10081            Some(13)
10082        }
10083        _ => None,
10084    }
10085}
10086
10087/// Ascending, element-by-element list comparison for ORDER BY, mirroring
10088/// `compare_with_dir`'s "null sorts last" rule recursively at every
10089/// position (deliberately *not* `value_partial_cmp`'s WHERE-filter
10090/// three-valued semantics, where a null anywhere makes the whole
10091/// comparison undecided instead of a definite presentation order) — a
10092/// shorter list that's a prefix of a longer one sorts first, same
10093/// convention `value_partial_cmp` already uses. `compare_with_dir`
10094/// reverses the *overall* result for `DESC`, not each element
10095/// individually — verified element-by-element against TCK's
10096/// ReturnOrderBy1 `[10]` ("ORDER BY DESC should order lists in the
10097/// expected order").
10098fn list_cmp_asc(a: &Value, b: &Value) -> std::cmp::Ordering {
10099    use std::cmp::Ordering;
10100    let a_null = matches!(a, Value::Null);
10101    let b_null = matches!(b, Value::Null);
10102    match (a_null, b_null) {
10103        (true, true) => return Ordering::Equal,
10104        (true, false) => return Ordering::Greater,
10105        (false, true) => return Ordering::Less,
10106        (false, false) => {}
10107    }
10108    if let (Value::List(xs), Value::List(ys)) = (a, b) {
10109        for (x, y) in xs.iter().zip(ys) {
10110            match list_cmp_asc(x, y) {
10111                Ordering::Equal => continue,
10112                other => return other,
10113            }
10114        }
10115        return xs.len().cmp(&ys.len());
10116    }
10117    compare_non_null(a, b)
10118}
10119
10120fn value_to_comparable(v: &Value) -> Option<PropertyValue> {
10121    match v {
10122        Value::Property(pv) => Some(pv.clone()),
10123        Value::Literal(lit) => Some(literal_to_value(lit)),
10124        _ => None,
10125    }
10126}
10127
10128/// Ordering for `min`/`max` aggregate folding — `None` for values with no
10129/// natural order (`Node`/`Edge`/`Map`/`Path`, or a `Null`, which
10130/// `AggAcc::fold` never passes here anyway since null contributions are
10131/// skipped before folding). The caller turns `None` into a clear error
10132/// rather than an arbitrary "always equal" fallback — unlike ORDER BY's
10133/// `compare_non_null`, which tolerates that for presentation ordering
10134/// (see its docs), silently treating two nodes as "equal" inside an
10135/// aggregate would be a wrong-answer failure mode, not just an
10136/// unhelpful sort order.
10137///
10138/// `List` *is* comparable here (real Cypher's `max()`/`min()` handle a
10139/// list argument, ordered element-by-element the same way ORDER BY
10140/// does — reuses `list_cmp_asc`), and so is a genuine cross-type pair
10141/// (`max()` over `[1, 'a', [1, 2]]`-shaped input), via the same
10142/// `type_rank` fallback `compare_non_null` uses.
10143pub(crate) fn comparable_ordering(a: &Value, b: &Value) -> Option<std::cmp::Ordering> {
10144    if let (Value::List(_), Value::List(_)) = (a, b) {
10145        return Some(list_cmp_asc(a, b));
10146    }
10147    let (pa, pb) = match (value_to_comparable(a), value_to_comparable(b)) {
10148        (Some(pa), Some(pb)) => (pa, pb),
10149        _ => {
10150            return match (type_rank(a), type_rank(b)) {
10151                // Different rank -- a real cross-type comparison (e.g. a
10152                // `List` vs a `String` inside a `max()` fold), safe to
10153                // order by rank.
10154                (Some(ra), Some(rb)) if ra != rb => Some(ra.cmp(&rb)),
10155                // Same rank only ever means both are `Map`/`Node`/`Edge`/
10156                // `Path` here (every type with a real per-value order
10157                // already matched via `value_to_comparable`'s `Some` case
10158                // above, `List` is handled separately at the top) --
10159                // those have no defined per-value order at all. Real for
10160                // ORDER BY's own use of `type_rank` (`compare_non_null`,
10161                // which tolerates "equal" for presentation purposes), but
10162                // silently treating two different `Map`s (or `Node`s,
10163                // ...) as "equal" here would be a wrong-answer failure
10164                // mode for an aggregate, not just an unhelpful sort
10165                // order -- `None` instead (see this function's own docs).
10166                _ => None,
10167            };
10168        }
10169    };
10170    Some(match (pa, pb) {
10171        (PropertyValue::Int(x), PropertyValue::Int(y)) => x.cmp(&y),
10172        (PropertyValue::Int(x), PropertyValue::Float(y)) => cmp_f64_nan_greatest(x as f64, y),
10173        (PropertyValue::Float(x), PropertyValue::Int(y)) => cmp_f64_nan_greatest(x, y as f64),
10174        (PropertyValue::Float(x), PropertyValue::Float(y)) => cmp_f64_nan_greatest(x, y),
10175        (PropertyValue::String(x), PropertyValue::String(y)) => x.cmp(&y),
10176        (PropertyValue::Bool(x), PropertyValue::Bool(y)) => x.cmp(&y),
10177        // `Duration` deliberately has no arm here (falls through to
10178        // `None` below) -- no defined ordering, only equality (see
10179        // `compare_values`'s docs on why months/days/seconds aren't
10180        // fungible enough to order against each other).
10181        (PropertyValue::Date(x), PropertyValue::Date(y)) => x.cmp(&y),
10182        (PropertyValue::LocalTime(x), PropertyValue::LocalTime(y)) => x.cmp(&y),
10183        (
10184            PropertyValue::Time {
10185                nanos_of_day: x,
10186                offset_seconds: ox,
10187            },
10188            PropertyValue::Time {
10189                nanos_of_day: y,
10190                offset_seconds: oy,
10191            },
10192        ) => (x - ox as i64 * 1_000_000_000).cmp(&(y - oy as i64 * 1_000_000_000)),
10193        (
10194            PropertyValue::LocalDateTime {
10195                epoch_seconds: xs,
10196                nanos: xn,
10197            },
10198            PropertyValue::LocalDateTime {
10199                epoch_seconds: ys,
10200                nanos: yn,
10201            },
10202        ) => (xs, xn).cmp(&(ys, yn)),
10203        (
10204            PropertyValue::DateTime {
10205                epoch_seconds: xs,
10206                nanos: xn,
10207                ..
10208            },
10209            PropertyValue::DateTime {
10210                epoch_seconds: ys,
10211                nanos: yn,
10212                ..
10213            },
10214        ) => (xs, xn).cmp(&(ys, yn)),
10215        _ => return None,
10216    })
10217}
10218
10219/// General `lhs op rhs` for `ReturnExpr::Compare` -- unlike `compare()`
10220/// (a `PropertyValue`-vs-`Literal` comparison for pattern-level `WHERE`,
10221/// where the RHS is always a literal), both sides here are already-
10222/// evaluated `Value`s, since either can be a *computed* result (e.g. two
10223/// `date(...)` calls) with no `Literal` able to stand in for it.
10224/// Three-valued like `compare()`: `None` (Cypher's "unknown") for a null
10225/// operand, an operator with no meaning for the operands' types (e.g. `<`
10226/// between two `Duration`s), or a type mismatch.
10227fn compare_values(a: &Value, op: CompareOp, b: &Value) -> Option<bool> {
10228    if matches!(a, Value::Null) || matches!(b, Value::Null) {
10229        return None;
10230    }
10231    match op {
10232        CompareOp::Eq => value_equal_ternary(a, b),
10233        CompareOp::Ne => value_equal_ternary(a, b).map(|eq| !eq),
10234        CompareOp::Lt => ordered_compare(a, b, |o| o == std::cmp::Ordering::Less),
10235        CompareOp::Le => ordered_compare(a, b, |o| o != std::cmp::Ordering::Greater),
10236        CompareOp::Gt => ordered_compare(a, b, |o| o == std::cmp::Ordering::Greater),
10237        CompareOp::Ge => ordered_compare(a, b, |o| o != std::cmp::Ordering::Less),
10238        CompareOp::StartsWith | CompareOp::EndsWith | CompareOp::Contains => {
10239            let (Some(s), Some(p)) = (as_arith_str(a), as_arith_str(b)) else {
10240                return None;
10241            };
10242            Some(match op {
10243                CompareOp::StartsWith => s.starts_with(p),
10244                CompareOp::EndsWith => s.ends_with(p),
10245                CompareOp::Contains => s.contains(p),
10246                _ => unreachable!("only StartsWith/EndsWith/Contains reach this arm"),
10247            })
10248        }
10249    }
10250}
10251
10252/// `<`/`<=`/`>`/`>=` -- numeric operands are special-cased (not folded
10253/// into `value_partial_cmp` below) specifically so `NaN` compares as a
10254/// definite `false` on every operator, matching real Cypher (`0.0/0.0 >
10255/// 1` is `false`, not `null`) -- verified against Comparison2's
10256/// "Comparing NaN" scenario, which is what exposed `comparable_ordering`'s
10257/// `unwrap_or(Equal)` silently making `NaN >= x`/`NaN <= x` both `true`.
10258/// Every other type (`List`, `Date`, `String`, `Bool`, ...) has no NaN-like
10259/// "exists but is unorderable" value, so `None` there really does mean
10260/// Cypher's ordinary "unknown" (a null operand, a null found while
10261/// lexicographically comparing two lists, or a genuine type mismatch),
10262/// not something to special-case to `false`.
10263fn ordered_compare(
10264    a: &Value,
10265    b: &Value,
10266    pred: impl Fn(std::cmp::Ordering) -> bool,
10267) -> Option<bool> {
10268    if let (Some(x), Some(y)) = (value_as_f64(a), value_as_f64(b)) {
10269        return Some(x.partial_cmp(&y).map(pred).unwrap_or(false));
10270    }
10271    value_partial_cmp(a, b).map(pred)
10272}
10273
10274/// `<`/`<=`/`>`/`>=` between two `List`s -- real Cypher orders lists
10275/// lexicographically: the first position where the two lists differ
10276/// decides the result; if every position up to the shorter list's length
10277/// is equal, the shorter list is "less". A `null` found at a
10278/// not-yet-decided position makes the *whole* comparison unknown (`None`)
10279/// -- lexicographic order can't skip past an undecided position to look
10280/// for a later one that happens to differ, since whether that later
10281/// position is even reached depends on what the undecided one turns out
10282/// to be. Verified element-by-element against every row of Comparison2's
10283/// "Comparing lists" scenario (`[1, 2] >= [1, null]` is `null`, not
10284/// `false`, even though `2 >= null` alone would also be `null` -- the
10285/// point is *why*: position 0 is equal, so position 1 is where the
10286/// answer would come from, and it's undecided). Delegates to
10287/// `comparable_ordering` for every non-list, non-numeric pair (`Date`,
10288/// `String`, `Bool`, ...), which has no list case to get wrong.
10289fn value_partial_cmp(a: &Value, b: &Value) -> Option<std::cmp::Ordering> {
10290    use std::cmp::Ordering;
10291    if matches!(a, Value::Null) || matches!(b, Value::Null) {
10292        return None;
10293    }
10294    if let (Value::List(xs), Value::List(ys)) = (a, b) {
10295        for (x, y) in xs.iter().zip(ys) {
10296            match value_partial_cmp(x, y) {
10297                Some(Ordering::Equal) => continue,
10298                other => return other,
10299            }
10300        }
10301        return Some(xs.len().cmp(&ys.len()));
10302    }
10303    // Real Cypher's `<`/`<=`/`>`/`>=` (unlike ORDER BY/`min`/`max`, which
10304    // need a *total* order across every type for presentation purposes --
10305    // see `comparable_ordering`'s own docs) is only ever defined within a
10306    // single comparable type. A genuine cross-type pair (a list against a
10307    // string, a node against a number, ...) must be `null`, not
10308    // `comparable_ordering`'s type-rank fallback -- that fallback exists
10309    // purely for `list_cmp_asc`/`min`/`max`'s total-order needs and must
10310    // not leak into a real WHERE-predicate comparison. Verified against
10311    // Comparison2's own "Comparing across types yields null, except
10312    // numbers" scenario (`[] < 1`/`[] < ''`/`[] < true` were all wrongly
10313    // `true` before this check, since `[]` alone -- not both sides --
10314    // isn't `Value`-to-`PropertyValue` representable, falling through to
10315    // the type-rank fallback).
10316    if value_to_comparable(a).is_none() || value_to_comparable(b).is_none() {
10317        return None;
10318    }
10319    comparable_ordering(a, b)
10320}
10321
10322/// `=`/`<>`'s equality -- three-valued (`None` is Cypher's "unknown"),
10323/// recursing into `List`/`Map` element-by-element so a `null` *inside* a
10324/// list/map only makes the overall result unknown when it actually
10325/// matters, not automatically `false`/`true`: a length/key-set mismatch
10326/// is `false` outright (definite, regardless of any null present --
10327/// `{k: null} = {}` is `false`, not `null`, since the key sets alone
10328/// already prove inequality), a definite element mismatch anywhere makes
10329/// the whole comparison `false` (short-circuits, `false` outranks
10330/// `unknown` the same way `and3`/`or3` already rank them), and only once
10331/// every element is confirmed equal or unknown (never definitely
10332/// unequal) does an unknown element propagate to an unknown overall
10333/// result. Verified against every row of List3's and Comparison1's
10334/// list/map equality scenarios. Scalars fall back to numeric-cross-type-
10335/// aware equality (`1 = 1.0` is `true`, unlike `value_eq`'s plain
10336/// `PropertyValue` equality, which doesn't promote `Int`/`Float` against
10337/// each other) or plain `value_eq` for everything else (`Date`,
10338/// `Duration`'s component equality, `Node`/`Edge` identity, ...).
10339fn value_equal_ternary(a: &Value, b: &Value) -> Option<bool> {
10340    match (a, b) {
10341        (Value::Null, _) | (_, Value::Null) => None,
10342        (Value::List(xs), Value::List(ys)) => {
10343            if xs.len() != ys.len() {
10344                return Some(false);
10345            }
10346            fold_ternary_eq(xs.iter().zip(ys).map(|(x, y)| value_equal_ternary(x, y)))
10347        }
10348        (Value::Map(x), Value::Map(y)) => {
10349            if !x.keys().eq(y.keys()) {
10350                return Some(false);
10351            }
10352            fold_ternary_eq(x.iter().map(|(k, xv)| value_equal_ternary(xv, &y[k])))
10353        }
10354        _ => Some(values_equal_numeric_aware(a, b)),
10355    }
10356}
10357
10358/// `needle IN haystack` -- three-valued like `=`, since it's built from
10359/// `=` per element: a definite match wins outright even past a later
10360/// `null` element (short-circuits, matching `and3`/`or3`'s "false/true
10361/// outranks unknown" convention), no match with at least one `null`
10362/// element compared along the way is "unknown" (not `false` -- that
10363/// element *might* have matched), no match and no `null` anywhere is a
10364/// definite `false`. An empty list is always a definite `false`
10365/// regardless of `needle`'s own nullness (nothing to compare against, no
10366/// unknown comparisons ever happened) -- verified against Comparison5's
10367/// exact empty-list scenarios. `haystack` being `Null` itself (not an
10368/// empty list) is "unknown", matching `=`'s own null-operand rule;
10369/// anything else on the right isn't a list at all, a real type error.
10370fn list_membership_ternary(needle: &Value, haystack: &Value) -> Result<Option<bool>, QueryError> {
10371    match haystack {
10372        Value::Null => Ok(None),
10373        Value::List(items) => {
10374            let mut saw_unknown = false;
10375            for item in items {
10376                match value_equal_ternary(needle, item) {
10377                    Some(true) => return Ok(Some(true)),
10378                    Some(false) => {}
10379                    None => saw_unknown = true,
10380                }
10381            }
10382            Ok(if saw_unknown { None } else { Some(false) })
10383        }
10384        other => Err(QueryError::Type(format!(
10385            "IN requires a list on the right-hand side, got {other:?}"
10386        ))),
10387    }
10388}
10389
10390/// Combines a sequence of per-element three-valued equality results into
10391/// one overall result: any definite `Some(false)` wins outright
10392/// (short-circuits), otherwise `Some(true)` only if every element was a
10393/// definite `Some(true)`, else `None` (at least one element's equality
10394/// was itself unknown, and nothing else disproved the match).
10395fn fold_ternary_eq(mut results: impl Iterator<Item = Option<bool>>) -> Option<bool> {
10396    let mut saw_unknown = false;
10397    for r in results.by_ref() {
10398        match r {
10399            Some(false) => return Some(false),
10400            Some(true) => {}
10401            None => saw_unknown = true,
10402        }
10403    }
10404    if saw_unknown {
10405        None
10406    } else {
10407        Some(true)
10408    }
10409}
10410
10411/// `=`/`<>`'s scalar leaf case: numeric cross-type promotion (`1 = 1.0`
10412/// is `true` in real Cypher, matching `compare()`'s existing `Int`-vs-
10413/// `Float` handling) that `value_eq`'s plain `PropertyValue` equality
10414/// doesn't give (`PropertyValue::Int(1) != PropertyValue::Float(1.0)`,
10415/// different enum variants) -- falls back to `value_eq` for every non-
10416/// numeric pair (`Date`, `Duration`'s component equality, `String`,
10417/// `Bool`, `Node`/`Edge` identity, ...), which is already correct for
10418/// those.
10419fn values_equal_numeric_aware(a: &Value, b: &Value) -> bool {
10420    match (as_arith_num(a), as_arith_num(b)) {
10421        (Some(ArithNum::Int(x)), Some(ArithNum::Int(y))) => x == y,
10422        (Some(ArithNum::Int(x)), Some(ArithNum::Float(y)))
10423        | (Some(ArithNum::Float(y)), Some(ArithNum::Int(x))) => x as f64 == y,
10424        (Some(ArithNum::Float(x)), Some(ArithNum::Float(y))) => x == y,
10425        _ => value_eq(a, b),
10426    }
10427}