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, IndexSeekValue, 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 IndexSeekValue,
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    ///
3400    /// `spec.value` is either fixed for the whole seek (a literal, or a
3401    /// `$param` already resolved to one -- looked up once, reused across
3402    /// every seed row, same as before this `enum` existed) or row-
3403    /// dependent (`IndexSeekValue::RowExpr`, e.g. `row.field` from an
3404    /// enclosing `UNWIND`) -- re-evaluated and re-looked-up for each seed
3405    /// row, since a different row can mean a different lookup value. This
3406    /// is the fix for what was previously *always* a `NodeByLabelScan` +
3407    /// `Filter` for that shape (`planner::apply_index_seeks` only
3408    /// recognized a literal-valued equality, never a per-row one) -- an
3409    /// O(label size) scan repeated per incoming row, the exact pattern a
3410    /// bulk import's relationship-creation pass hits hardest.
3411    fn stream_index_seek<'s>(
3412        &'s self,
3413        txn: Txn<'s>,
3414        spec: IndexSeekSpec<'s>,
3415        seed: &'s [BindingRow],
3416        guard: &'s ExecutionGuard<'_>,
3417        row_limit: Option<usize>,
3418    ) -> RowStream<'s> {
3419        let budget_node_limit = guard.options.max_intermediate_rows.map(|max_rows| {
3420            max_rows
3421                .checked_div(seed.len().max(1))
3422                .unwrap_or(0)
3423                .saturating_add(1)
3424        });
3425        let storage_limit = match (row_limit, budget_node_limit) {
3426            (Some(a), Some(b)) => Some(a.min(b)),
3427            (Some(a), None) => Some(a),
3428            (None, Some(b)) => Some(b),
3429            (None, None) => None,
3430        };
3431        let lookup = move |value: &PropertyValue| -> Result<Vec<NodeId>, QueryError> {
3432            match storage_limit {
3433                Some(limit) => GraphStore::lookup_by_index_limited_in_txn(
3434                    txn, spec.label, spec.prop, value, limit,
3435                )
3436                .map_err(Into::into),
3437                None => GraphStore::lookup_by_index_in_txn(txn, spec.label, spec.prop, value)
3438                    .map_err(Into::into),
3439            }
3440        };
3441        match spec.value {
3442            // One lookup, reused across every seed row -- identical shape
3443            // to `stream_scan`'s own cross join, and to this function
3444            // before `IndexSeekValue` existed.
3445            IndexSeekValue::Fixed(value) => {
3446                let mut node_ids: Option<Vec<NodeId>> = None;
3447                let mut seed_index = 0usize;
3448                let mut node_index = 0usize;
3449                let mut done = false;
3450                let stream = std::iter::from_fn(move || {
3451                    if done || seed.is_empty() {
3452                        return None;
3453                    }
3454                    let ids = match &node_ids {
3455                        Some(ids) => ids,
3456                        None => match lookup(value) {
3457                            Ok(ids) => node_ids.insert(ids),
3458                            Err(error) => {
3459                                done = true;
3460                                return Some(Err(error));
3461                            }
3462                        },
3463                    };
3464                    if ids.is_empty() || seed_index >= seed.len() {
3465                        return None;
3466                    }
3467                    if let Err(error) = guard.checkpoint() {
3468                        done = true;
3469                        return Some(Err(error));
3470                    }
3471                    let mut row = seed[seed_index].clone();
3472                    row.insert(spec.var.to_string(), Binding::Node(ids[node_index]));
3473                    node_index += 1;
3474                    if node_index == ids.len() {
3475                        node_index = 0;
3476                        seed_index += 1;
3477                    }
3478                    Some(Ok(row))
3479                });
3480                Self::count_stream(Box::new(stream), guard)
3481            }
3482            // A fresh lookup per seed row -- `expr` (e.g. `row.field` from
3483            // an enclosing `UNWIND`) can evaluate to a different value for
3484            // each one, so last row's `node_ids` can't be reused for the
3485            // next.
3486            IndexSeekValue::RowExpr(expr) => {
3487                let mut node_ids: Vec<NodeId> = Vec::new();
3488                let mut seed_index = 0usize;
3489                let mut node_index = 0usize;
3490                let mut done = false;
3491                let stream = std::iter::from_fn(move || loop {
3492                    if done || seed_index >= seed.len() {
3493                        return None;
3494                    }
3495                    if node_index == 0 {
3496                        let evaluated =
3497                            match self.eval_return_expr(txn, expr, &seed[seed_index], guard) {
3498                                Ok(v) => v,
3499                                Err(error) => {
3500                                    done = true;
3501                                    return Some(Err(error));
3502                                }
3503                            };
3504                        let value = value_to_property_value(&evaluated);
3505                        // Real Cypher's three-valued logic: comparing
3506                        // against `null` is "unknown", not "find nodes
3507                        // whose stored value happens to be Null" -- this
3508                        // row contributes zero rows, same as the Filter
3509                        // fallback this replaces would reject it outright.
3510                        if matches!(value, PropertyValue::Null) {
3511                            seed_index += 1;
3512                            continue;
3513                        }
3514                        node_ids = match lookup(&value) {
3515                            Ok(ids) => ids,
3516                            Err(error) => {
3517                                done = true;
3518                                return Some(Err(error));
3519                            }
3520                        };
3521                        if node_ids.is_empty() {
3522                            seed_index += 1;
3523                            continue;
3524                        }
3525                    }
3526                    if let Err(error) = guard.checkpoint() {
3527                        done = true;
3528                        return Some(Err(error));
3529                    }
3530                    let mut row = seed[seed_index].clone();
3531                    row.insert(spec.var.to_string(), Binding::Node(node_ids[node_index]));
3532                    node_index += 1;
3533                    if node_index == node_ids.len() {
3534                        node_index = 0;
3535                        seed_index += 1;
3536                    }
3537                    return Some(Ok(row));
3538                });
3539                Self::count_stream(Box::new(stream), guard)
3540            }
3541        }
3542    }
3543
3544    fn expand_variable_row(
3545        &self,
3546        txn: Txn,
3547        row: BindingRow,
3548        spec: VarExpandSpec<'_>,
3549        guard: &ExecutionGuard<'_>,
3550    ) -> Result<Vec<BindingRow>, QueryError> {
3551        let start_id = match row.get(spec.from_var) {
3552            Some(Binding::Node(id)) => *id,
3553            Some(Binding::Value(PropertyValue::Null)) => return Ok(Vec::new()),
3554            _ => return Err(QueryError::UnboundVariable(spec.from_var.to_string())),
3555        };
3556        let mut out = Vec::new();
3557        if spec.min_hops == 0 {
3558            let mut new_row = row.clone();
3559            new_row.insert(spec.to_var.to_string(), Binding::Node(start_id));
3560            if let Some(path_segment_var) = spec.path_segment_var {
3561                new_row.insert(path_segment_var.to_string(), Binding::Path(Vec::new()));
3562            }
3563            if let Some(rel_list_var) = spec.rel_list_var {
3564                new_row.insert(rel_list_var.to_string(), Binding::List(Vec::new()));
3565            }
3566            new_row.insert(spec.exclude_edge_var.to_string(), Binding::Path(Vec::new()));
3567            out.push(new_row);
3568        }
3569        // `[:TYPE* {year: 1988}]` -- evaluated once here (constant across
3570        // the whole BFS, not per-candidate; the values can reference this
3571        // row's own already-bound variables, same as a fixed hop's inline
3572        // props already can) and checked against each candidate edge's
3573        // own stored properties during expansion below (TCK's Match4
3574        // `[5]`).
3575        let rel_props = spec
3576            .rel_props
3577            .iter()
3578            .map(|(key, expr)| {
3579                let value = self.eval_return_expr(txn, expr, &row, guard)?;
3580                Ok::<_, QueryError>((key.as_str(), value_to_property_value(&value)))
3581            })
3582            .collect::<Result<Vec<_>, _>>()?;
3583        let unbounded = spec.max_hops.is_none();
3584        let effective_max = spec.max_hops.unwrap_or(VAR_EXPAND_DEPTH_CAP);
3585        // Real Cypher's edge-isomorphism rule (no relationship repeated
3586        // within one MATCH pattern) applies across the *whole* pattern, not
3587        // just within this hop's own BFS -- seed the excluded set with
3588        // whatever edges earlier fixed hops of this same pattern already
3589        // bound, so this traversal can't walk back over one of them (see
3590        // `LogicalPlan::VarExpand`'s docs; found via TCK's Match5 `[27]`).
3591        // Complementary direction: an *earlier variable-length* hop's own
3592        // traversed-edge set (deposited under its own `exclude_edge_var`,
3593        // see `LogicalPlan::VarExpand`'s docs) -- union every such row's
3594        // `Binding::Path` edge ids in too (TCK's Match4 `[7]`).
3595        let seed_used_edges: HashSet<EdgeId> = spec
3596            .exclude_edge_vars
3597            .iter()
3598            .filter_map(|v| match row.get(v) {
3599                Some(Binding::Edge(id)) => Some(*id),
3600                _ => None,
3601            })
3602            .chain(spec.exclude_edge_sets.iter().flat_map(|v| {
3603                match row.get(v) {
3604                    Some(Binding::Path(segment)) => segment
3605                        .iter()
3606                        .filter_map(|p| match p {
3607                            PathBinding::Edge(id) => Some(*id),
3608                            PathBinding::Node(_) => None,
3609                        })
3610                        .collect::<Vec<_>>(),
3611                    _ => Vec::new(),
3612                }
3613            }))
3614            .collect();
3615        // The ordered `Edge, Node, Edge, Node, ...` sequence built up so
3616        // far, alongside the existing `used_edges` isomorphism set --
3617        // only actually consulted when `path_segment_var` is set (named-
3618        // path capture over this hop, see `LogicalPlan::VarExpand`'s own
3619        // docs), but always threaded through the BFS regardless (a plain
3620        // `Vec`, cheap to carry and clone even when unused).
3621        let mut frontier = vec![(start_id, seed_used_edges, Vec::<PathBinding>::new())];
3622        let mut depth = 0u32;
3623        while depth < effective_max && !frontier.is_empty() {
3624            depth += 1;
3625            let mut next_frontier = Vec::new();
3626            for (node, used_edges, segment) in frontier {
3627                for entry in neighbors_for_direction(txn, node, spec.direction, spec.rel_labels)? {
3628                    guard.relationship_expansion()?;
3629                    if used_edges.contains(&entry.edge_id) {
3630                        continue;
3631                    }
3632                    if !rel_props.is_empty() {
3633                        let edge = deleted_entity_access(GraphStore::get_edge_in_txn(
3634                            txn,
3635                            entry.edge_id,
3636                        )?)?;
3637                        let matches = rel_props
3638                            .iter()
3639                            .all(|(key, expected)| edge.props.get(*key) == Some(expected));
3640                        if !matches {
3641                            continue;
3642                        }
3643                    }
3644                    let mut next_used_edges = used_edges.clone();
3645                    next_used_edges.insert(entry.edge_id);
3646                    let mut next_segment = segment.clone();
3647                    next_segment.push(PathBinding::Edge(entry.edge_id));
3648                    next_segment.push(PathBinding::Node(entry.other));
3649                    next_frontier.push((entry.other, next_used_edges, next_segment.clone()));
3650                    guard.check_intermediate_rows(next_frontier.len())?;
3651                    if depth >= spec.min_hops {
3652                        let mut new_row = row.clone();
3653                        new_row.insert(spec.to_var.to_string(), Binding::Node(entry.other));
3654                        if let Some(path_segment_var) = spec.path_segment_var {
3655                            new_row.insert(
3656                                path_segment_var.to_string(),
3657                                Binding::Path(next_segment.clone()),
3658                            );
3659                        }
3660                        if let Some(rel_list_var) = spec.rel_list_var {
3661                            let edges = segment_edges_to_list(txn, &next_segment)?;
3662                            new_row.insert(rel_list_var.to_string(), edges);
3663                        }
3664                        new_row.insert(
3665                            spec.exclude_edge_var.to_string(),
3666                            Binding::Path(next_segment.clone()),
3667                        );
3668                        out.push(new_row);
3669                        guard.check_intermediate_rows(out.len())?;
3670                    }
3671                }
3672            }
3673            frontier = next_frontier;
3674            if depth == effective_max && unbounded && !frontier.is_empty() {
3675                return Err(QueryError::ResourceLimit(format!(
3676                    "variable-length traversal exceeded the safety depth cap ({VAR_EXPAND_DEPTH_CAP} \
3677                     hops) — likely a cyclic graph or unexpectedly large fanout; narrow the pattern or \
3678                     add an explicit upper bound (e.g. *0..10)"
3679                )));
3680            }
3681        }
3682        Ok(out)
3683    }
3684
3685    /// `LogicalPlan::MatchRelList`'s own docs -- deterministic, no search:
3686    /// `spec.rel_list_var`'s edges are already concrete, so there's
3687    /// exactly one possible walk to check, starting from `spec.from_var`'s
3688    /// already-bound node. Returns `Ok(None)` (row dropped, not an error)
3689    /// for every "doesn't match" case -- wrong hop count, a broken chain,
3690    /// an edge whose label isn't in `spec.rel_labels` -- same "no match
3691    /// survives" convention `Expand`/`VarExpand` already use for a filter
3692    /// that simply excludes a row.
3693    fn match_bound_rel_list_row(
3694        &self,
3695        row: BindingRow,
3696        spec: MatchRelListSpec<'_>,
3697    ) -> Result<Option<BindingRow>, QueryError> {
3698        let start_id = match row.get(spec.from_var) {
3699            Some(Binding::Node(id)) => *id,
3700            Some(Binding::Value(PropertyValue::Null)) => return Ok(None),
3701            _ => return Err(QueryError::UnboundVariable(spec.from_var.to_string())),
3702        };
3703        let edges: Vec<&Edge> = match row.get(spec.rel_list_var) {
3704            Some(Binding::List(items)) => items
3705                .iter()
3706                .map(|v| match v {
3707                    Value::Edge(e) => Ok(e),
3708                    other => Err(QueryError::Type(format!(
3709                        "'{}' must be a list of relationships, found {other:?} in it",
3710                        spec.rel_list_var
3711                    ))),
3712                })
3713                .collect::<Result<_, _>>()?,
3714            Some(Binding::Value(PropertyValue::Null)) => return Ok(None),
3715            _ => return Err(QueryError::UnboundVariable(spec.rel_list_var.to_string())),
3716        };
3717        let hops = edges.len() as u32;
3718        if hops < spec.min_hops || spec.max_hops.is_some_and(|max| hops > max) {
3719            return Ok(None);
3720        }
3721        if !spec.rel_labels.is_empty() && edges.iter().any(|e| !spec.rel_labels.contains(&e.label))
3722        {
3723            return Ok(None);
3724        }
3725        let mut current = start_id;
3726        for edge in &edges {
3727            let next = match spec.direction {
3728                ExpandDirection::Out if edge.src == current => edge.dst,
3729                ExpandDirection::In if edge.dst == current => edge.src,
3730                ExpandDirection::Either if edge.src == current => edge.dst,
3731                ExpandDirection::Either if edge.dst == current => edge.src,
3732                _ => return Ok(None),
3733            };
3734            current = next;
3735        }
3736        let mut new_row = row.clone();
3737        new_row.insert(spec.to_var.to_string(), Binding::Node(current));
3738        Ok(Some(new_row))
3739    }
3740
3741    /// `Option<bool>` — see `eval_with_expr`'s docs, same reasoning.
3742    /// `HasLabel`/`VarEq` never produce "unknown" (they operate on real
3743    /// bound node/edge identity, not a possibly-null property), so they
3744    /// always return `Some`.
3745    fn eval_expr(
3746        &self,
3747        txn: Txn,
3748        expr: &Expr,
3749        row: &BindingRow,
3750        guard: &ExecutionGuard<'_>,
3751    ) -> Result<Option<bool>, QueryError> {
3752        Ok(match expr {
3753            Expr::And(l, r) => and3(
3754                self.eval_expr(txn, l, row, guard)?,
3755                self.eval_expr(txn, r, row, guard)?,
3756            ),
3757            Expr::Or(l, r) => or3(
3758                self.eval_expr(txn, l, row, guard)?,
3759                self.eval_expr(txn, r, row, guard)?,
3760            ),
3761            Expr::Not(e) => self.eval_expr(txn, e, row, guard)?.map(|b| !b),
3762            Expr::Compare(pa, op, lit) => {
3763                let prop_value = self.lookup_prop(txn, pa, row)?;
3764                compare(&prop_value, *op, lit)
3765            }
3766            Expr::PropCompare(left, op, right) => {
3767                let a = self.lookup_prop(txn, left, row)?;
3768                let b = self.lookup_prop(txn, right, row)?;
3769                compare_property_pair_opt(&a, *op, &b)
3770            }
3771            // Always definite -- that's the whole point of IS NULL, so
3772            // this is the one `Expr` leaf that's always `Some`, same as
3773            // `HasLabel`/`VarEq` below.
3774            Expr::IsNull(pa) => Some(matches!(
3775                self.lookup_prop(txn, pa, row)?,
3776                None | Some(PropertyValue::Null)
3777            )),
3778            Expr::HasLabel(var, label) => {
3779                let binding = row
3780                    .get(var)
3781                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
3782                let Binding::Node(id) = binding else {
3783                    return Err(QueryError::UnboundVariable(var.clone()));
3784                };
3785                let node = GraphStore::get_node_in_txn(txn, *id)?;
3786                Some(node.is_some_and(|n| n.labels.iter().any(|l| l == label)))
3787            }
3788            Expr::VarEq(a, b) => {
3789                let ba = row
3790                    .get(a)
3791                    .ok_or_else(|| QueryError::UnboundVariable(a.clone()))?;
3792                let bb = row
3793                    .get(b)
3794                    .ok_or_else(|| QueryError::UnboundVariable(b.clone()))?;
3795                Some(match (ba, bb) {
3796                    (Binding::Node(x), Binding::Node(y)) => x == y,
3797                    (Binding::Edge(x), Binding::Edge(y)) => x == y,
3798                    // A null-padded `Binding::Value` (from an earlier
3799                    // OPTIONAL MATCH that didn't match) can't equal a
3800                    // real node/edge, and comparing across binding kinds
3801                    // (a node vs an edge) is never meaningful here — the
3802                    // planner only ever synthesizes VarEq between two
3803                    // occurrences of the same pattern variable, which are
3804                    // always the same kind when both are real.
3805                    _ => false,
3806                })
3807            }
3808            Expr::GeneralCompare(lhs, op, rhs) => {
3809                let lv = self.eval_return_expr(txn, lhs, row, guard)?;
3810                let rv = self.eval_return_expr(txn, rhs, row, guard)?;
3811                compare_values(&lv, *op, &rv)
3812            }
3813            Expr::GeneralIsNull(e) => Some(matches!(
3814                self.eval_return_expr(txn, e, row, guard)?,
3815                Value::Null
3816            )),
3817            Expr::GeneralBare(e) => self.eval_return_expr_bool3(txn, e, row, guard)?,
3818            // `WHERE (n)-[:REL]->()` etc (TCK's Pattern1) -- existential:
3819            // true iff at least one real match of `pattern` exists, with
3820            // every already-bound named endpoint (`n`, and `m` in `(n)-->
3821            // (m)` when `m` is also bound by an earlier MATCH) held fixed
3822            // to this row's own binding rather than searched freely.
3823            // `semantic::bind_pattern_predicate` already rejected any
3824            // named endpoint that ISN'T already bound (real Cypher's
3825            // UndefinedVariable), so every named var here is safe to seed.
3826            // Reuses the exact same `build_match_plan` "already-bound var
3827            // -> Seed, not a fresh scan" mechanism `eval_merge`'s own
3828            // "try as an ordinary MATCH first" half already relies on --
3829            // for a one-hop pattern this is a real connected-subgraph
3830            // search (Expand + Filter), not an isolated per-node check.
3831            // `Some(1)`-limited: existence is all that's needed, so
3832            // there's no reason to enumerate every match.
3833            Expr::Pattern(pattern) => {
3834                Some(self.eval_pattern_predicate_exists(txn, pattern, row, guard)?)
3835            }
3836            // `exists { (n)-->(m) WHERE ... }` (TCK's ExistentialSubquery1,
3837            // the "simple" form) -- same existential search as `Pattern`
3838            // above, just with its own inline `where?` threaded straight
3839            // into `build_match_plan`, same as an ordinary `MATCH ...
3840            // WHERE ...` (not evaluated as a separate post-filter step).
3841            Expr::Exists {
3842                pattern,
3843                where_clause,
3844            } => {
3845                let carried_vars: HashSet<String> = row.keys().cloned().collect();
3846                let wc: Option<Expr> = where_clause.as_deref().cloned();
3847                let plan = apply_index_seeks(build_match_plan(pattern, &wc, &carried_vars)?, txn)?;
3848                let found = self.eval_plan_with_limit(
3849                    txn,
3850                    &plan,
3851                    std::slice::from_ref(row),
3852                    guard,
3853                    Some(1),
3854                )?;
3855                Some(!found.is_empty())
3856            }
3857            // `exists { MATCH ... RETURN ... }` (TCK's
3858            // ExistentialSubquery2/3, the "full" form) -- runs the nested
3859            // statement correlated against `row` (`execute_match_seeded`)
3860            // and checks whether it produced at least one output row.
3861            Expr::ExistsSubquery(stmt) => Some(self.eval_exists_subquery(txn, stmt, row, guard)?),
3862            // See `Expr::EdgeNotInSet`'s own docs -- `edge_var` is always
3863            // a real `Binding::Edge` (a fixed hop's own filter var, the
3864            // only thing this gets generated for) and `edge_set_var` is
3865            // always the `Binding::Path` `expand_variable_row` deposits
3866            // for *every* variable-length hop, unconditionally (see
3867            // `LogicalPlan::VarExpand::exclude_edge_var`'s own docs) --
3868            // never anything else, so there's no null/wrong-kind case to
3869            // handle here the way `VarEq` above has to.
3870            Expr::EdgeNotInSet {
3871                edge_var,
3872                edge_set_var,
3873            } => {
3874                let Some(Binding::Edge(edge_id)) = row.get(edge_var) else {
3875                    return Err(QueryError::UnboundVariable(edge_var.clone()));
3876                };
3877                let Some(Binding::Path(segment)) = row.get(edge_set_var) else {
3878                    return Err(QueryError::UnboundVariable(edge_set_var.clone()));
3879                };
3880                Some(
3881                    !segment
3882                        .iter()
3883                        .any(|elem| matches!(elem, PathBinding::Edge(id) if id == edge_id)),
3884                )
3885            }
3886        })
3887    }
3888
3889    fn lookup_prop(
3890        &self,
3891        txn: Txn,
3892        pa: &PropAccess,
3893        row: &BindingRow,
3894    ) -> Result<Option<PropertyValue>, QueryError> {
3895        let binding = row
3896            .get(&pa.var)
3897            .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
3898        match binding {
3899            // A missing *property key* on an existing node/edge is a real,
3900            // legal "absent" (-> null downstream) -- but a missing
3901            // *node/edge record* means it was deleted earlier in this same
3902            // statement (`deleted_entity_access`'s docs), which is a real
3903            // error (`MATCH (n) DELETE n RETURN n.num` -- TCK's Return2
3904            // scenario [15]), not a silent null. These are two different
3905            // kinds of "missing" and must not be collapsed into one.
3906            Binding::Node(id) => {
3907                let node = deleted_entity_access(GraphStore::get_node_in_txn(txn, *id)?)?;
3908                Ok(node.props.get(&pa.prop).cloned())
3909            }
3910            Binding::Edge(id) => {
3911                let edge = deleted_entity_access(GraphStore::get_edge_in_txn(txn, *id)?)?;
3912                Ok(edge.props.get(&pa.prop).cloned())
3913            }
3914            // A WITH-projected scalar (or list/map) has no scalar `.prop`
3915            // to access via this path — e.g. `WITH message.id AS
3916            // messageId` then `messageId.foo` isn't meaningful. Treat as
3917            // absent rather than erroring, consistent with how a missing
3918            // property already behaves. `Binding::Map` specifically *does*
3919            // have real `.prop` access, just not through this method (its
3920            // values aren't always a scalar `PropertyValue`) — see
3921            // `lookup_prop_value`, which `ReturnExpr::Prop` actually calls.
3922            // A `Binding::Value` holding a `Date`/`Duration` also has real
3923            // `.prop` access (`d.year`, etc) — also handled there, not
3924            // here, for the same "not always a scalar `PropertyValue`"
3925            // reason (well, it always *is* one here, but `lookup_prop_value`
3926            // is where that access actually happens either way).
3927            Binding::Value(_) | Binding::List(_) | Binding::Map(_) => Ok(None),
3928            // Unlike the others, a path is a real type error, not just an
3929            // "absent" property -- real Cypher's `InvalidArgumentType`
3930            // (TCK's MatchWhere1 `[14]`: `MATCH r = (n)-[*]->() WHERE
3931            // r.name = 'apa'`). Property access never had a meaning for a
3932            // path to begin with (it's not a graph-object-shaped value).
3933            Binding::Path(_) => Err(QueryError::Type(format!(
3934                "'{}' is a path — property access requires a node, relationship, or map",
3935                pa.var
3936            ))),
3937        }
3938    }
3939
3940    /// `ReturnExpr::Prop`'s own lookup -- unlike `lookup_prop` (used by
3941    /// pattern-level `WHERE`, which only ever compares a real node/edge
3942    /// property against a `Literal`), a map's value can be any `Value`
3943    /// shape (nested list/map/node), not just a scalar `PropertyValue`,
3944    /// so this returns the wider type and handles `Binding::Map` itself
3945    /// rather than collapsing through `lookup_prop`. A `Binding::Value`
3946    /// holding a `Date`/`Duration` is handled here too, for the same
3947    /// reason -- `d.year`/`d.months`/etc are real component accessors
3948    /// (Temporal5's whole scenario shape, `WITH v.date AS d ... RETURN
3949    /// d.year`), not a stored property `lookup_prop` could ever find.
3950    ///
3951    /// Only a node, relationship, map, or temporal value has any `.prop`
3952    /// to access at all -- a plain scalar (`Bool`/`Int`/`Float`/`String`)
3953    /// or a `List` is a real type error here (real Cypher's own
3954    /// `InvalidArgumentType` is raised at *compile* time; this codebase's
3955    /// `Kind` system can't see through a WITH-projected value's real
3956    /// runtime shape to catch it any earlier -- see `infer_expr`'s own
3957    /// `Kind::Scalar` docs -- so it surfaces here instead), not a silent
3958    /// `null` (TCK's Graph6 [9] / Map1 [6]). `null` itself is exempt --
3959    /// real Cypher's null propagation rule, not a type error.
3960    fn lookup_prop_value(
3961        &self,
3962        txn: Txn,
3963        pa: &PropAccess,
3964        row: &BindingRow,
3965    ) -> Result<Value, QueryError> {
3966        match row.get(&pa.var) {
3967            Some(Binding::Map(m)) => Ok(m.get(&pa.prop).cloned().unwrap_or(Value::Null)),
3968            Some(Binding::Value(PropertyValue::Null)) => Ok(Value::Null),
3969            Some(Binding::Value(pv)) => match temporal_component(pv, &pa.prop) {
3970                Some(component) => Ok(Value::Property(component)),
3971                None if is_temporal_property_value(pv) => Ok(Value::Null),
3972                None => Err(QueryError::Type(format!(
3973                    "'{}' can't have properties accessed on it -- property access requires a \
3974                     node, relationship, map, or temporal value",
3975                    pa.var
3976                ))),
3977            },
3978            Some(Binding::List(_)) => Err(QueryError::Type(format!(
3979                "'{}' can't have properties accessed on it -- property access requires a node, \
3980                 relationship, map, or temporal value, not a list",
3981                pa.var
3982            ))),
3983            Some(_) => Ok(match self.lookup_prop(txn, pa, row)? {
3984                Some(PropertyValue::Null) | None => Value::Null,
3985                Some(pv) => property_value_to_value(pv),
3986            }),
3987            None => Err(QueryError::UnboundVariable(pa.var.clone())),
3988        }
3989    }
3990
3991    fn materialize_return(
3992        &self,
3993        txn: Txn,
3994        items: &[ReturnItem],
3995        rows: &[BindingRow],
3996        distinct: bool,
3997        guard: &ExecutionGuard<'_>,
3998    ) -> Result<QueryResult, QueryError> {
3999        let columns = items
4000            .iter()
4001            .enumerate()
4002            .map(|(i, item)| {
4003                item.alias
4004                    .clone()
4005                    .unwrap_or_else(|| default_column_name(&item.expr, i))
4006            })
4007            .collect();
4008        let mut out_rows = if !has_aggregate(items) {
4009            let mut out_rows = Vec::with_capacity(rows.len());
4010            for row in rows {
4011                let mut out_row = Vec::with_capacity(items.len());
4012                for item in items {
4013                    out_row.push(self.eval_return_expr(txn, &item.expr, row, guard)?);
4014                }
4015                out_rows.push(out_row);
4016            }
4017            out_rows
4018        } else {
4019            validate_return_items(items)?;
4020            let grouped = self.resolve_grouped_rows(txn, items, rows, guard)?;
4021            grouped
4022                .into_iter()
4023                .map(|bindings| {
4024                    bindings
4025                        .iter()
4026                        .map(|b| self.binding_to_value(txn, b))
4027                        .collect::<Result<Vec<_>, _>>()
4028                })
4029                .collect::<Result<Vec<_>, _>>()?
4030        };
4031        if distinct {
4032            out_rows = dedup_rows(out_rows)?;
4033        }
4034        Ok(QueryResult {
4035            columns,
4036            rows: out_rows,
4037        })
4038    }
4039
4040    /// An aggregating `RETURN`'s own `ORDER BY`, when at least one key
4041    /// doesn't verbatim/alias-match any item -- `RETURN me.age AS age,
4042    /// count(you.age) AS cnt ORDER BY age + count(you.age)` (TCK's
4043    /// ReturnOrderBy6). Folds those extra keys through the *same*
4044    /// grouping pass as `items` themselves, as synthetic unreturned extra
4045    /// items (reusing `resolve_grouped_rows`/`rewrite_composed_item`
4046    /// exactly as a composed RETURN item would, including an aggregate
4047    /// call that appears *only* in the ORDER BY key, nowhere in `items`
4048    /// -- real Cypher allows that too, it just needs to fold consistently
4049    /// with `items`' own implicit grouping, not literally reuse one of
4050    /// their accumulators), then uses their per-group values as
4051    /// additional sort keys before stripping them back off. Degrades to
4052    /// exactly the ordinary "sort by already-computed columns" behavior
4053    /// when every key does verbatim/alias-match (`extra_exprs` empty) --
4054    /// callers can route every aggregating-`RETURN`-with-`ORDER-BY` case
4055    /// through this one function rather than branching on whether extras
4056    /// are actually needed.
4057    ///
4058    /// `DISTINCT` isn't handled here -- deliberately: grouping already
4059    /// makes every output row unique by its own grouping-key columns (two
4060    /// groups can't have the same grouping key and still be different
4061    /// groups), so `RETURN DISTINCT` combined with aggregation is
4062    /// provably always a no-op downstream of this function regardless.
4063    fn materialize_aggregating_return_with_order(
4064        &self,
4065        txn: Txn,
4066        items: &[ReturnItem],
4067        rows: &[BindingRow],
4068        order_by: &[(ReturnExpr, SortDir)],
4069        skip_limit: (Option<i64>, Option<i64>),
4070        guard: &ExecutionGuard<'_>,
4071    ) -> Result<QueryResult, QueryError> {
4072        let (skip, limit) = skip_limit;
4073        enum OrderKeySource {
4074            RealColumn(usize),
4075            Extra(usize),
4076        }
4077        let mut extra_exprs: Vec<ReturnExpr> = Vec::new();
4078        let order_by_source: Vec<OrderKeySource> = order_by
4079            .iter()
4080            .map(|(expr, _)| {
4081                match items
4082                    .iter()
4083                    .enumerate()
4084                    .position(|(i, it)| item_matches_leaf(expr, i, it))
4085                {
4086                    Some(i) => OrderKeySource::RealColumn(i),
4087                    None => {
4088                        let idx = extra_exprs.len();
4089                        extra_exprs.push(expr.clone());
4090                        OrderKeySource::Extra(idx)
4091                    }
4092                }
4093            })
4094            .collect();
4095        let extended_items: Vec<ReturnItem> = items
4096            .iter()
4097            .cloned()
4098            .chain(
4099                extra_exprs
4100                    .into_iter()
4101                    .map(|expr| ReturnItem { expr, alias: None }),
4102            )
4103            .collect();
4104        validate_return_items(&extended_items)?;
4105        let grouped = self.resolve_grouped_rows(txn, &extended_items, rows, guard)?;
4106        let columns: Vec<String> = items
4107            .iter()
4108            .enumerate()
4109            .map(|(i, item)| {
4110                item.alias
4111                    .clone()
4112                    .unwrap_or_else(|| default_column_name(&item.expr, i))
4113            })
4114            .collect();
4115        let real_len = items.len();
4116        let mut keyed: Vec<(Vec<Value>, Vec<Value>)> = Vec::with_capacity(grouped.len());
4117        for bindings in grouped {
4118            let values: Vec<Value> = bindings
4119                .iter()
4120                .map(|b| self.binding_to_value(txn, b))
4121                .collect::<Result<Vec<_>, _>>()?;
4122            let (real, extra) = values.split_at(real_len);
4123            let keys: Vec<Value> = order_by_source
4124                .iter()
4125                .map(|src| match src {
4126                    OrderKeySource::RealColumn(i) => real[*i].clone(),
4127                    OrderKeySource::Extra(k) => extra[*k].clone(),
4128                })
4129                .collect();
4130            keyed.push((keys, real.to_vec()));
4131        }
4132        let rows = top_k_by(keyed, order_by, skip, limit)
4133            .into_iter()
4134            .map(|(_, row)| row)
4135            .collect();
4136        Ok(QueryResult { columns, rows })
4137    }
4138
4139    /// `SKIP`/`LIMIT` accept any expression, not just a literal integer
4140    /// (`SKIP $n`, `SKIP toInteger(rand()*9)` -- TCK's `ReturnSkipLimit1
4141    /// [2]`/`[3]`) -- evaluated exactly once here, against an empty row,
4142    /// since no pattern variable can be in scope at a statement's own
4143    /// SKIP/LIMIT (an `UnboundVariable` error from `eval_return_expr`
4144    /// below is exactly the right outcome if one is referenced). Params
4145    /// are already resolved to concrete `Literal`s by this point (see
4146    /// `params::substitute_params`).
4147    fn resolve_skip_limit(
4148        &self,
4149        txn: Txn,
4150        expr: Option<&ReturnExpr>,
4151        clause: &str,
4152        guard: &ExecutionGuard<'_>,
4153    ) -> Result<Option<i64>, QueryError> {
4154        let Some(expr) = expr else {
4155            return Ok(None);
4156        };
4157        let value = self.eval_return_expr(txn, expr, &BindingRow::new(), guard)?;
4158        let n = match value {
4159            Value::Literal(Literal::Int(n)) | Value::Property(PropertyValue::Int(n)) => n,
4160            _ => {
4161                return Err(QueryError::Semantic(format!(
4162                    "{clause} must evaluate to an integer"
4163                )));
4164            }
4165        };
4166        if n < 0 {
4167            return Err(QueryError::Semantic(format!("{clause} can't be negative")));
4168        }
4169        Ok(Some(n))
4170    }
4171
4172    fn eval_return_expr(
4173        &self,
4174        txn: Txn,
4175        expr: &ReturnExpr,
4176        row: &BindingRow,
4177        guard: &ExecutionGuard<'_>,
4178    ) -> Result<Value, QueryError> {
4179        match expr {
4180            ReturnExpr::Var(var) => {
4181                let binding = row
4182                    .get(var)
4183                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
4184                self.binding_to_value(txn, binding)
4185            }
4186            ReturnExpr::Prop(pa) => self.lookup_prop_value(txn, pa, row),
4187            ReturnExpr::PropOf(base, prop) => {
4188                let v = self.eval_return_expr(txn, base, row, guard)?;
4189                property_of_value(&v, prop)
4190            }
4191            ReturnExpr::Lit(lit) => Ok(match lit {
4192                Literal::Null => Value::Null,
4193                other => Value::Literal(other.clone()),
4194            }),
4195            ReturnExpr::Call { name, args, .. } => {
4196                // Reaching here with an aggregate name means an aggregate
4197                // call slipped past `validate_return_items` (which only
4198                // allows one at a return item's top level) — grouping
4199                // itself never calls `eval_return_expr` on the aggregate
4200                // wrapper, only on each aggregate's own argument
4201                // subexpression (see `resolve_grouped_rows`), so this is
4202                // an internal-consistency error, not a normal user path.
4203                if is_aggregate_name(name) {
4204                    return Err(QueryError::Semantic(format!(
4205                        "aggregate function '{name}' can only be used as a return item's top-level expression"
4206                    )));
4207                }
4208                let lower = name.to_ascii_lowercase();
4209                if lower == "type" {
4210                    // Special-cased *before* the generic arg-evaluation
4211                    // below -- that would eagerly fail on a deleted
4212                    // relationship (`deleted_entity_access`), before
4213                    // `eval_type_call` ever gets a chance to fall back to
4214                    // its cached type. See `ExecutionGuard::
4215                    // deleted_edge_types`'s own docs.
4216                    return self.eval_type_call(txn, args.first(), row, guard);
4217                }
4218                let arg_values = args
4219                    .iter()
4220                    .map(|a| self.eval_return_expr(txn, a, row, guard))
4221                    .collect::<Result<Vec<_>, _>>()?;
4222                if lower == "startnode" || lower == "endnode" {
4223                    return self.start_or_end_node(txn, &lower, arg_values.first());
4224                }
4225                call_builtin(name, &arg_values, self.now_snapshot())
4226            }
4227            ReturnExpr::CountStar => Err(QueryError::Semantic(
4228                "count(*) can only be used as a return item's top-level expression".into(),
4229            )),
4230            ReturnExpr::Case { test, whens, else_ } => {
4231                let test_value = match test {
4232                    Some(t) => Some(self.eval_return_expr(txn, t, row, guard)?),
4233                    None => None,
4234                };
4235                for (when, then) in whens {
4236                    let when_value = self.eval_return_expr(txn, when, row, guard)?;
4237                    // Deliberately reuses the same Null == Null -> true
4238                    // convention as `compare()` below, not standard
4239                    // three-valued NULL logic — IS7's `CASE r WHEN null
4240                    // THEN false ELSE true END` depends on this exact
4241                    // semantics to detect an OPTIONAL MATCH non-match.
4242                    let matched = match &test_value {
4243                        Some(tv) => value_eq(tv, &when_value),
4244                        None => matches!(when_value, Value::Literal(Literal::Bool(true))),
4245                    };
4246                    if matched {
4247                        return self.eval_return_expr(txn, then, row, guard);
4248                    }
4249                }
4250                match else_ {
4251                    Some(e) => self.eval_return_expr(txn, e, row, guard),
4252                    None => Ok(Value::Null),
4253                }
4254            }
4255            ReturnExpr::Arith(l, op, r) => {
4256                let lv = self.eval_return_expr(txn, l, row, guard)?;
4257                let rv = self.eval_return_expr(txn, r, row, guard)?;
4258                apply_arith(*op, &lv, &rv)
4259            }
4260            ReturnExpr::Neg(e) => {
4261                let v = self.eval_return_expr(txn, e, row, guard)?;
4262                apply_neg(&v)
4263            }
4264            ReturnExpr::ListLit(items) => Ok(Value::List(
4265                items
4266                    .iter()
4267                    .map(|item| self.eval_return_expr(txn, item, row, guard))
4268                    .collect::<Result<Vec<_>, _>>()?,
4269            )),
4270            ReturnExpr::Index(base, index) => {
4271                let base_v = self.eval_return_expr(txn, base, row, guard)?;
4272                let index_v = self.eval_return_expr(txn, index, row, guard)?;
4273                apply_index(&base_v, &index_v)
4274            }
4275            ReturnExpr::Slice(base, start, end) => {
4276                let base_v = self.eval_return_expr(txn, base, row, guard)?;
4277                let start_v = start
4278                    .as_deref()
4279                    .map(|s| self.eval_return_expr(txn, s, row, guard))
4280                    .transpose()?;
4281                let end_v = end
4282                    .as_deref()
4283                    .map(|e| self.eval_return_expr(txn, e, row, guard))
4284                    .transpose()?;
4285                apply_slice(&base_v, start_v.as_ref(), end_v.as_ref())
4286            }
4287            ReturnExpr::ListComp {
4288                var,
4289                source,
4290                where_clause,
4291                project,
4292            } => {
4293                let source_v = self.eval_return_expr(txn, source, row, guard)?;
4294                let items = match source_v {
4295                    Value::List(items) => items,
4296                    Value::Null => return Ok(Value::Null),
4297                    other => {
4298                        return Err(QueryError::Type(format!(
4299                            "list comprehension source must be a list, got {other:?}"
4300                        )))
4301                    }
4302                };
4303                let mut result = Vec::with_capacity(items.len());
4304                for item in items {
4305                    // A fresh overlay per element -- `var` shadows any
4306                    // outer binding of the same name for the duration of
4307                    // this one element, same scoping UNWIND already uses.
4308                    let mut scoped_row = row.clone();
4309                    scoped_row.insert(var.clone(), value_to_binding_restore(&item));
4310                    let keep = match where_clause {
4311                        Some(w) => {
4312                            self.eval_return_expr_bool3(txn, w, &scoped_row, guard)? == Some(true)
4313                        }
4314                        None => true,
4315                    };
4316                    if !keep {
4317                        continue;
4318                    }
4319                    result.push(match project {
4320                        Some(p) => self.eval_return_expr(txn, p, &scoped_row, guard)?,
4321                        None => item,
4322                    });
4323                }
4324                Ok(Value::List(result))
4325            }
4326            ReturnExpr::Quantifier {
4327                kind,
4328                var,
4329                source,
4330                where_clause,
4331            } => {
4332                let source_v = self.eval_return_expr(txn, source, row, guard)?;
4333                let items = match source_v {
4334                    Value::List(items) => items,
4335                    Value::Null => return Ok(Value::Null),
4336                    other => {
4337                        return Err(QueryError::Type(format!(
4338                            "quantifier source must be a list, got {other:?}"
4339                        )))
4340                    }
4341                };
4342                let mut preds = Vec::with_capacity(items.len());
4343                for item in &items {
4344                    let mut scoped_row = row.clone();
4345                    scoped_row.insert(var.clone(), value_to_binding_restore(item));
4346                    preds.push(match where_clause {
4347                        Some(w) => self.eval_return_expr_bool3(txn, w, &scoped_row, guard)?,
4348                        None => item_truthy(item),
4349                    });
4350                }
4351                Ok(match eval_quantifier(*kind, &preds) {
4352                    Some(b) => Value::Literal(Literal::Bool(b)),
4353                    None => Value::Null,
4354                })
4355            }
4356            ReturnExpr::MapLit(entries) => {
4357                let mut map = BTreeMap::new();
4358                for (k, v) in entries {
4359                    map.insert(k.clone(), self.eval_return_expr(txn, v, row, guard)?);
4360                }
4361                Ok(Value::Map(map))
4362            }
4363            ReturnExpr::And(l, r) => Ok(bool3_to_value(and3(
4364                self.eval_return_expr_bool3(txn, l, row, guard)?,
4365                self.eval_return_expr_bool3(txn, r, row, guard)?,
4366            ))),
4367            ReturnExpr::Or(l, r) => Ok(bool3_to_value(or3(
4368                self.eval_return_expr_bool3(txn, l, row, guard)?,
4369                self.eval_return_expr_bool3(txn, r, row, guard)?,
4370            ))),
4371            ReturnExpr::Xor(l, r) => Ok(bool3_to_value(xor3(
4372                self.eval_return_expr_bool3(txn, l, row, guard)?,
4373                self.eval_return_expr_bool3(txn, r, row, guard)?,
4374            ))),
4375            ReturnExpr::Not(e) => Ok(bool3_to_value(
4376                self.eval_return_expr_bool3(txn, e, row, guard)?.map(|b| !b),
4377            )),
4378            ReturnExpr::Compare(l, op, r) => {
4379                let lv = self.eval_return_expr(txn, l, row, guard)?;
4380                let rv = self.eval_return_expr(txn, r, row, guard)?;
4381                Ok(bool3_to_value(compare_values(&lv, *op, &rv)))
4382            }
4383            ReturnExpr::IsNull(e) => {
4384                let v = self.eval_return_expr(txn, e, row, guard)?;
4385                Ok(Value::Literal(Literal::Bool(matches!(v, Value::Null))))
4386            }
4387            ReturnExpr::In(needle, haystack) => {
4388                let nv = self.eval_return_expr(txn, needle, row, guard)?;
4389                let hv = self.eval_return_expr(txn, haystack, row, guard)?;
4390                Ok(bool3_to_value(list_membership_ternary(&nv, &hv)?))
4391            }
4392            ReturnExpr::HasLabel(var, labels) => {
4393                let binding = row
4394                    .get(var)
4395                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
4396                match binding {
4397                    Binding::Node(id) => {
4398                        let node = deleted_entity_access(GraphStore::get_node_in_txn(txn, *id)?)?;
4399                        Ok(Value::Literal(Literal::Bool(
4400                            labels.iter().all(|l| node.labels.contains(l)),
4401                        )))
4402                    }
4403                    // `r:TYPE` -- a relationship has exactly one type, so
4404                    // this is just an equality check, not a set-membership
4405                    // one; a conjunctive `r:A:B` (only reachable from
4406                    // general expression position, never real Cypher's own
4407                    // pattern-level `WHERE` -- relationships can't carry
4408                    // more than one type) is trivially always false unless
4409                    // every listed name is the same one type (TCK's Graph5
4410                    // "Node and edge label expressions" [2]).
4411                    Binding::Edge(id) => {
4412                        let edge = deleted_entity_access(GraphStore::get_edge_in_txn(txn, *id)?)?;
4413                        Ok(Value::Literal(Literal::Bool(
4414                            labels.iter().all(|l| edge.label == *l),
4415                        )))
4416                    }
4417                    Binding::Value(PropertyValue::Null) => Ok(Value::Null),
4418                    other => Err(QueryError::Type(format!(
4419                        "'{var}' isn't a node or relationship — (n:Label) needs one, got {other:?}"
4420                    ))),
4421                }
4422            }
4423            ReturnExpr::PatternPredicate(_) => Err(QueryError::Semantic(
4424                "a pattern predicate (`(n)-->()` etc) can only be used inside WHERE".into(),
4425            )),
4426            ReturnExpr::PatternComprehension {
4427                path_var,
4428                pattern,
4429                where_clause,
4430                projection,
4431            } => self.eval_pattern_comprehension(
4432                txn,
4433                PatternComprehensionSpec {
4434                    path_var,
4435                    pattern,
4436                    where_clause,
4437                    projection,
4438                },
4439                row,
4440                guard,
4441            ),
4442            ReturnExpr::ExistsPattern { .. } | ReturnExpr::ExistsSubquery(_) => Err(
4443                QueryError::Semantic("an exists {} subquery can only be used inside WHERE".into()),
4444            ),
4445        }
4446    }
4447
4448    /// `[p = (n)-->() | p]` / `[(n)-[:T]->(b) | b.name]` -- enumerates
4449    /// every match of `pattern` against the graph (already-bound named
4450    /// endpoints in `row` held fixed, exactly like `Expr::Pattern`'s own
4451    /// existential search reuses `build_match_plan`'s "already-bound var
4452    /// -> Seed, not a fresh scan" mechanism) and projects `projection`
4453    /// against each match's own resulting row, collecting into a
4454    /// `Value::List`. No limit on `eval_plan_with_limit` here (unlike
4455    /// `Expr::Pattern`'s `Some(1)`) -- a comprehension needs every match,
4456    /// not just whether one exists.
4457    ///
4458    /// A named path (`path_var: Some`) reuses `execute_match`'s own
4459    /// `name_pattern_for_path`/`assemble_path` pair verbatim -- same
4460    /// "synthesize internal names for any unnamed hop, assemble the path
4461    /// from those, then strip the synthesized keys (and the reserved
4462    /// variable-length-hop segment key, if any) back out" approach a real
4463    /// `MATCH p = ...` clause already uses, including over a single
4464    /// variable-length hop (TCK's Pattern2 `[9]`) -- also reuses
4465    /// `validate_named_path_pattern`'s own restriction on anything wider
4466    /// (a variable-length hop mixed with another hop) for the same reason
4467    /// it already applies to `MATCH`.
4468    fn eval_pattern_comprehension(
4469        &self,
4470        txn: Txn,
4471        spec: PatternComprehensionSpec<'_>,
4472        row: &BindingRow,
4473        guard: &ExecutionGuard<'_>,
4474    ) -> Result<Value, QueryError> {
4475        let PatternComprehensionSpec {
4476            path_var,
4477            pattern,
4478            where_clause,
4479            projection,
4480        } = spec;
4481        if path_var.is_some() {
4482            validate_named_path_pattern(pattern)?;
4483        }
4484        let carried_vars: HashSet<String> = row.keys().cloned().collect();
4485        let (named_pattern, synthesized) = match path_var {
4486            Some(_) => name_pattern_for_path(pattern),
4487            None => (pattern.clone(), HashSet::new()),
4488        };
4489        let wc: Option<Expr> = where_clause.as_deref().cloned();
4490        let plan = apply_index_seeks(build_match_plan(&named_pattern, &wc, &carried_vars)?, txn)?;
4491        let rows = self.eval_plan_with_limit(txn, &plan, std::slice::from_ref(row), guard, None)?;
4492        let mut out = Vec::with_capacity(rows.len());
4493        for mut r in rows {
4494            if let Some(pv) = path_var {
4495                let path_binding = assemble_path(&named_pattern, &r);
4496                for key in &synthesized {
4497                    r.remove(key);
4498                }
4499                r.insert(pv.clone(), path_binding);
4500            }
4501            out.push(self.eval_return_expr(txn, projection, &r, guard)?);
4502        }
4503        Ok(Value::List(out))
4504    }
4505
4506    /// A `WHERE`-position `ReturnExpr` (list comprehension/quantifier
4507    /// filters) evaluated as three-valued logic instead of a plain
4508    /// `Value` -- delegates to `eval_return_expr` then folds the result
4509    /// down via `value_to_bool3`.
4510    fn eval_return_expr_bool3(
4511        &self,
4512        txn: Txn,
4513        expr: &ReturnExpr,
4514        row: &BindingRow,
4515        guard: &ExecutionGuard<'_>,
4516    ) -> Result<Option<bool>, QueryError> {
4517        value_to_bool3(&self.eval_return_expr(txn, expr, row, guard)?)
4518    }
4519
4520    /// Deletes every `targets` expression's value, across every row --
4521    /// shared by `materialize_delete` (`DELETE`/`DETACH DELETE` as a
4522    /// statement tail) and `execute_match`'s own `QueryClause::Delete`
4523    /// (`DELETE ... WITH ...` mid-pattern). Edges are deleted immediately
4524    /// (no ordering constraint), but nodes are only *collected* into
4525    /// `pending_nodes` and deleted in a second pass, after every target
4526    /// across every row has contributed its own edges -- not deleted
4527    /// inline the way `delete_binding`/`delete_value` used to. A single
4528    /// non-`DETACH` `DELETE` naming *several* targets that collectively
4529    /// cover all of a node's edges (e.g. `DELETE pathColls.key[0],
4530    /// pathColls.key[1]`, two paths sharing a node, each contributing one
4531    /// of its two incident edges) must succeed -- deleting inline would
4532    /// try to delete the first path's node while the second path's edge
4533    /// (not yet processed) was still attached, a real bug found via TCK's
4534    /// Delete5 `[7]` once `{key: collect(p)}`-shaped composed expressions
4535    /// could reach this code path at all (previously rejected outright at
4536    /// compile time, before general aggregate composition was supported).
4537    fn delete_targets(
4538        &self,
4539        txn: Txn,
4540        write_txn: &WriteTransaction,
4541        targets: &[ReturnExpr],
4542        rows: &[BindingRow],
4543        detach: bool,
4544        guard: &ExecutionGuard<'_>,
4545    ) -> Result<(), QueryError> {
4546        let mut deleted_edges = HashSet::new();
4547        let mut pending_nodes = HashSet::new();
4548        for row in rows {
4549            for target in targets {
4550                // A bare variable (`DELETE r, a, b`, by far the common
4551                // case) deletes by the raw id already sitting in the row's
4552                // `Binding` -- no existence check, no property fetch.
4553                // That's what lets `DELETE r, a, b` work when two rows of
4554                // the same undirected match both reference the same `a`/
4555                // `b`/`r` (real, from TCK's Delete4 `[1]`): the second
4556                // row's own dedup lookup must succeed even though the
4557                // first row already deleted them. Anything else (`list[0]`,
4558                // `map.key`, a whole path variable's *elements* accessed
4559                // computedly, ...) has no such raw shortcut and goes
4560                // through real evaluation instead -- which correctly does
4561                // still error via `deleted_entity_access` if it tries to
4562                // read a property off something already gone, since that's
4563                // a genuine access, not just a re-statement of identity.
4564                if let ReturnExpr::Var(name) = target {
4565                    let binding = row
4566                        .get(name)
4567                        .ok_or_else(|| QueryError::UnboundVariable(name.clone()))?;
4568                    delete_binding(
4569                        txn,
4570                        binding,
4571                        write_txn,
4572                        &mut deleted_edges,
4573                        &mut pending_nodes,
4574                        guard,
4575                    )?;
4576                } else {
4577                    let value = self.eval_return_expr(txn, target, row, guard)?;
4578                    delete_value(
4579                        &value,
4580                        write_txn,
4581                        &mut deleted_edges,
4582                        &mut pending_nodes,
4583                        guard,
4584                    )?;
4585                }
4586            }
4587        }
4588        for id in pending_nodes {
4589            GraphStore::delete_node_in_txn(write_txn, id, detach)?;
4590        }
4591        Ok(())
4592    }
4593
4594    /// `ret`, when present, is evaluated *after* the physical delete runs,
4595    /// not before — real Cypher's own DELETE+RETURN TCK scenarios agree on
4596    /// this ordering: `MATCH (n) DELETE n RETURN n.num` must raise a
4597    /// `DeletedEntityAccess` error (TCK's Return2 scenarios [15]/[17]), not
4598    /// silently return the pre-delete value. `lookup_prop`/
4599    /// `binding_to_value` (via `deleted_entity_access`) already turn "the
4600    /// bound id's record is gone" into a proper `QueryError` rather than a
4601    /// silent null or a panic, which is exactly what makes deleting first
4602    /// safe here — every other real DELETE+RETURN shape (`count(*)`,
4603    /// `sum(num)` off a WITH-projected scalar, a literal, a null OPTIONAL
4604    /// MATCH binding) never touches the just-deleted entity's live record
4605    /// at all, so this ordering changes nothing for them.
4606    fn materialize_delete(
4607        &self,
4608        txn: Txn,
4609        targets: &[ReturnExpr],
4610        rows: &[BindingRow],
4611        detach: bool,
4612        ret: &Option<ReturnTail>,
4613        guard: &ExecutionGuard<'_>,
4614    ) -> Result<QueryResult, QueryError> {
4615        let write_txn = require_write_txn(txn);
4616        self.delete_targets(txn, write_txn, targets, rows, detach, guard)?;
4617        let result = match ret {
4618            Some(rt) => self.materialize_return(txn, &rt.items, rows, rt.distinct, guard)?,
4619            None => QueryResult {
4620                columns: vec![],
4621                rows: vec![],
4622            },
4623        };
4624        Ok(result)
4625    }
4626
4627    fn materialize_set(
4628        &self,
4629        txn: Txn,
4630        items: &[SetItem],
4631        rows: &[BindingRow],
4632        ret: &Option<ReturnTail>,
4633        guard: &ExecutionGuard<'_>,
4634    ) -> Result<QueryResult, QueryError> {
4635        let write_txn = require_write_txn(txn);
4636        for row in rows {
4637            for item in items {
4638                self.apply_set_item(txn, write_txn, row, item, guard)?;
4639            }
4640        }
4641        match ret {
4642            Some(rt) => self.materialize_return(txn, &rt.items, rows, rt.distinct, guard),
4643            None => Ok(QueryResult {
4644                columns: vec![],
4645                rows: vec![],
4646            }),
4647        }
4648    }
4649
4650    fn materialize_remove(
4651        &self,
4652        txn: Txn,
4653        items: &[RemoveItem],
4654        rows: &[BindingRow],
4655        ret: &Option<ReturnTail>,
4656        guard: &ExecutionGuard<'_>,
4657    ) -> Result<QueryResult, QueryError> {
4658        let write_txn = require_write_txn(txn);
4659        for row in rows {
4660            for item in items {
4661                apply_remove_item(write_txn, row, item)?;
4662            }
4663        }
4664        match ret {
4665            Some(rt) => self.materialize_return(txn, &rt.items, rows, rt.distinct, guard),
4666            None => Ok(QueryResult {
4667                columns: vec![],
4668                rows: vec![],
4669            }),
4670        }
4671    }
4672
4673    /// `<match_stmt> UNION [ALL] <match_stmt> ...` — every part shares the
4674    /// same `txn` (one snapshot for a read-only union, one write
4675    /// transaction otherwise — see `is_read_only`'s own `Union` handling)
4676    /// but no bindings: each part is `execute_match`'d completely
4677    /// independently, matching real Cypher's own scoping. Column names
4678    /// must match exactly across every part (real Cypher's
4679    /// `DifferentColumnsInUnion` — checked here, once each part's real
4680    /// `QueryResult.columns` is in hand, rather than statically, since
4681    /// nothing else in this codebase infers a `RETURN` list's column
4682    /// names without evaluating it). `all: false` (plain `UNION`) dedups
4683    /// the combined rows via the same `dedup_rows` `RETURN DISTINCT`
4684    /// already uses; `all: true` keeps every row.
4685    fn materialize_union(
4686        &self,
4687        txn: Txn,
4688        parts: &[Statement],
4689        all: bool,
4690        guard: &ExecutionGuard<'_>,
4691    ) -> Result<QueryResult, QueryError> {
4692        let mut combined: Option<QueryResult> = None;
4693        for part in parts {
4694            let Statement::Match {
4695                clauses,
4696                tail,
4697                order_by,
4698                skip,
4699                limit,
4700            } = part
4701            else {
4702                unreachable!(
4703                    "union_stmt parts are always Statement::Match -- see parser::parse_union_stmt"
4704                )
4705            };
4706            let skip = self.resolve_skip_limit(txn, skip.as_deref(), "SKIP", guard)?;
4707            let limit = self.resolve_skip_limit(txn, limit.as_deref(), "LIMIT", guard)?;
4708            let result = self.execute_match(
4709                txn,
4710                clauses,
4711                tail,
4712                ResultModifiers {
4713                    order_by,
4714                    skip,
4715                    limit,
4716                },
4717                guard,
4718            )?;
4719            combined = Some(match combined {
4720                None => result,
4721                Some(mut acc) => {
4722                    if acc.columns != result.columns {
4723                        return Err(QueryError::Semantic(format!(
4724                            "UNION requires every part to return the same columns -- got {:?} \
4725                             and {:?}",
4726                            acc.columns, result.columns
4727                        )));
4728                    }
4729                    acc.rows.extend(result.rows);
4730                    acc
4731                }
4732            });
4733            guard.check_intermediate_rows(combined.as_ref().map(|r| r.rows.len()).unwrap_or(0))?;
4734        }
4735        let mut result = combined.expect("union_stmt grammar guarantees at least 2 parts");
4736        if !all {
4737            result.rows = dedup_rows(result.rows)?;
4738        }
4739        Ok(result)
4740    }
4741
4742    fn apply_set_item(
4743        &self,
4744        txn: Txn,
4745        write_txn: &WriteTransaction,
4746        row: &BindingRow,
4747        item: &SetItem,
4748        guard: &ExecutionGuard<'_>,
4749    ) -> Result<(), QueryError> {
4750        match item {
4751            SetItem::Prop(pa, expr) => {
4752                let binding = row
4753                    .get(&pa.var)
4754                    .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
4755                // `SET` on a null binding is a documented no-op, same as
4756                // `DELETE`/`REMOVE` on one -- an `OPTIONAL MATCH` that found
4757                // nothing pads its variables with null (found via TCK's
4758                // Set1/Set3 "Ignore null when setting property/label"
4759                // scenarios).
4760                if matches!(binding, Binding::Value(PropertyValue::Null)) {
4761                    return Ok(());
4762                }
4763                let node_id = if let Binding::Node(id) = binding {
4764                    Some(*id)
4765                } else {
4766                    None
4767                };
4768                let edge_id = if let Binding::Edge(id) = binding {
4769                    Some(*id)
4770                } else {
4771                    None
4772                };
4773                if node_id.is_none() && edge_id.is_none() {
4774                    return Err(QueryError::UnboundVariable(format!(
4775                    "'{}' is a WITH-projected scalar, not a node/edge — SET needs a graph binding",
4776                    pa.var
4777                )));
4778                }
4779                let value = self.eval_return_expr(txn, expr, row, guard)?;
4780                // `SET n.prop = null` *removes* the property in real Cypher
4781                // (found via TCK's Set2 "Set a Property to Null" scenarios,
4782                // which this codebase previously couldn't parse at all --
4783                // `SET` had no trailing RETURN to observe the result with, so
4784                // this bug was never exercised until that gap closed).
4785                // Storing a literal `PropertyValue::Null` instead is
4786                // observably different: `n.prop` still shows up as a
4787                // (nulled-out) key when a caller enumerates a node's own
4788                // props (e.g. this RETURN's own node-to-string rendering),
4789                // where a real missing property wouldn't. The RHS being
4790                // `null` is now a *runtime* fact (it's any `ReturnExpr`, not
4791                // just the `Literal::Null` token), not something checkable
4792                // from the AST alone -- `SET n.prop = coalesce(x, null)`
4793                // must remove the property too if `x` turns out null.
4794                if matches!(value, Value::Null) {
4795                    if let Some(id) = node_id {
4796                        GraphStore::remove_node_prop_in_txn(write_txn, id, &pa.prop)?;
4797                    }
4798                    if let Some(id) = edge_id {
4799                        GraphStore::remove_edge_prop_in_txn(write_txn, id, &pa.prop)?;
4800                    }
4801                } else {
4802                    let pv = value_to_storable_property(&value).ok_or_else(|| {
4803                    QueryError::Type(format!(
4804                        "property '{}' can't be stored -- MarsDB's node/edge properties are limited \
4805                         to null/bool/int/float/string/date/duration; a list/map/node/edge/path value \
4806                         (got {value:?}) isn't storable",
4807                        pa.prop
4808                    ))
4809                })?;
4810                    if let Some(id) = node_id {
4811                        GraphStore::set_node_prop_in_txn(write_txn, id, &pa.prop, pv.clone())?;
4812                    }
4813                    if let Some(id) = edge_id {
4814                        GraphStore::set_edge_prop_in_txn(write_txn, id, &pa.prop, pv)?;
4815                    }
4816                }
4817            }
4818            SetItem::Labels(var, labels) => {
4819                let binding = row
4820                    .get(var)
4821                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
4822                match binding {
4823                    Binding::Node(id) => {
4824                        for label in labels {
4825                            GraphStore::add_node_label_in_txn(write_txn, *id, label)?;
4826                        }
4827                    }
4828                    // Same null-is-a-no-op rule as the property arm above.
4829                    Binding::Value(PropertyValue::Null) => {}
4830                    _ => {
4831                        return Err(QueryError::UnboundVariable(format!(
4832                            "'{var}' isn't a node — SET can only add labels to a node"
4833                        )))
4834                    }
4835                }
4836            }
4837            SetItem::MapAssign { var, value, merge } => {
4838                let binding = row
4839                    .get(var)
4840                    .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
4841                // Same null-is-a-no-op rule as the property arm above.
4842                if matches!(binding, Binding::Value(PropertyValue::Null)) {
4843                    return Ok(());
4844                }
4845                let node_id = if let Binding::Node(id) = binding {
4846                    Some(*id)
4847                } else {
4848                    None
4849                };
4850                let edge_id = if let Binding::Edge(id) = binding {
4851                    Some(*id)
4852                } else {
4853                    None
4854                };
4855                if node_id.is_none() && edge_id.is_none() {
4856                    return Err(QueryError::UnboundVariable(format!(
4857                        "'{var}' is a WITH-projected scalar, not a node/edge — SET needs a graph binding"
4858                    )));
4859                }
4860                let map_value = self.eval_return_expr(txn, value, row, guard)?;
4861                // A map literal is the common case, but real Cypher also
4862                // allows `SET r = a`/`SET r += a` where `a` is itself a
4863                // bound node/relationship -- copies its properties, same
4864                // as a map built from them would (TCK's Merge6 [6]/
4865                // Merge7 [4], "Copying properties from node").
4866                let entries = match map_value {
4867                    Value::Map(entries) => entries,
4868                    Value::Node(n) => n
4869                        .props
4870                        .into_iter()
4871                        .map(|(k, v)| (k, property_value_to_value(v)))
4872                        .collect(),
4873                    Value::Edge(e) => e
4874                        .props
4875                        .into_iter()
4876                        .map(|(k, v)| (k, property_value_to_value(v)))
4877                        .collect(),
4878                    other => {
4879                        return Err(QueryError::Type(format!(
4880                            "SET {var} = ...{} needs a map, node, or relationship, got {other:?}",
4881                            if *merge { " (+=)" } else { "" }
4882                        )))
4883                    }
4884                };
4885                // `SET n = {...}` (`merge: false`) replaces every existing
4886                // property -- delete whatever's already there first, not
4887                // just overwrite the map's own keys, or a key n already
4888                // had that the map doesn't mention would wrongly survive
4889                // (TCK's Set4 [2]/[3]/[4]).
4890                if !merge {
4891                    let existing_keys: Vec<String> = if let Some(id) = node_id {
4892                        deleted_entity_access(GraphStore::get_node_in_txn(txn, id)?)?
4893                            .props
4894                            .into_keys()
4895                            .collect()
4896                    } else {
4897                        deleted_entity_access(GraphStore::get_edge_in_txn(
4898                            txn,
4899                            edge_id.expect("node_id or edge_id is Some, checked above"),
4900                        )?)?
4901                        .props
4902                        .into_keys()
4903                        .collect()
4904                    };
4905                    for key in existing_keys {
4906                        if let Some(id) = node_id {
4907                            GraphStore::remove_node_prop_in_txn(write_txn, id, &key)?;
4908                        }
4909                        if let Some(id) = edge_id {
4910                            GraphStore::remove_edge_prop_in_txn(write_txn, id, &key)?;
4911                        }
4912                    }
4913                }
4914                // Either way, apply the map's own entries -- a `null`
4915                // value removes that one key (real Cypher's rule, same
4916                // "null means remove" convention `SetItem::Prop` already
4917                // has -- TCK's Set5 [4]), anything else sets it.
4918                for (key, entry_value) in entries {
4919                    if matches!(entry_value, Value::Null) {
4920                        if let Some(id) = node_id {
4921                            GraphStore::remove_node_prop_in_txn(write_txn, id, &key)?;
4922                        }
4923                        if let Some(id) = edge_id {
4924                            GraphStore::remove_edge_prop_in_txn(write_txn, id, &key)?;
4925                        }
4926                        continue;
4927                    }
4928                    let pv = value_to_storable_property(&entry_value).ok_or_else(|| {
4929                        QueryError::Type(format!(
4930                            "property '{key}' can't be stored -- MarsDB's node/edge properties are \
4931                             limited to null/bool/int/float/string/date/duration/list; a map/node/\
4932                             edge/path value (got {entry_value:?}) isn't storable"
4933                        ))
4934                    })?;
4935                    if let Some(id) = node_id {
4936                        GraphStore::set_node_prop_in_txn(write_txn, id, &key, pv.clone())?;
4937                    }
4938                    if let Some(id) = edge_id {
4939                        GraphStore::set_edge_prop_in_txn(write_txn, id, &key, pv)?;
4940                    }
4941                }
4942            }
4943        }
4944        Ok(())
4945    }
4946}
4947
4948/// `materialize_delete`'s bare-variable fast path -- deletes straight off
4949/// the row's raw `Binding` (just an id), no existence check and no
4950/// property fetch, so re-referencing an already-deleted-this-statement
4951/// entity by identity (a later row of the same multi-row `DELETE`) is a
4952/// silent dedup no-op, not an error. Mirrors `delete_value`'s shape
4953/// (including the path/null/type-error handling) but over `Binding`/
4954/// `PathBinding` (raw ids) instead of `Value`/`PathElem` (fully
4955/// materialized records).
4956/// Deletes edge `id`, first stashing its (immutable, so safe to cache)
4957/// type into `guard` -- see `ExecutionGuard::deleted_edge_types`'s own
4958/// docs for why. The lookup can't fail with a real error here: `id` was
4959/// just read out of a live `Binding::Edge`/`PathBinding::Edge` this same
4960/// transaction, so its record is still there to fetch (deletion hasn't
4961/// happened yet -- that's the very next line).
4962fn record_and_delete_edge(
4963    txn: Txn,
4964    write_txn: &WriteTransaction,
4965    id: EdgeId,
4966    guard: &ExecutionGuard<'_>,
4967) -> Result<(), QueryError> {
4968    if let Some(edge) = GraphStore::get_edge_in_txn(txn, id)? {
4969        guard.record_deleted_edge_type(id, edge.label);
4970    }
4971    GraphStore::delete_edge_in_txn(write_txn, id)?;
4972    Ok(())
4973}
4974
4975fn delete_binding(
4976    txn: Txn,
4977    binding: &Binding,
4978    write_txn: &WriteTransaction,
4979    deleted_edges: &mut HashSet<EdgeId>,
4980    pending_nodes: &mut HashSet<NodeId>,
4981    guard: &ExecutionGuard<'_>,
4982) -> Result<(), QueryError> {
4983    match binding {
4984        Binding::Node(id) => {
4985            pending_nodes.insert(*id);
4986        }
4987        Binding::Edge(id) => {
4988            if deleted_edges.insert(*id) {
4989                record_and_delete_edge(txn, write_txn, *id, guard)?;
4990            }
4991        }
4992        Binding::Path(elems) => {
4993            for elem in elems {
4994                if let PathBinding::Edge(id) = elem {
4995                    if deleted_edges.insert(*id) {
4996                        record_and_delete_edge(txn, write_txn, *id, guard)?;
4997                    }
4998                }
4999            }
5000            for elem in elems {
5001                if let PathBinding::Node(id) = elem {
5002                    pending_nodes.insert(*id);
5003                }
5004            }
5005        }
5006        // A null binding is a real, legal DELETE target -- an `OPTIONAL
5007        // MATCH` that didn't match pads its variables with null, and
5008        // deleting that is a documented no-op, not an error.
5009        Binding::Value(PropertyValue::Null) => {}
5010        Binding::Value(_) | Binding::List(_) | Binding::Map(_) => {
5011            return Err(QueryError::Type(
5012                "DELETE needs a node, relationship, or path, not a scalar/list/map".into(),
5013            ))
5014        }
5015    }
5016    Ok(())
5017}
5018
5019/// Deletes whatever `value` evaluated to -- a node, a relationship, every
5020/// node/edge in a path, or nothing at all for `null` (a documented no-op:
5021/// an `OPTIONAL MATCH` that didn't match pads its variables with null, and
5022/// deleting that is specified as silent, not an error). Anything else (a
5023/// list, a map, a bare scalar, ...) is a real `QueryError::Type` --
5024/// `DELETE`'s target must resolve to a graph element, unlike `SET`'s RHS.
5025/// Edges are deleted immediately; nodes are only collected into
5026/// `pending_nodes` -- `delete_targets` (the only caller) deletes them in
5027/// its own second pass, after every target across every row has had a
5028/// chance to delete its own edges first (see its own docs for why).
5029fn delete_value(
5030    value: &Value,
5031    write_txn: &WriteTransaction,
5032    deleted_edges: &mut HashSet<EdgeId>,
5033    pending_nodes: &mut HashSet<NodeId>,
5034    guard: &ExecutionGuard<'_>,
5035) -> Result<(), QueryError> {
5036    match value {
5037        Value::Node(n) => {
5038            pending_nodes.insert(n.id);
5039        }
5040        Value::Edge(e) => {
5041            if deleted_edges.insert(e.id) {
5042                guard.record_deleted_edge_type(e.id, e.label.clone());
5043                GraphStore::delete_edge_in_txn(write_txn, e.id)?;
5044            }
5045        }
5046        Value::Path(elems) => {
5047            for elem in elems {
5048                if let PathElem::Edge(e) = elem {
5049                    if deleted_edges.insert(e.id) {
5050                        guard.record_deleted_edge_type(e.id, e.label.clone());
5051                        GraphStore::delete_edge_in_txn(write_txn, e.id)?;
5052                    }
5053                }
5054            }
5055            for elem in elems {
5056                if let PathElem::Node(n) = elem {
5057                    pending_nodes.insert(n.id);
5058                }
5059            }
5060        }
5061        Value::Null => {}
5062        other => {
5063            return Err(QueryError::Type(format!(
5064                "DELETE needs a node, relationship, or path, got {other:?}"
5065            )))
5066        }
5067    }
5068    Ok(())
5069}
5070
5071fn apply_remove_item(
5072    write_txn: &WriteTransaction,
5073    row: &BindingRow,
5074    item: &RemoveItem,
5075) -> Result<(), QueryError> {
5076    match item {
5077        RemoveItem::Prop(pa) => {
5078            let binding = row
5079                .get(&pa.var)
5080                .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
5081            match binding {
5082                Binding::Node(id) => {
5083                    GraphStore::remove_node_prop_in_txn(write_txn, *id, &pa.prop)?;
5084                }
5085                Binding::Edge(id) => {
5086                    GraphStore::remove_edge_prop_in_txn(write_txn, *id, &pa.prop)?;
5087                }
5088                // Same null-is-a-no-op rule DELETE already follows (found
5089                // via TCK's Remove1 "Ignore null when removing property"
5090                // scenarios).
5091                Binding::Value(PropertyValue::Null) => {}
5092                Binding::Value(_) | Binding::List(_) | Binding::Map(_) | Binding::Path(_) => {
5093                    return Err(QueryError::UnboundVariable(format!(
5094                        "'{}' is a WITH-projected scalar, not a node/edge — REMOVE needs a graph binding",
5095                        pa.var
5096                    )))
5097                }
5098            }
5099        }
5100        RemoveItem::Labels(var, labels) => {
5101            let binding = row
5102                .get(var)
5103                .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
5104            match binding {
5105                Binding::Node(id) => {
5106                    for label in labels {
5107                        GraphStore::remove_node_label_in_txn(write_txn, *id, label)?;
5108                    }
5109                }
5110                // Same null-is-a-no-op rule as the property arm above
5111                // (found via TCK's Remove2 "Ignore null when removing a
5112                // node label" scenario).
5113                Binding::Value(PropertyValue::Null) => {}
5114                _ => {
5115                    return Err(QueryError::UnboundVariable(format!(
5116                        "'{var}' isn't a node — REMOVE can only remove labels from a node"
5117                    )))
5118                }
5119            }
5120        }
5121    }
5122    Ok(())
5123}
5124
5125/// Whether `tail`'s ultimate RETURN (if it has one at all -- either
5126/// `Tail::Return` itself, or a mutating tail's trailing `ReturnTail`) is a
5127/// `RETURN DISTINCT`. Used by `execute_match`'s LIMIT pre-truncate and
5128/// scan-limit-pushdown shortcuts, both of which must NOT fire for a
5129/// DISTINCT return -- dedup can drop rows, so capping the raw input at
5130/// `limit` before it runs could return fewer than `limit` distinct rows
5131/// even when more exist.
5132fn tail_is_distinct_return(tail: &Option<Tail>) -> bool {
5133    match tail {
5134        Some(Tail::Return(_, distinct)) | Some(Tail::ReturnStar(distinct)) => *distinct,
5135        Some(Tail::Delete(_, ret))
5136        | Some(Tail::DetachDelete(_, ret))
5137        | Some(Tail::Set(_, ret))
5138        | Some(Tail::Remove(_, ret))
5139        | Some(Tail::Create(_, ret)) => ret.as_ref().is_some_and(|rt| rt.distinct),
5140        None => false,
5141    }
5142}
5143
5144/// A statement never mutates anything iff it's a `MATCH ... RETURN` with no
5145/// `DELETE`/`DETACH DELETE`/`SET` tail *and* no `MERGE` clause anywhere in
5146/// it (`MERGE (n) RETURN n` has a `Tail::Return`, but still writes whenever
5147/// it has to create — checking `tail` alone here would be a real bug, not
5148/// just an incomplete check: it would send a MERGE-that-creates through a
5149/// `ReadTransaction`, which has no `.insert`). `Statement::Create` and
5150/// every other `Tail` variant always write. Confirmed by tracing every
5151/// function reachable from pattern/WHERE/WITH evaluation: none of them
5152/// ever call a table-mutating `*_in_txn` method for a `Tail::Return`
5153/// statement with no `MERGE` clause (a label-filtered scan looks up an
5154/// existing label id, it never allocates one — allocation only happens in
5155/// `create_node_in_txn`/`create_edge_in_txn`). `Executor::execute` uses
5156/// this to decide whether to open a `ReadTransaction` (no contention with
5157/// concurrent readers or a concurrent writer) or a `WriteTransaction`.
5158/// Returns whether executing `stmt` can mutate the graph. Public so callers
5159/// which execute generated or otherwise untrusted Cypher can enforce a
5160/// read-only policy using the same classification as the executor.
5161pub fn is_read_only(stmt: &Statement) -> bool {
5162    if let Statement::Union { parts, .. } = stmt {
5163        return parts.iter().all(is_read_only);
5164    }
5165    let Statement::Match {
5166        tail: Some(Tail::Return(_, _)) | Some(Tail::ReturnStar(_)),
5167        clauses,
5168        ..
5169    } = stmt
5170    else {
5171        return false;
5172    };
5173    !clauses.iter().any(|c| {
5174        matches!(
5175            c,
5176            QueryClause::Merge(_)
5177                | QueryClause::Set(_)
5178                | QueryClause::Delete { .. }
5179                | QueryClause::Remove(_)
5180                | QueryClause::Create(_)
5181                // A procedure is opaque to MarsDB -- it might write, so
5182                // any statement calling one is conservatively treated as
5183                // non-read-only too, same reasoning `Statement::
5184                // StandaloneCall` already gets for free (it isn't a
5185                // `Statement::Match` at all, so it never matches this
5186                // function's own read-only pattern above).
5187                | QueryClause::Call(_)
5188        )
5189    })
5190}
5191
5192/// Recovers the real `&WriteTransaction` from a `Txn` for `execute_match`
5193/// tail/clause arms (`DELETE`/`SET`, both the terminal-tail and
5194/// `QueryClause::Set`'s own mid-statement form) that need `.insert`/
5195/// `.remove`, not just `Txn`'s read-only `get`/`iter`. Panics if given
5196/// `Txn::Read` — which can't happen: any of these make `is_read_only`
5197/// return `false`, so `Executor::execute` always opens a
5198/// `WriteTransaction` (and thus `Txn::Write`) before reaching this path.
5199fn require_write_txn(txn: Txn<'_>) -> &WriteTransaction {
5200    let Txn::Write(write_txn) = txn else {
5201        unreachable!(
5202            "materialize_delete/materialize_set/QueryClause::Set only reached via the \
5203             write-dispatch path in Executor::execute — is_read_only(stmt) is false for any \
5204             statement with one of these, so execute always opens a WriteTransaction for them"
5205        )
5206    };
5207    write_txn
5208}
5209
5210fn default_column_name(expr: &ReturnExpr, idx: usize) -> String {
5211    match expr {
5212        ReturnExpr::Var(v) => v.clone(),
5213        ReturnExpr::Prop(pa) => format!("{}.{}", pa.var, pa.prop),
5214        ReturnExpr::Lit(_) => format!("col{idx}"),
5215        ReturnExpr::Call { name, .. } => format!("{name}(...)"),
5216        ReturnExpr::CountStar => "count(*)".to_string(),
5217        ReturnExpr::Case { .. } => format!("case{idx}"),
5218        ReturnExpr::Arith(..) | ReturnExpr::Neg(..) => format!("col{idx}"),
5219        ReturnExpr::ListLit(..)
5220        | ReturnExpr::Index(..)
5221        | ReturnExpr::PropOf(..)
5222        | ReturnExpr::Slice(..)
5223        | ReturnExpr::ListComp { .. }
5224        | ReturnExpr::Quantifier { .. }
5225        | ReturnExpr::MapLit(..)
5226        | ReturnExpr::And(..)
5227        | ReturnExpr::Or(..)
5228        | ReturnExpr::Xor(..)
5229        | ReturnExpr::Not(..)
5230        | ReturnExpr::Compare(..)
5231        | ReturnExpr::IsNull(..)
5232        | ReturnExpr::In(..)
5233        | ReturnExpr::HasLabel(..)
5234        | ReturnExpr::PatternPredicate(..)
5235        | ReturnExpr::PatternComprehension { .. }
5236        | ReturnExpr::ExistsPattern { .. }
5237        | ReturnExpr::ExistsSubquery(_) => format!("col{idx}"),
5238    }
5239}
5240
5241/// The name a `WITH`/`RETURN` item is known by afterward — its alias, or
5242/// a name derived from the expression (its bare var name, `col{i}`, etc).
5243/// `pub(crate)` so `explain.rs` can compute the same post-`WITH`
5244/// `carried_vars` set EXPLAIN needs without executing any rows.
5245pub(crate) fn with_item_output_name((i, item): (usize, &ReturnItem)) -> String {
5246    item.alias
5247        .clone()
5248        .unwrap_or_else(|| default_column_name(&item.expr, i))
5249}
5250
5251/// True iff `expr` contains an aggregate call anywhere inside it, at any
5252/// depth — used to reject an aggregate nested inside another aggregate's
5253/// argument, or inside a non-aggregate expression's `CASE`/`Call`
5254/// arguments (an aggregate must be a return item's *entire* top-level
5255/// expression — see `validate_return_items`).
5256/// Collects every aggregate-bearing subexpression in `expr` (a `CountStar`
5257/// or an aggregate-named `Call`), in a fixed pre-order -- the same
5258/// traversal `contains_aggregate` uses, just gathering references instead
5259/// of stopping at the first `true`. Doesn't recurse *into* a found node's
5260/// own arguments (an aggregate's argument is folded per-row as a whole,
5261/// not decomposed further -- see `resolve_grouped_rows`). The resulting
5262/// order is what makes a composed item's per-row folding
5263/// (`resolve_grouped_rows`) and its per-group finishing
5264/// (`Executor::rewrite_composed_item`) agree on which accumulator is
5265/// which, without needing to name or otherwise identify individual
5266/// aggregate calls within one item's expression tree.
5267fn collect_agg_nodes<'a>(expr: &'a ReturnExpr, out: &mut Vec<&'a ReturnExpr>) {
5268    match expr {
5269        ReturnExpr::CountStar => out.push(expr),
5270        ReturnExpr::Call { name, args, .. } => {
5271            if is_aggregate_name(name) {
5272                out.push(expr);
5273            } else {
5274                for arg in args {
5275                    collect_agg_nodes(arg, out);
5276                }
5277            }
5278        }
5279        ReturnExpr::Case { test, whens, else_ } => {
5280            if let Some(t) = test.as_deref() {
5281                collect_agg_nodes(t, out);
5282            }
5283            for (w, t) in whens {
5284                collect_agg_nodes(w, out);
5285                collect_agg_nodes(t, out);
5286            }
5287            if let Some(e) = else_.as_deref() {
5288                collect_agg_nodes(e, out);
5289            }
5290        }
5291        ReturnExpr::Arith(l, _, r) => {
5292            collect_agg_nodes(l, out);
5293            collect_agg_nodes(r, out);
5294        }
5295        ReturnExpr::Neg(e) => collect_agg_nodes(e, out),
5296        ReturnExpr::ListLit(items) => {
5297            for item in items {
5298                collect_agg_nodes(item, out);
5299            }
5300        }
5301        ReturnExpr::Index(base, index) => {
5302            collect_agg_nodes(base, out);
5303            collect_agg_nodes(index, out);
5304        }
5305        ReturnExpr::PropOf(base, _) => collect_agg_nodes(base, out),
5306        ReturnExpr::Slice(base, start, end) => {
5307            collect_agg_nodes(base, out);
5308            if let Some(s) = start.as_deref() {
5309                collect_agg_nodes(s, out);
5310            }
5311            if let Some(e) = end.as_deref() {
5312                collect_agg_nodes(e, out);
5313            }
5314        }
5315        // Same `where_clause`-not-checked scope limitation as
5316        // `contains_aggregate`'s matching arm.
5317        ReturnExpr::ListComp {
5318            source, project, ..
5319        } => {
5320            collect_agg_nodes(source, out);
5321            if let Some(p) = project.as_deref() {
5322                collect_agg_nodes(p, out);
5323            }
5324        }
5325        ReturnExpr::Quantifier { source, .. } => collect_agg_nodes(source, out),
5326        ReturnExpr::MapLit(entries) => {
5327            for (_, v) in entries {
5328                collect_agg_nodes(v, out);
5329            }
5330        }
5331        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
5332            collect_agg_nodes(l, out);
5333            collect_agg_nodes(r, out);
5334        }
5335        ReturnExpr::Not(e) => collect_agg_nodes(e, out),
5336        ReturnExpr::Compare(l, _, r) => {
5337            collect_agg_nodes(l, out);
5338            collect_agg_nodes(r, out);
5339        }
5340        ReturnExpr::IsNull(e) => collect_agg_nodes(e, out),
5341        ReturnExpr::In(needle, haystack) => {
5342            collect_agg_nodes(needle, out);
5343            collect_agg_nodes(haystack, out);
5344        }
5345        ReturnExpr::Var(_)
5346        | ReturnExpr::Prop(_)
5347        | ReturnExpr::Lit(_)
5348        | ReturnExpr::HasLabel(..)
5349        | ReturnExpr::PatternPredicate(..)
5350        | ReturnExpr::PatternComprehension { .. }
5351        | ReturnExpr::ExistsPattern { .. }
5352        | ReturnExpr::ExistsSubquery(_) => {}
5353    }
5354}
5355
5356pub(crate) fn contains_aggregate(expr: &ReturnExpr) -> bool {
5357    match expr {
5358        ReturnExpr::CountStar => true,
5359        ReturnExpr::Call { name, args, .. } => {
5360            is_aggregate_name(name) || args.iter().any(contains_aggregate)
5361        }
5362        ReturnExpr::Case { test, whens, else_ } => {
5363            test.as_deref().is_some_and(contains_aggregate)
5364                || whens
5365                    .iter()
5366                    .any(|(w, t)| contains_aggregate(w) || contains_aggregate(t))
5367                || else_.as_deref().is_some_and(contains_aggregate)
5368        }
5369        ReturnExpr::Arith(l, _, r) => contains_aggregate(l) || contains_aggregate(r),
5370        ReturnExpr::Neg(e) => contains_aggregate(e),
5371        ReturnExpr::ListLit(items) => items.iter().any(contains_aggregate),
5372        ReturnExpr::Index(base, index) => contains_aggregate(base) || contains_aggregate(index),
5373        ReturnExpr::PropOf(base, _) => contains_aggregate(base),
5374        ReturnExpr::Slice(base, start, end) => {
5375            contains_aggregate(base)
5376                || start.as_deref().is_some_and(contains_aggregate)
5377                || end.as_deref().is_some_and(contains_aggregate)
5378        }
5379        // `where_clause` isn't checked -- same scope limitation as
5380        // `UnwindClause`'s own filter, which never routes through this
5381        // check either; the source/project halves are the ones a real
5382        // TCK scenario nests an aggregate in (`size([x IN collect(r) ...])`).
5383        ReturnExpr::ListComp {
5384            source, project, ..
5385        } => contains_aggregate(source) || project.as_deref().is_some_and(contains_aggregate),
5386        ReturnExpr::Quantifier { source, .. } => contains_aggregate(source),
5387        ReturnExpr::MapLit(entries) => entries.iter().any(|(_, v)| contains_aggregate(v)),
5388        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
5389            contains_aggregate(l) || contains_aggregate(r)
5390        }
5391        ReturnExpr::Not(e) => contains_aggregate(e),
5392        ReturnExpr::Compare(l, _, r) => contains_aggregate(l) || contains_aggregate(r),
5393        ReturnExpr::IsNull(e) => contains_aggregate(e),
5394        ReturnExpr::In(needle, haystack) => {
5395            contains_aggregate(needle) || contains_aggregate(haystack)
5396        }
5397        ReturnExpr::Var(_)
5398        | ReturnExpr::Prop(_)
5399        | ReturnExpr::Lit(_)
5400        | ReturnExpr::HasLabel(..)
5401        | ReturnExpr::PatternPredicate(..)
5402        // A pattern comprehension's projection runs against its own
5403        // per-match row, not the outer query's group -- an aggregate
5404        // inside it wouldn't mean "aggregate across the outer group,"
5405        // it'd need its own separate grouping concept this codebase
5406        // doesn't have, so (like `PatternPredicate`) it's opaque here
5407        // rather than searched into.
5408        | ReturnExpr::PatternComprehension { .. }
5409        | ReturnExpr::ExistsPattern { .. }
5410        | ReturnExpr::ExistsSubquery(_) => false,
5411    }
5412}
5413
5414/// True iff any item's top-level expression is an aggregate call —
5415/// `materialize_with`/`materialize_return` dispatch to the grouping path
5416/// iff this is true, otherwise the existing row-at-a-time path runs
5417/// completely unchanged (zero perf/behavior impact on non-aggregating
5418/// queries).
5419pub(crate) fn has_aggregate(items: &[ReturnItem]) -> bool {
5420    // `contains_aggregate`, not a narrower "is the item's whole top-level
5421    // expression itself an aggregate call" check -- an aggregate nested
5422    // inside a wrapping expression (`1 + count(x)`, real Cypher composition
5423    // -- see `resolve_grouped_rows`) still needs to route to the grouping
5424    // path, both to actually compute it and so `validate_return_items` gets
5425    // a chance to reject an invalid composition with a clear error. A
5426    // narrower top-level-only check here would let such a query silently
5427    // take the ordinary per-row path instead (iterating `rows` directly,
5428    // which is empty for an empty MATCH), producing the wrong row count
5429    // instead of the right (or correctly rejected) one.
5430    items.iter().any(|item| contains_aggregate(&item.expr))
5431}
5432
5433/// True iff `expr` contains a call to `rand()` anywhere inside it, at any
5434/// depth -- same traversal shape as `contains_aggregate`, used only to
5435/// reject `rand()` as (part of) an aggregate's own argument (see
5436/// `validate_return_items`); `rand()` elsewhere in a query is completely
5437/// fine.
5438fn contains_rand_call(expr: &ReturnExpr) -> bool {
5439    match expr {
5440        ReturnExpr::Call { name, args, .. } => {
5441            name.eq_ignore_ascii_case("rand") || args.iter().any(contains_rand_call)
5442        }
5443        ReturnExpr::Case { test, whens, else_ } => {
5444            test.as_deref().is_some_and(contains_rand_call)
5445                || whens
5446                    .iter()
5447                    .any(|(w, t)| contains_rand_call(w) || contains_rand_call(t))
5448                || else_.as_deref().is_some_and(contains_rand_call)
5449        }
5450        ReturnExpr::Arith(l, _, r) => contains_rand_call(l) || contains_rand_call(r),
5451        ReturnExpr::Neg(e) => contains_rand_call(e),
5452        ReturnExpr::ListLit(items) => items.iter().any(contains_rand_call),
5453        ReturnExpr::Index(base, index) => contains_rand_call(base) || contains_rand_call(index),
5454        ReturnExpr::PropOf(base, _) => contains_rand_call(base),
5455        ReturnExpr::Slice(base, start, end) => {
5456            contains_rand_call(base)
5457                || start.as_deref().is_some_and(contains_rand_call)
5458                || end.as_deref().is_some_and(contains_rand_call)
5459        }
5460        ReturnExpr::ListComp {
5461            source, project, ..
5462        } => contains_rand_call(source) || project.as_deref().is_some_and(contains_rand_call),
5463        ReturnExpr::Quantifier { source, .. } => contains_rand_call(source),
5464        ReturnExpr::MapLit(entries) => entries.iter().any(|(_, v)| contains_rand_call(v)),
5465        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
5466            contains_rand_call(l) || contains_rand_call(r)
5467        }
5468        ReturnExpr::Not(e) => contains_rand_call(e),
5469        ReturnExpr::Compare(l, _, r) => contains_rand_call(l) || contains_rand_call(r),
5470        ReturnExpr::IsNull(e) => contains_rand_call(e),
5471        ReturnExpr::In(needle, haystack) => {
5472            contains_rand_call(needle) || contains_rand_call(haystack)
5473        }
5474        ReturnExpr::CountStar
5475        | ReturnExpr::Var(_)
5476        | ReturnExpr::Prop(_)
5477        | ReturnExpr::Lit(_)
5478        | ReturnExpr::HasLabel(..)
5479        | ReturnExpr::PatternPredicate(..)
5480        // Same opaque treatment as `contains_aggregate`'s own arm above --
5481        // a pattern comprehension's projection is checked once it's
5482        // actually evaluated per match, not searched into ahead of time.
5483        | ReturnExpr::PatternComprehension { .. }
5484        | ReturnExpr::ExistsPattern { .. }
5485        | ReturnExpr::ExistsSubquery(_) => false,
5486    }
5487}
5488
5489/// `RETURN *`/`RETURN DISTINCT *` resolved into the equivalent concrete
5490/// item list -- one bare-`Var` item per currently-bound name, sorted
5491/// alphabetically (real Cypher's own `RETURN *` column order, confirmed
5492/// against the TCK's own multi-variable scenarios, not introduction
5493/// order). Shared by `semantic.rs` (`scope.keys()`) and this file's own
5494/// `execute_match` (`carried_vars`) -- each already has its own accurate
5495/// bound-name set on hand at the point `Tail::ReturnStar` is reached, so
5496/// resolving it there (rather than via a separate whole-AST-mutation
5497/// pass before execution) needs no `&mut Statement` ripple through
5498/// `Executor::execute`'s public signature. Real Cypher's own
5499/// `NoVariablesInScope` compile-time error when nothing is bound at all
5500/// (TCK's Return7 `[2]`, `MATCH () RETURN *`). `WITH *` doesn't share this
5501/// restriction -- an empty `WITH *` is a legal, if useless, "carry forward
5502/// nothing" no-op (TCK's Create3 `[2]`/`[3]`: `MATCH () CREATE () WITH *
5503/// CREATE ()`, every token anonymous) -- see `with_star_items` below.
5504pub(crate) fn return_star_items(
5505    names: impl Iterator<Item = String>,
5506) -> Result<Vec<ReturnItem>, QueryError> {
5507    let names: Vec<String> = names.collect();
5508    if names.is_empty() {
5509        return Err(QueryError::Semantic(
5510            "RETURN * needs at least one variable in scope".into(),
5511        ));
5512    }
5513    Ok(star_items(names))
5514}
5515
5516/// `WITH *`'s own version of `return_star_items` -- same alphabetical
5517/// `Var`-per-name expansion, but tolerates an empty name set instead of
5518/// erroring (see that function's docs for why the two differ).
5519pub(crate) fn with_star_items(names: impl Iterator<Item = String>) -> Vec<ReturnItem> {
5520    star_items(names.collect())
5521}
5522
5523fn star_items(mut names: Vec<String>) -> Vec<ReturnItem> {
5524    names.sort();
5525    names
5526        .into_iter()
5527        .map(|name| ReturnItem {
5528            expr: ReturnExpr::Var(name),
5529            alias: None,
5530        })
5531        .collect()
5532}
5533
5534/// Validates a RETURN/WITH item list before any row is processed. Two
5535/// checks, both real Cypher compile-time errors:
5536///
5537/// - Every aggregate call (found anywhere -- not just a return item's
5538///   whole top-level expression, since `RETURN a, count(a) + 3`-style
5539///   composition is real Cypher, TCK's Return6 `[2]` etc) has the right
5540///   number of arguments, doesn't nest another aggregate inside its own
5541///   argument (`NestedAggregation`), and isn't given a non-deterministic
5542///   argument like `rand()` (`NonConstantExpression`).
5543/// - Once *any* item aggregates, every other item's own non-aggregate
5544///   leaf (a bare `Var`/`Prop` used outside any aggregate call) must
5545///   match some *other* item's whole top-level expression verbatim
5546///   (`AmbiguousAggregationExpression`, TCK's Return6 `[20]`/`[21]`) --
5547///   real Cypher's rule that a value used alongside an aggregate must
5548///   itself be an explicit grouping key, not just something that happens
5549///   to be in scope. A literal/param is always fine (same value on every
5550///   row, nothing to group by). This is checked by recursing into every
5551///   item whose expression contains an aggregate anywhere, stopping at
5552///   each aggregate-bearing subexpression itself (its own argument
5553///   doesn't need to be grouping-key-safe -- it's folded per row).
5554pub(crate) fn validate_return_items(items: &[ReturnItem]) -> Result<(), QueryError> {
5555    for item in items {
5556        if contains_aggregate(&item.expr) {
5557            validate_composed_expr(&item.expr, items)?;
5558        }
5559    }
5560    Ok(())
5561}
5562
5563/// Whether `expr` (a leaf found inside some *other* composed expression)
5564/// refers to `item` -- either structurally (`item.expr == *expr`) or, for
5565/// a bare `Var`, by `item`'s own output *alias* (`RETURN me.age AS age
5566/// ... ORDER BY age + count(...)`, TCK's ReturnOrderBy6 `[2]`: `age`
5567/// alone doesn't structurally equal `me.age`, but it's still exactly
5568/// item `age`'s value). Shared by `validate_composed_expr`'s compile-time
5569/// check and `Executor::rewrite_composed_item`'s matching runtime lookup
5570/// -- both need to agree on what counts as "the same grouping key,"
5571/// including this by-alias case, or one would accept what the other
5572/// can't actually evaluate.
5573pub(crate) fn item_matches_leaf(expr: &ReturnExpr, index: usize, item: &ReturnItem) -> bool {
5574    item.expr == *expr
5575        || matches!(expr, ReturnExpr::Var(name) if *name == with_item_output_name((index, item)))
5576}
5577
5578pub(crate) fn validate_composed_expr(
5579    expr: &ReturnExpr,
5580    items: &[ReturnItem],
5581) -> Result<(), QueryError> {
5582    if matches!(expr, ReturnExpr::CountStar) {
5583        return Ok(());
5584    }
5585    if let ReturnExpr::Call { name, args, .. } = expr {
5586        if is_aggregate_name(name) {
5587            // `percentileCont`/`percentileDisc` take a second argument
5588            // (the percentile) alongside the value being aggregated —
5589            // every other aggregate takes exactly one.
5590            let expected_args = if is_percentile_name(name) { 2 } else { 1 };
5591            if args.len() != expected_args {
5592                return Err(QueryError::Semantic(if expected_args == 2 {
5593                    format!("{name}() takes exactly two arguments (the value, then the percentile)")
5594                } else {
5595                    format!(
5596                        "{name}() takes exactly one argument (use count(*) for a row count with no argument)"
5597                    )
5598                }));
5599            }
5600            for arg in args {
5601                if contains_aggregate(arg) {
5602                    return Err(QueryError::Semantic(format!(
5603                        "aggregate function '{name}' can't take another aggregate as an argument"
5604                    )));
5605                }
5606                // `count(rand())` etc -- an aggregate's argument must be
5607                // deterministic per row for grouping/re-execution to have
5608                // well-defined semantics, which `rand()` (a fresh value on
5609                // every call, see its own docs) fundamentally breaks. Real
5610                // Cypher rejects this at compile time (TCK's Return6
5611                // [15], `NonConstantExpression`), not just "whatever value
5612                // it happens to produce."
5613                if contains_rand_call(arg) {
5614                    return Err(QueryError::Semantic(format!(
5615                        "aggregate function '{name}' can't take a non-deterministic expression \
5616                         (e.g. rand()) as an argument"
5617                    )));
5618                }
5619            }
5620            return Ok(());
5621        }
5622    }
5623    if matches!(expr, ReturnExpr::Var(_) | ReturnExpr::Prop(_)) {
5624        let is_grouping_key = items
5625            .iter()
5626            .enumerate()
5627            .any(|(i, it)| item_matches_leaf(expr, i, it) && !contains_aggregate(&it.expr));
5628        return if is_grouping_key {
5629            Ok(())
5630        } else {
5631            Err(QueryError::Semantic(format!(
5632                "{expr:?} is used alongside an aggregate function but isn't itself one of this \
5633                 RETURN/WITH's own items -- once any item aggregates, every other value used \
5634                 with it must be listed as its own explicit grouping key"
5635            )))
5636        };
5637    }
5638    // `Lit`/`HasLabel`/`PatternPredicate`/`PatternComprehension` need no
5639    // check here: a literal is the same value on every row (nothing to
5640    // group by), and the other three are opaque leaves for this same
5641    // reason `contains_aggregate`/`collect_agg_nodes` treat them that way
5642    // (see their own docs) -- not reachable with real content to check
5643    // since none can themselves contain an aggregate.
5644    match expr {
5645        ReturnExpr::Case { test, whens, else_ } => {
5646            if let Some(t) = test.as_deref() {
5647                validate_composed_expr(t, items)?;
5648            }
5649            for (w, t) in whens {
5650                validate_composed_expr(w, items)?;
5651                validate_composed_expr(t, items)?;
5652            }
5653            if let Some(e) = else_.as_deref() {
5654                validate_composed_expr(e, items)?;
5655            }
5656        }
5657        ReturnExpr::Call { args, .. } => {
5658            for arg in args {
5659                validate_composed_expr(arg, items)?;
5660            }
5661        }
5662        ReturnExpr::Arith(l, _, r) => {
5663            validate_composed_expr(l, items)?;
5664            validate_composed_expr(r, items)?;
5665        }
5666        ReturnExpr::Neg(e) => validate_composed_expr(e, items)?,
5667        ReturnExpr::ListLit(list_items) => {
5668            for item in list_items {
5669                validate_composed_expr(item, items)?;
5670            }
5671        }
5672        ReturnExpr::Index(base, index) => {
5673            validate_composed_expr(base, items)?;
5674            validate_composed_expr(index, items)?;
5675        }
5676        ReturnExpr::PropOf(base, _) => validate_composed_expr(base, items)?,
5677        ReturnExpr::Slice(base, start, end) => {
5678            validate_composed_expr(base, items)?;
5679            if let Some(s) = start.as_deref() {
5680                validate_composed_expr(s, items)?;
5681            }
5682            if let Some(e) = end.as_deref() {
5683                validate_composed_expr(e, items)?;
5684            }
5685        }
5686        // `source` may itself be a (possibly composed) aggregate --
5687        // `[x IN collect(p) | head(nodes(x))]` aggregates once per group
5688        // to build the list, then the comprehension iterates its result
5689        // normally (TCK's List12 [4]/[5], real and required) -- recursed
5690        // into below via the generic `Call`/`Arith`/etc. machinery, same
5691        // as any other composed leaf. `project`, in contrast, runs once
5692        // *per element* of that already-built list -- an aggregate
5693        // there has no defined semantics at all (real Cypher flatly
5694        // rejects it, TCK's List12 [7], "Fail when using aggregation in
5695        // list comprehension") and `resolve_grouped_rows` has no
5696        // "fold once per group, then run per element" fold shape for it
5697        // anyway, so it's checked directly here rather than falling
5698        // through to the generic recursion below, which would otherwise
5699        // validate (and `rewrite_composed_item` would then evaluate) a
5700        // nested aggregate as if it were an ordinary composed leaf.
5701        ReturnExpr::ListComp {
5702            source,
5703            project,
5704            where_clause,
5705            ..
5706        } => {
5707            if project.as_deref().is_some_and(contains_aggregate) {
5708                return Err(QueryError::Semantic(
5709                    "an aggregate function can't be used inside a list comprehension's projection"
5710                        .into(),
5711                ));
5712            }
5713            validate_composed_expr(source, items)?;
5714            // `where_clause` isn't checked -- same scope limitation as
5715            // `contains_aggregate`'s own matching arm.
5716            let _ = where_clause;
5717        }
5718        ReturnExpr::Quantifier { source, .. } => validate_composed_expr(source, items)?,
5719        ReturnExpr::MapLit(entries) => {
5720            for (_, v) in entries {
5721                validate_composed_expr(v, items)?;
5722            }
5723        }
5724        ReturnExpr::And(l, r) | ReturnExpr::Or(l, r) | ReturnExpr::Xor(l, r) => {
5725            validate_composed_expr(l, items)?;
5726            validate_composed_expr(r, items)?;
5727        }
5728        ReturnExpr::Not(e) => validate_composed_expr(e, items)?,
5729        ReturnExpr::Compare(l, _, r) => {
5730            validate_composed_expr(l, items)?;
5731            validate_composed_expr(r, items)?;
5732        }
5733        ReturnExpr::IsNull(e) => validate_composed_expr(e, items)?,
5734        ReturnExpr::In(needle, haystack) => {
5735            validate_composed_expr(needle, items)?;
5736            validate_composed_expr(haystack, items)?;
5737        }
5738        ReturnExpr::CountStar
5739        | ReturnExpr::Var(_)
5740        | ReturnExpr::Prop(_)
5741        | ReturnExpr::Lit(_)
5742        | ReturnExpr::HasLabel(..)
5743        | ReturnExpr::PatternPredicate(..)
5744        | ReturnExpr::PatternComprehension { .. }
5745        | ReturnExpr::ExistsPattern { .. }
5746        | ReturnExpr::ExistsSubquery(_) => {}
5747    }
5748    Ok(())
5749}
5750
5751/// Same rules as `validate_composed_expr` (reused directly, first), plus
5752/// one more real Cypher only enforces for an ORDER BY key specifically,
5753/// not for a RETURN/WITH item's own composed expression: every
5754/// aggregate-bearing subexpression found anywhere in it must itself
5755/// verbatim/alias-match some existing RETURN/WITH item (TCK's
5756/// WithOrderBy4 `[14]`, "Fail on sorting by a non-projected aggregation
5757/// on an expression" -- `ORDER BY sum(x)` when the WITH only computes
5758/// `min(x)`, a *different* aggregate over the same argument, is a real
5759/// compile-time error, not "just fold it separately"). A RETURN/WITH
5760/// item's own composed expression has no such restriction -- `RETURN a,
5761/// count(a) + sum(b)` folds both `count(a)` and `sum(b)` fresh as part of
5762/// evaluating that one item, with nothing else either needs to match.
5763pub(crate) fn validate_order_by_composed_expr(
5764    expr: &ReturnExpr,
5765    items: &[ReturnItem],
5766) -> Result<(), QueryError> {
5767    validate_composed_expr(expr, items)?;
5768    let mut agg_nodes = Vec::new();
5769    collect_agg_nodes(expr, &mut agg_nodes);
5770    for node in agg_nodes {
5771        let matches_item = items
5772            .iter()
5773            .enumerate()
5774            .any(|(i, it)| item_matches_leaf(node, i, it));
5775        if !matches_item {
5776            return Err(QueryError::Semantic(
5777                "ORDER BY aggregate does not match any RETURN/WITH item".into(),
5778            ));
5779        }
5780    }
5781    Ok(())
5782}
5783
5784/// Grouping-key hashing — deliberately at the `Binding` level (`NodeId`/
5785/// `EdgeId`/`PropertyValue`), not `Value`: cheaper (no `GraphStore` fetch
5786/// just to compute) and the correct semantics (two `Binding::Node`s are
5787/// the same group iff the same node **identity**, not equal-by-struct-
5788/// contents). `Binding::List`'s elements are `Value`s already, so those
5789/// delegate to `value_hash_key` directly.
5790fn binding_hash_key(b: &Binding) -> Result<HashKey, QueryError> {
5791    Ok(match b {
5792        Binding::Node(id) => HashKey::Node(*id),
5793        Binding::Edge(id) => HashKey::Edge(*id),
5794        Binding::Value(pv) => property_value_hash_key(pv),
5795        Binding::List(items) => HashKey::List(
5796            items
5797                .iter()
5798                .map(value_hash_key)
5799                .collect::<Result<Vec<_>, _>>()?,
5800        ),
5801        // A path's identity is its exact node/edge sequence, in order --
5802        // same graph-identity-by-id convention as the `Node`/`Edge` arms
5803        // above, just walked element-by-element (found via TCK's
5804        // Pattern2 [8]: `WITH [p = (n)-->() | p] AS ps, count(b) AS c`
5805        // makes `ps` -- a list of paths -- an implicit GROUP BY key,
5806        // real Cypher's own rule that every non-aggregate WITH/RETURN
5807        // item groups by).
5808        Binding::Path(elems) => HashKey::List(
5809            elems
5810                .iter()
5811                .map(|e| match e {
5812                    PathBinding::Node(id) => HashKey::Node(*id),
5813                    PathBinding::Edge(id) => HashKey::Edge(*id),
5814                })
5815                .collect(),
5816        ),
5817        // Same canonical-sorted-entries encoding as `value_hash_key`'s
5818        // matching `Value::Map` arm (a `BTreeMap` already iterates in
5819        // sorted key order).
5820        Binding::Map(m) => HashKey::List(
5821            m.iter()
5822                .map(|(k, v)| -> Result<HashKey, QueryError> {
5823                    Ok(HashKey::List(vec![
5824                        HashKey::Str(k.clone()),
5825                        value_hash_key(v)?,
5826                    ]))
5827                })
5828                .collect::<Result<Vec<_>, _>>()?,
5829        ),
5830    })
5831}
5832
5833/// Projects one of `ProcedureProvider::call`'s raw output rows (positional,
5834/// `sig.outputs.len()` values in that order) down to whatever `yield_items`
5835/// actually asked for -- `YIELD *` keeps every output under its own name;
5836/// an explicit item list picks out just those (by the procedure's own
5837/// declared name, not any rename yet) and pairs each with its `AS` alias
5838/// if it had one, same output order the `YIELD` itself was written in
5839/// (TCK's Call5 `[3]`: order is irrelevant to the *result*, but this still
5840/// preserves whatever order was written, which `materialize_return`-style
5841/// column ordering downstream expects to already be correct).
5842fn project_call_row(
5843    sig: &ProcedureSignature,
5844    proc_row: &[Value],
5845    yield_items: &CallYield,
5846) -> Result<Vec<Value>, QueryError> {
5847    match yield_items {
5848        CallYield::Star => Ok(proc_row.to_vec()),
5849        CallYield::Items(items, _) => items
5850            .iter()
5851            .map(|(name, _)| {
5852                let idx = sig.outputs.iter().position(|o| o == name).ok_or_else(|| {
5853                    QueryError::Semantic(format!(
5854                        "'{name}' isn't a declared output of this procedure"
5855                    ))
5856                })?;
5857                Ok(proc_row[idx].clone())
5858            })
5859            .collect(),
5860    }
5861}
5862
5863/// Coarse compile-time-shaped argument-type check (TCK's Call2
5864/// `[5]`/`[6]`: passing a `BOOLEAN` where `INTEGER` is declared must
5865/// error, even against an empty mock table that would otherwise just
5866/// silently return zero rows). `Value::Null` always matches regardless of
5867/// declared type -- every signature this codebase's own callers declare
5868/// is nullable (`INTEGER?` etc, TCK's Call4), and there's no dedicated
5869/// non-null marker to check against anyway. An unrecognized type name is
5870/// tolerated (accepts anything) rather than rejected -- this is a coarse
5871/// sanity check for the handful of type names TCK's own procedures
5872/// actually declare (`INTEGER`/`FLOAT`/`NUMBER`/`STRING`/`BOOLEAN`), not a
5873/// full type system.
5874fn value_matches_declared_type(value: &Value, declared: &str) -> bool {
5875    if matches!(value, Value::Null) {
5876        return true;
5877    }
5878    let is_int = matches!(
5879        value,
5880        Value::Literal(Literal::Int(_)) | Value::Property(PropertyValue::Int(_))
5881    );
5882    let is_float = matches!(
5883        value,
5884        Value::Literal(Literal::Float(_)) | Value::Property(PropertyValue::Float(_))
5885    );
5886    match declared.trim_end_matches('?').to_ascii_uppercase().as_str() {
5887        "INTEGER" => is_int,
5888        "FLOAT" | "NUMBER" => is_int || is_float,
5889        "STRING" => matches!(
5890            value,
5891            Value::Literal(Literal::String(_)) | Value::Property(PropertyValue::String(_))
5892        ),
5893        "BOOLEAN" => matches!(
5894            value,
5895            Value::Literal(Literal::Bool(_)) | Value::Property(PropertyValue::Bool(_))
5896        ),
5897        _ => true,
5898    }
5899}
5900
5901/// Converts a finished `AggAcc::finish()` result to the `Binding` it's
5902/// carried as through a `WITH` boundary — `collect()`'s `Value::List`
5903/// needs `Binding::List`, not `Binding::Value(PropertyValue::List(_))`:
5904/// `Binding::List` carries full `Value` elements (a `Node`/`Edge`'s real
5905/// id, restorable graph identity), while `PropertyValue::List` is the
5906/// flatter, storage-format shape (scalar elements only) -- collapsing a
5907/// `collect()` of nodes down to that would lose the ability to keep
5908/// traversing from them after the `WITH`. Everything else collapses to
5909/// `Binding::Value` same as any other computed WITH item.
5910fn value_to_binding(v: Value) -> Binding {
5911    match v {
5912        Value::List(items) => Binding::List(items),
5913        Value::Map(m) => Binding::Map(m),
5914        other => Binding::Value(value_to_property_value(&other)),
5915    }
5916}
5917
5918/// `UNWIND`'s counterpart to `value_to_binding` — restores graph identity
5919/// from a `collect()`'d element instead of collapsing it. `Value::Node`/
5920/// `Edge` carry their full `id`, so this isn't lossy the way carrying only
5921/// a display value would be: a `MATCH` after the `UNWIND` can keep
5922/// traversing from the restored `Binding::Node`/`Edge`, exactly as if it
5923/// had been bound by a fresh scan/expand. See `Binding::List`'s docs,
5924/// which anticipated this exact restoration.
5925fn value_to_binding_restore(v: &Value) -> Binding {
5926    match v {
5927        Value::Node(n) => Binding::Node(n.id),
5928        Value::Edge(e) => Binding::Edge(e.id),
5929        Value::Property(pv) => Binding::Value(pv.clone()),
5930        Value::Literal(lit) => Binding::Value(literal_to_value(lit)),
5931        Value::List(items) => Binding::List(items.clone()),
5932        Value::Map(m) => Binding::Map(m.clone()),
5933        Value::Path(elems) => Binding::Path(elems.iter().map(path_elem_to_binding).collect()),
5934        Value::Null => Binding::Value(PropertyValue::Null),
5935    }
5936}
5937
5938fn path_elem_to_binding(elem: &PathElem) -> PathBinding {
5939    match elem {
5940        PathElem::Node(n) => PathBinding::Node(n.id),
5941        PathElem::Edge(e) => PathBinding::Edge(e.id),
5942    }
5943}
5944
5945/// When a path is being captured, every hop's rel/node needs a trackable
5946/// binding even if the user left it anonymous — `Expand` only inserts a
5947/// `rel_var` into the row `if let Some(rv) = rel_var`, silently dropping
5948/// anonymous rels, which is fine for ordinary matching but loses exactly
5949/// the information path assembly needs. Returns a clone of `pattern` with
5950/// every position named (synthesizing `__path_elemN` for anything
5951/// anonymous), plus the set of names that were synthesized so
5952/// `execute_match` can strip them from the row again after `assemble_path`
5953/// runs — they were never something the user could reference. Only this
5954/// renamed clone is used for plan-building/OPTIONAL-MATCH null-padding
5955/// bookkeeping *within this one clause*; `carried_vars` (what's exposed to
5956/// later clauses) is still computed from the original `part.pattern`
5957/// elsewhere, so synthesized names never leak past this function's caller.
5958fn name_pattern_for_path(pattern: &Pattern) -> (Pattern, HashSet<String>) {
5959    fn fresh(counter: &mut usize, synthesized: &mut HashSet<String>) -> String {
5960        *counter += 1;
5961        let name = format!("__path_elem{counter}");
5962        synthesized.insert(name.clone());
5963        name
5964    }
5965    let mut counter = 0usize;
5966    let mut synthesized = HashSet::new();
5967    let mut start = pattern.start.clone();
5968    if start.var.is_none() {
5969        start.var = Some(fresh(&mut counter, &mut synthesized));
5970    }
5971    let hops = pattern
5972        .hops
5973        .iter()
5974        .map(|(rel, node)| {
5975            let mut rel = rel.clone();
5976            if rel.hop_range.is_some() {
5977                // A variable-length hop's own internally-traversed edges
5978                // are exposed via a fresh synthesized binding name (same
5979                // `fresh()` mechanism as every other anonymous token
5980                // here, so multiple variable-length hops in one pattern
5981                // each get their own, no collision -- TCK's Match6
5982                // `[17]`), read by `planner::build_match_plan` (its
5983                // `VarExpand`'s `path_segment_var`) and `assemble_path`.
5984                // The user's own real rel-list variable, if this hop had
5985                // one (`p = (a)-[r*1..3]->(b)`, TCK's Match9 `[9]`), is
5986                // preserved separately in `rel_list_var` rather than lost
5987                // to this overwrite -- `var` itself is always this hop's
5988                // internal path-segment bookkeeping name from here on.
5989                rel.rel_list_var = rel.var.take();
5990                rel.var = Some(fresh(&mut counter, &mut synthesized));
5991                rel.capture_path_segment = true;
5992            } else if rel.var.is_none() {
5993                rel.var = Some(fresh(&mut counter, &mut synthesized));
5994            }
5995            let mut node = node.clone();
5996            if node.var.is_none() {
5997                node.var = Some(fresh(&mut counter, &mut synthesized));
5998            }
5999            (rel, node)
6000        })
6001        .collect();
6002    (Pattern { start, hops }, synthesized)
6003}
6004
6005/// Assembles a `Binding::Path` from `pattern`'s (fully-named, via
6006/// `name_pattern_for_path`) start/hop variables, in pattern order. Falls
6007/// back to `Binding::Value(Null)` — never errors — if any position isn't a
6008/// real node/edge binding, which only happens when this row came from
6009/// `OPTIONAL MATCH` null-padding (every position `name_pattern_for_path`
6010/// named is guaranteed present in the row either way, as a real binding or
6011/// as `Binding::Value(Null)`, so "missing key" isn't a case this needs to
6012/// handle) — same "no match survives as Null, not a dropped row" outcome
6013/// `OPTIONAL MATCH` already gives every other variable.
6014fn assemble_path(pattern: &Pattern, row: &BindingRow) -> Binding {
6015    let Some(start_id) = path_node_id(pattern.start.var.as_deref(), row) else {
6016        return Binding::Value(PropertyValue::Null);
6017    };
6018    let mut elems = vec![PathBinding::Node(start_id)];
6019    for (rel, node) in &pattern.hops {
6020        if rel.capture_path_segment {
6021            // A variable-length hop's own segment, deposited by
6022            // `expand_variable_row` under this hop's own synthesized
6023            // `rel.var` -- already the exact alternating Edge/Node/.../
6024            // Node sequence this hop contributes, ending at `node`'s own
6025            // binding (so no separate `path_node_id(node.var, ...)` read
6026            // is needed after this).
6027            let Some(Binding::Path(segment)) = rel.var.as_deref().and_then(|v| row.get(v)) else {
6028                return Binding::Value(PropertyValue::Null);
6029            };
6030            elems.extend(segment.iter().cloned());
6031            continue;
6032        }
6033        let Some(edge_id) = path_edge_id(rel.var.as_deref(), row) else {
6034            return Binding::Value(PropertyValue::Null);
6035        };
6036        let Some(node_id) = path_node_id(node.var.as_deref(), row) else {
6037            return Binding::Value(PropertyValue::Null);
6038        };
6039        elems.push(PathBinding::Edge(edge_id));
6040        elems.push(PathBinding::Node(node_id));
6041    }
6042    Binding::Path(elems)
6043}
6044
6045/// `[r:TYPE*1..3]`'s own `r` -- real Cypher binds the traversed
6046/// relationships as a *list*, fully materialized (not just ids the way
6047/// `path_segment_var`'s cheaper `Binding::Path` segment stays), since
6048/// `Binding::List` -- like every other post-projection value shape --
6049/// only ever holds already-resolved `Value`s (TCK's Match4 `[1]`/`[6]`).
6050fn segment_edges_to_list(txn: Txn, segment: &[PathBinding]) -> Result<Binding, QueryError> {
6051    let edges = segment
6052        .iter()
6053        .filter_map(|elem| match elem {
6054            PathBinding::Edge(id) => Some(*id),
6055            PathBinding::Node(_) => None,
6056        })
6057        .map(|id| {
6058            let edge = deleted_entity_access(GraphStore::get_edge_in_txn(txn, id)?)?;
6059            Ok(Value::Edge(edge))
6060        })
6061        .collect::<Result<Vec<_>, QueryError>>()?;
6062    Ok(Binding::List(edges))
6063}
6064
6065fn path_node_id(var: Option<&str>, row: &BindingRow) -> Option<NodeId> {
6066    match var.and_then(|v| row.get(v)) {
6067        Some(Binding::Node(id)) => Some(*id),
6068        _ => None,
6069    }
6070}
6071
6072fn path_edge_id(var: Option<&str>, row: &BindingRow) -> Option<EdgeId> {
6073    match var.and_then(|v| row.get(v)) {
6074        Some(Binding::Edge(id)) => Some(*id),
6075        _ => None,
6076    }
6077}
6078
6079fn require_bound_node(row: &BindingRow, var: &str) -> Result<NodeId, QueryError> {
6080    match row.get(var) {
6081        Some(Binding::Node(id)) => Ok(*id),
6082        _ => Err(QueryError::UnboundVariable(format!(
6083            "'{var}' must already be bound to a node before shortestPath() — match it in a preceding MATCH"
6084        ))),
6085    }
6086}
6087
6088/// Walks `parent` (populated by `shortest_path_between`'s BFS) backward
6089/// from `end` to `start`, then reverses — `parent` only ever needs to
6090/// answer "how did BFS first reach this node," not support any other
6091/// traversal, so a plain `HashMap` (not a `LogicalPlan`/adjacency
6092/// structure) is enough.
6093fn reconstruct_path(
6094    parent: &HashMap<NodeId, (NodeId, EdgeId)>,
6095    start: NodeId,
6096    end: NodeId,
6097) -> Vec<PathBinding> {
6098    let mut hops = Vec::new();
6099    let mut current = end;
6100    while current != start {
6101        let (prev, edge_id) = parent[&current];
6102        hops.push((edge_id, current));
6103        current = prev;
6104    }
6105    hops.reverse();
6106    let mut elems = vec![PathBinding::Node(start)];
6107    for (edge_id, node) in hops {
6108        elems.push(PathBinding::Edge(edge_id));
6109        elems.push(PathBinding::Node(node));
6110    }
6111    elems
6112}
6113
6114/// Coerces a materialized `Value` down to a `PropertyValue` for storing in
6115/// `Binding::Value` — used by `item_binding` for a computed (non-bare-var)
6116/// WITH/RETURN item. `Value::Node`/`Edge` can't occur here in practice (no
6117/// non-aggregate `ReturnExpr` form produces one except `Var`, which takes
6118/// the bare-variable path instead), and a bare `collect()` result is
6119/// routed to `Binding::List` before reaching here (see `has_aggregate`) --
6120/// both still fall back to `Null` rather than needing a fallible signature
6121/// for an unreachable case. `Value::List` genuinely *can* reach here now,
6122/// though (`WITH n.numbers + [4] AS x` -- a real computed list expression,
6123/// not a bare `collect()`, once list-valued properties round-trip through
6124/// `lookup_prop_value` as real `Value::List`s) -- recurses per-element,
6125/// same as `value_to_storable_property`'s own list handling.
6126fn value_to_property_value(v: &Value) -> PropertyValue {
6127    match v {
6128        Value::Null => PropertyValue::Null,
6129        Value::Property(pv) => pv.clone(),
6130        Value::Literal(lit) => literal_to_value(lit),
6131        Value::List(items) => {
6132            PropertyValue::List(items.iter().map(value_to_property_value).collect())
6133        }
6134        Value::Node(_) | Value::Edge(_) | Value::Map(_) | Value::Path(_) => PropertyValue::Null,
6135    }
6136}
6137
6138/// `eval_props_to_values`'s stricter cousin of `value_to_property_value`
6139/// above -- a CREATE/SET prop value that evaluates to a node/edge/path/map
6140/// is a real, reportable error (`None` here), not a silent `Null`.
6141/// `value_to_property_value`'s silent-`Null` fallback is correct at *its*
6142/// call sites (a WITH-projected scalar, where those shapes genuinely can't
6143/// occur — see its own doc comment) but was never meant for CREATE/SET's
6144/// prop value, where writing one of those is a real, everyday mistake
6145/// (`CREATE (n {tags: some_node})`) that should say so, not silently store
6146/// `null`. `Value::List` *is* storable (`PropertyValue::List`, real
6147/// Cypher/Neo4j's own "homogeneous array property" shape) -- recurses
6148/// per-element, so a list containing something unstorable (a nested list
6149/// isn't rejected here, since no TCK scenario tests that restriction and
6150/// nothing about `PropertyValue::List`'s own storage format requires it,
6151/// but a node/edge/path/map element still correctly fails the whole list).
6152fn value_to_storable_property(v: &Value) -> Option<PropertyValue> {
6153    match v {
6154        Value::Null => Some(PropertyValue::Null),
6155        Value::Property(pv) => Some(pv.clone()),
6156        Value::Literal(lit) => Some(literal_to_value(lit)),
6157        Value::List(items) => Some(PropertyValue::List(
6158            items
6159                .iter()
6160                .map(value_to_storable_property)
6161                .collect::<Option<Vec<_>>>()?,
6162        )),
6163        Value::Node(_) | Value::Edge(_) | Value::Map(_) | Value::Path(_) => None,
6164    }
6165}
6166
6167/// `value_to_storable_property`'s inverse -- turns a raw stored/bound
6168/// `PropertyValue` back into a real `Value`, the read-time counterpart
6169/// every property-access site (`lookup_prop_value`, `binding_to_value`,
6170/// `eval_projected_expr`'s node/edge prop arms) needs. A scalar wraps as
6171/// `Value::Property` exactly as before; `PropertyValue::List` becomes a
6172/// genuine `Value::List` (not `Value::Property(PropertyValue::List(_))`)
6173/// so every existing list operation (`size()`, `tail()`, indexing, `IN`,
6174/// `UNWIND`, ...) -- all of which pattern-match on `Value::List`
6175/// specifically -- works transparently on a property-sourced list the
6176/// same as a list literal/`collect()` result, with no special-casing
6177/// needed anywhere else. `PropertyValue::Null` collapses to `Value::Null`,
6178/// matching every other property-read site's existing null convention.
6179fn property_value_to_value(pv: PropertyValue) -> Value {
6180    match pv {
6181        PropertyValue::Null => Value::Null,
6182        PropertyValue::List(items) => {
6183            Value::List(items.into_iter().map(property_value_to_value).collect())
6184        }
6185        other => Value::Property(other),
6186    }
6187}
6188
6189/// A bound `NodeId`/`EdgeId` whose record is no longer in the store means
6190/// exactly one thing within a single statement's transaction: it was
6191/// deleted earlier in this same statement (e.g. `MATCH (n) DELETE n RETURN
6192/// n.num` -- real Cypher's `DeletedEntityAccess` error, TCK's Return2
6193/// scenarios [15]/[16]/[17]). Nothing else can cause a `None` here --
6194/// there's no concurrent deletion mid-statement, and a `Binding::Node`/
6195/// `Edge` only ever gets constructed from an id a prior MATCH/CREATE/MERGE
6196/// in this same transaction actually found or made. Centralized here
6197/// (rather than each of `binding_to_value`/`resolve_path_elems`/
6198/// `lookup_prop` re-deriving the message) so the wording stays one place.
6199fn deleted_entity_access<T>(record: Option<T>) -> Result<T, QueryError> {
6200    record.ok_or_else(|| {
6201        QueryError::UnboundVariable(
6202            "refers to a node/relationship that no longer exists — it was deleted earlier in this statement".into(),
6203        )
6204    })
6205}
6206
6207pub(crate) fn literal_to_value(lit: &Literal) -> PropertyValue {
6208    match lit {
6209        Literal::Int(i) => PropertyValue::Int(*i),
6210        Literal::Float(f) => PropertyValue::Float(*f),
6211        Literal::String(s) => PropertyValue::String(s.clone()),
6212        Literal::Bool(b) => PropertyValue::Bool(*b),
6213        Literal::Null => PropertyValue::Null,
6214        Literal::Param(name) => {
6215            unreachable!("param ${name} must be substituted before execution — see params::substitute_params")
6216        }
6217    }
6218}
6219
6220fn tag_merge_created(mut row: BindingRow, created: bool) -> BindingRow {
6221    row.insert(
6222        MERGE_CREATED_KEY.to_string(),
6223        Binding::Value(PropertyValue::Bool(created)),
6224    );
6225    row
6226}
6227
6228/// `Either` (undirected `-[r:TYPE]-`) has no single storage-level call —
6229/// query both directions and dedupe by `edge_id` (a self-loop would
6230/// otherwise appear twice, once from each direction's adjacency table).
6231/// Multiple `rel_labels` (`[:A|B]`) has no single storage-level call
6232/// either — `GraphStore::neighbors_in_txn` only ever filters by one label
6233/// at a time, so this makes one call per type (per direction) and
6234/// dedupes by `edge_id` across all of them, same technique as `Either`
6235/// above (an edge whose type is in `rel_labels` is only ever returned by
6236/// exactly one of those per-type calls, so the only real duplication risk
6237/// is the same direction-crossing one `Either` already handles). Empty
6238/// `rel_labels` means untyped — matches any relationship, same as
6239/// `neighbors_in_txn`'s own `None` behavior.
6240fn neighbors_for_direction(
6241    txn: Txn,
6242    node: NodeId,
6243    direction: ExpandDirection,
6244    rel_labels: &[String],
6245) -> Result<Vec<AdjEntry>, QueryError> {
6246    let dirs: &[Direction] = match direction {
6247        ExpandDirection::Out => &[Direction::Out],
6248        ExpandDirection::In => &[Direction::In],
6249        ExpandDirection::Either => &[Direction::Out, Direction::In],
6250    };
6251    let mut out = Vec::new();
6252    let mut seen: HashSet<EdgeId> = HashSet::new();
6253    let label_filters: Vec<Option<&str>> = if rel_labels.is_empty() {
6254        vec![None]
6255    } else {
6256        rel_labels.iter().map(|l| Some(l.as_str())).collect()
6257    };
6258    for label in label_filters {
6259        for &dir in dirs {
6260            for entry in GraphStore::neighbors_in_txn(txn, node, dir, label)? {
6261                if seen.insert(entry.edge_id) {
6262                    out.push(entry);
6263                }
6264            }
6265        }
6266    }
6267    Ok(out)
6268}
6269
6270/// Three-valued: `None` is Cypher's "unknown", not `false` -- any
6271/// comparison touching a null (a missing property, or a literal `null` on
6272/// either side) is unknown, always, regardless of operator -- including
6273/// `Eq` (`x = null` is unknown, never true, same as real Cypher; it is
6274/// *not* how `x`'s own missing-ness is tested -- there's no `IS NULL`
6275/// operator yet). Callers combine this with `and3`/`or3`/`Option::map`
6276/// (for `NOT`) rather than unwrapping early, so unknown propagates
6277/// correctly through `AND`/`OR`/`NOT` instead of collapsing to `false`.
6278fn compare(prop: &Option<PropertyValue>, op: CompareOp, lit: &Literal) -> Option<bool> {
6279    let Some(prop) = prop else { return None };
6280    if matches!(prop, PropertyValue::Null) || matches!(lit, Literal::Null) {
6281        return None;
6282    }
6283    compare_property_pair(prop, op, &literal_to_value(lit))
6284}
6285
6286/// Same null-handling as `compare()`, but both sides are a looked-up
6287/// property (`Expr::PropCompare` -- `a.id = b.id`) instead of one side
6288/// being a fixed `Literal`.
6289fn compare_property_pair_opt(
6290    a: &Option<PropertyValue>,
6291    op: CompareOp,
6292    b: &Option<PropertyValue>,
6293) -> Option<bool> {
6294    let (Some(a), Some(b)) = (a, b) else {
6295        return None;
6296    };
6297    if matches!(a, PropertyValue::Null) || matches!(b, PropertyValue::Null) {
6298        return None;
6299    }
6300    compare_property_pair(a, op, b)
6301}
6302
6303/// The actual per-type comparison rules, shared by `compare()`
6304/// (`PropertyValue` vs a `Literal`, reduced to a `PropertyValue` via
6305/// `literal_to_value`) and `compare_values` (two arbitrary `Value`s,
6306/// each reduced to a `PropertyValue` via `value_to_property_value`) --
6307/// both callers have already handled the "either side is null" case
6308/// before reaching here. Returns `Option<bool>`, not `bool` -- a
6309/// type-mismatched pair (`1 < 'a'`) isn't a uniform "false" the way an
6310/// earlier version of this function had it: real Cypher's `=`/`<>` on
6311/// mismatched types is a definite `false`/`true` (never equal, so
6312/// "not equal" is true), but ordering (`<`/`<=`/`>`/`>=`) on mismatched
6313/// types is `null` (no defined ordering exists to be definite about) --
6314/// confirmed against real TCK scenarios (`'1.0' < 1.0` is `null`, not
6315/// `false`; `NaN <> 'a'` is `true`, not `false`), not assumed.
6316fn compare_property_pair(a: &PropertyValue, op: CompareOp, b: &PropertyValue) -> Option<bool> {
6317    match (a, b) {
6318        (PropertyValue::Int(a), PropertyValue::Int(b)) => Some(cmp_ord(op, *a, *b)),
6319        (PropertyValue::Int(a), PropertyValue::Float(b)) => Some(cmp_f64(op, *a as f64, *b)),
6320        (PropertyValue::Float(a), PropertyValue::Float(b)) => Some(cmp_f64(op, *a, *b)),
6321        (PropertyValue::Float(a), PropertyValue::Int(b)) => Some(cmp_f64(op, *a, *b as f64)),
6322        (PropertyValue::String(a), PropertyValue::String(b)) => Some(match op {
6323            CompareOp::StartsWith => a.starts_with(b.as_str()),
6324            CompareOp::EndsWith => a.ends_with(b.as_str()),
6325            CompareOp::Contains => a.contains(b.as_str()),
6326            _ => cmp_ord(op, a.as_str(), b.as_str()),
6327        }),
6328        // Real Cypher defines boolean ordering (`false < true`), same as
6329        // Rust's own `bool: PartialOrd` -- confirmed via a real TCK
6330        // scenario (`Quantifier7 :: [3]`) that specifically compares two
6331        // boolean expressions with `<=`.
6332        (PropertyValue::Bool(a), PropertyValue::Bool(b)) => Some(cmp_ord(op, *a, *b)),
6333        // `Date` had no arm here at all before -- fell through to the
6334        // generic mismatch fallback below, which always answers
6335        // `Eq -> false`/`Ne -> true` regardless of the actual values, so
6336        // `WHERE a.date = b.date` on two genuinely-equal stored dates
6337        // incorrectly evaluated to `false`. A real, pre-existing gap,
6338        // fixed here rather than left alongside the new temporal types.
6339        (PropertyValue::Date(a), PropertyValue::Date(b)) => Some(cmp_ord(op, *a, *b)),
6340        (PropertyValue::LocalTime(a), PropertyValue::LocalTime(b)) => Some(cmp_ord(op, *a, *b)),
6341        // Compares the UTC-equivalent instant-of-day, not the raw
6342        // wall-clock fields -- see `PropertyValue::Time`'s doc comment.
6343        (
6344            PropertyValue::Time {
6345                nanos_of_day: na,
6346                offset_seconds: oa,
6347            },
6348            PropertyValue::Time {
6349                nanos_of_day: nb,
6350                offset_seconds: ob,
6351            },
6352        ) => Some(cmp_ord(
6353            op,
6354            na - *oa as i64 * 1_000_000_000,
6355            nb - *ob as i64 * 1_000_000_000,
6356        )),
6357        (
6358            PropertyValue::LocalDateTime {
6359                epoch_seconds: sa,
6360                nanos: na,
6361            },
6362            PropertyValue::LocalDateTime {
6363                epoch_seconds: sb,
6364                nanos: nb,
6365            },
6366        ) => Some(cmp_ord(op, (*sa, *na), (*sb, *nb))),
6367        // Instant-only, `offset_seconds` ignored -- see
6368        // `PropertyValue::DateTime`'s doc comment.
6369        (
6370            PropertyValue::DateTime {
6371                epoch_seconds: sa,
6372                nanos: na,
6373                ..
6374            },
6375            PropertyValue::DateTime {
6376                epoch_seconds: sb,
6377                nanos: nb,
6378                ..
6379            },
6380        ) => Some(cmp_ord(op, (*sa, *na), (*sb, *nb))),
6381        // `Duration` has no defined *ordering* (see its own doc comment)
6382        // but `=`/`<>` are still real, component-wise comparisons (the
6383        // same bug `Date` had above -- the generic mismatch fallback's
6384        // unconditional `Eq -> false` would otherwise make two
6385        // genuinely-equal durations compare unequal).
6386        (PropertyValue::Duration { .. }, PropertyValue::Duration { .. }) => match op {
6387            CompareOp::Eq => Some(a == b),
6388            CompareOp::Ne => Some(a != b),
6389            _ => None,
6390        },
6391        _ => match op {
6392            CompareOp::Eq => Some(false),
6393            CompareOp::Ne => Some(true),
6394            // A string predicate on a non-null, non-string operand has no
6395            // defined answer (undefined, not "definitely false") -- same
6396            // "type mismatch -> null" stance as ordering, confirmed via a
6397            // real TCK scenario (`'abc' STARTS WITH true` must be `null`,
6398            // not `false`, so `(x STARTS WITH true) <> (x STARTS WITH
6399            // true)` correctly stays `null` rather than folding to a
6400            // spurious `false`/`true`).
6401            CompareOp::StartsWith
6402            | CompareOp::EndsWith
6403            | CompareOp::Contains
6404            | CompareOp::Lt
6405            | CompareOp::Le
6406            | CompareOp::Gt
6407            | CompareOp::Ge => None,
6408        },
6409    }
6410}
6411
6412/// A `ReturnExpr` boolean operand -- `Null` is "unknown" (`None`), a real
6413/// bool passes through, anything else is a genuine type error (real
6414/// Cypher: `1 AND true` doesn't silently coerce).
6415fn value_to_bool3(v: &Value) -> Result<Option<bool>, QueryError> {
6416    match v {
6417        Value::Null => Ok(None),
6418        Value::Literal(Literal::Bool(b)) | Value::Property(PropertyValue::Bool(b)) => Ok(Some(*b)),
6419        other => Err(QueryError::Type(format!(
6420            "expected a boolean, got {other:?}"
6421        ))),
6422    }
6423}
6424
6425fn bool3_to_value(b: Option<bool>) -> Value {
6426    match b {
6427        Some(b) => Value::Literal(Literal::Bool(b)),
6428        None => Value::Null,
6429    }
6430}
6431
6432/// `None`/`None` (both unknown) combines to unknown, matching Cypher's
6433/// `AND` truth table -- `false` wins over `unknown` (`false AND unknown =
6434/// false`), but `true AND unknown = unknown`, not `true`.
6435fn and3(a: Option<bool>, b: Option<bool>) -> Option<bool> {
6436    match (a, b) {
6437        (Some(false), _) | (_, Some(false)) => Some(false),
6438        (Some(true), Some(true)) => Some(true),
6439        _ => None,
6440    }
6441}
6442
6443/// Mirrors `and3` for `OR` -- `true` wins over `unknown`.
6444fn or3(a: Option<bool>, b: Option<bool>) -> Option<bool> {
6445    match (a, b) {
6446        (Some(true), _) | (_, Some(true)) => Some(true),
6447        (Some(false), Some(false)) => Some(false),
6448        _ => None,
6449    }
6450}
6451
6452/// `XOR` has no "one side already decides it" shortcut the way `AND`/`OR`
6453/// do -- either operand being unknown makes the whole result unknown,
6454/// since flipping the unknown side could flip the answer either way.
6455fn xor3(a: Option<bool>, b: Option<bool>) -> Option<bool> {
6456    match (a, b) {
6457        (Some(a), Some(b)) => Some(a != b),
6458        _ => None,
6459    }
6460}
6461
6462fn cmp_f64(op: CompareOp, a: f64, b: f64) -> bool {
6463    match op {
6464        CompareOp::Eq => a == b,
6465        CompareOp::Ne => a != b,
6466        CompareOp::Lt => a < b,
6467        CompareOp::Le => a <= b,
6468        CompareOp::Gt => a > b,
6469        CompareOp::Ge => a >= b,
6470        // Only meaningful for String/String, handled separately in
6471        // `compare()` before reaching here -- a numeric operand with one
6472        // of these ops is a type mismatch, same as any other.
6473        CompareOp::StartsWith | CompareOp::EndsWith | CompareOp::Contains => false,
6474    }
6475}
6476
6477fn cmp_ord<T: PartialOrd>(op: CompareOp, a: T, b: T) -> bool {
6478    match op {
6479        CompareOp::Eq => a == b,
6480        CompareOp::Ne => a != b,
6481        CompareOp::Lt => a < b,
6482        CompareOp::Le => a <= b,
6483        CompareOp::Gt => a > b,
6484        CompareOp::Ge => a >= b,
6485        CompareOp::StartsWith | CompareOp::EndsWith | CompareOp::Contains => false,
6486    }
6487}
6488
6489/// Value equality for CASE's WHEN-comparison (and, elsewhere, DISTINCT
6490/// dedup within an aggregate). Null == Null -> true here deliberately,
6491/// unlike `compare()`'s three-valued `WHERE`-filter semantics -- CASE and
6492/// DISTINCT need a definite yes/no ("is this the same value as a value
6493/// already collected", "does this WHEN branch match") rather than
6494/// "unknown", so plain equality is the correct, separate choice here, not
6495/// an oversight. `Node`/`Edge` compare by id (graph identity), not
6496/// full-struct contents — cheaper, and the correct semantics regardless
6497/// (two bindings are "the same node" iff the same node, not iff their
6498/// label/prop snapshots happen to match).
6499pub(crate) fn value_eq(a: &Value, b: &Value) -> bool {
6500    match (a, b) {
6501        (Value::Null, Value::Null) => true,
6502        (Value::Null, _) | (_, Value::Null) => false,
6503        (Value::Property(pa), Value::Property(pb)) => property_value_eq(pa, pb),
6504        (Value::Literal(la), Value::Literal(lb)) => la == lb,
6505        (Value::Property(pa), Value::Literal(lb)) => *pa == literal_to_value(lb),
6506        (Value::Literal(la), Value::Property(pb)) => literal_to_value(la) == *pb,
6507        (Value::Node(na), Value::Node(nb)) => na.id == nb.id,
6508        (Value::Edge(ea), Value::Edge(eb)) => ea.id == eb.id,
6509        (Value::List(la), Value::List(lb)) => {
6510            la.len() == lb.len() && la.iter().zip(lb).all(|(x, y)| value_eq(x, y))
6511        }
6512        // Two paths are equal iff they visit the same nodes/relationships
6513        // in the same order (real Cypher's own path-equality rule) --
6514        // element-wise identity, same `.id` comparison `Value::Node`/
6515        // `Value::Edge` above already use. Previously fell through to the
6516        // catch-all `_ => false` (any two paths were unconditionally
6517        // unequal, even two bindings of the identical path) -- unreachable
6518        // until two independently-MATCHed paths could be compared via `=`
6519        // in one statement (TCK's Comparison1 [14]).
6520        (Value::Path(pa), Value::Path(pb)) => {
6521            pa.len() == pb.len()
6522                && pa.iter().zip(pb).all(|(x, y)| match (x, y) {
6523                    (PathElem::Node(na), PathElem::Node(nb)) => na.id == nb.id,
6524                    (PathElem::Edge(ea), PathElem::Edge(eb)) => ea.id == eb.id,
6525                    _ => false,
6526                })
6527        }
6528        _ => false,
6529    }
6530}
6531
6532/// `PropertyValue`'s derived `PartialEq` is structural (every field must
6533/// match), which is wrong for `Time`/`DateTime`: two values at the same
6534/// instant but different offsets must compare equal (see their own doc
6535/// comments -- same rule `compare_property_pair`/`compare_non_null`/
6536/// `comparable_ordering` already apply for `<`/`>`/ORDER BY/min/max).
6537/// Everything else keeps plain structural equality.
6538fn property_value_eq(a: &PropertyValue, b: &PropertyValue) -> bool {
6539    match (a, b) {
6540        (
6541            PropertyValue::Time {
6542                nanos_of_day: na,
6543                offset_seconds: oa,
6544            },
6545            PropertyValue::Time {
6546                nanos_of_day: nb,
6547                offset_seconds: ob,
6548            },
6549        ) => na - *oa as i64 * 1_000_000_000 == nb - *ob as i64 * 1_000_000_000,
6550        (
6551            PropertyValue::DateTime {
6552                epoch_seconds: sa,
6553                nanos: na,
6554                ..
6555            },
6556            PropertyValue::DateTime {
6557                epoch_seconds: sb,
6558                nanos: nb,
6559                ..
6560            },
6561        ) => sa == sb && na == nb,
6562        _ => a == b,
6563    }
6564}
6565
6566/// A number coerced out of a `Value`, for `apply_arith` below -- separate
6567/// from `PropertyValue`/`Literal` since either could hold the operand
6568/// (`n.price + 1` mixes a stored property with a literal).
6569enum ArithNum {
6570    Int(i64),
6571    Float(f64),
6572}
6573
6574fn as_arith_num(v: &Value) -> Option<ArithNum> {
6575    match v {
6576        Value::Property(PropertyValue::Int(i)) | Value::Literal(Literal::Int(i)) => {
6577            Some(ArithNum::Int(*i))
6578        }
6579        Value::Property(PropertyValue::Float(f)) | Value::Literal(Literal::Float(f)) => {
6580            Some(ArithNum::Float(*f))
6581        }
6582        _ => None,
6583    }
6584}
6585
6586/// `datetime.fromepoch(seconds, nanos)`/`datetime.fromepochmillis(millis)`'s
6587/// own argument check -- both take a required, definite integer, not the
6588/// wider "any arithmetic-ish value" `as_arith_num` allows (no float
6589/// coercion for a raw epoch count) and not optional (missing/null isn't a
6590/// documented no-op the way it is for e.g. `date()`'s own no-arg form).
6591fn require_int_arg(v: Option<&Value>, fn_name: &str) -> Result<i64, QueryError> {
6592    match v {
6593        Some(Value::Property(PropertyValue::Int(i))) | Some(Value::Literal(Literal::Int(i))) => {
6594            Ok(*i)
6595        }
6596        other => Err(QueryError::Type(format!(
6597            "{fn_name}() expects an integer argument, got {other:?}"
6598        ))),
6599    }
6600}
6601
6602fn as_arith_str(v: &Value) -> Option<&str> {
6603    match v {
6604        Value::Property(PropertyValue::String(s)) | Value::Literal(Literal::String(s)) => {
6605            Some(s.as_str())
6606        }
6607        _ => None,
6608    }
6609}
6610
6611/// `-x` for `ReturnExpr::Neg` -- a negative numeric *literal* (`-3`)
6612/// never reaches this (see `cypher.pest`'s `unary_minus_expr` docs), so
6613/// this only ever handles negating a genuinely computed/bound value
6614/// (`-n.prop`, `-(1+2)`, ...). Same null-propagation/numeric-only
6615/// convention as `apply_arith`.
6616fn apply_neg(v: &Value) -> Result<Value, QueryError> {
6617    if matches!(v, Value::Null) {
6618        return Ok(Value::Null);
6619    }
6620    Ok(match as_arith_num(v) {
6621        Some(ArithNum::Int(i)) => {
6622            Value::Property(PropertyValue::Int(i.checked_neg().ok_or_else(|| {
6623                QueryError::Type("integer arithmetic overflow".into())
6624            })?))
6625        }
6626        Some(ArithNum::Float(f)) => Value::Property(PropertyValue::Float(-f)),
6627        None => {
6628            return Err(QueryError::Type(format!(
6629                "unary minus needs a number -- got {v:?}"
6630            )))
6631        }
6632    })
6633}
6634
6635/// `lhs op rhs` for `ReturnExpr::Arith`. Null propagates (matches every
6636/// other operator's null-handling convention in this file). `+` also
6637/// concatenates two strings, real Cypher's other overload for that
6638/// operator; every other combination of non-numeric operands is a real
6639/// type error, not a silent `Null`/`false` fallback -- an arithmetic
6640/// expression that can't be evaluated should say so, not produce a
6641/// plausible-looking wrong answer.
6642fn apply_arith(op: ArithOp, a: &Value, b: &Value) -> Result<Value, QueryError> {
6643    if matches!(a, Value::Null) || matches!(b, Value::Null) {
6644        return Ok(Value::Null);
6645    }
6646    if op == ArithOp::Add {
6647        // Real Cypher's list concatenation/append/prepend via `+` --
6648        // `[1,2] + [3]` concatenates, `[1,2] + 3`/`3 + [1,2]` appends/
6649        // prepends the scalar. Only `+` has this meaning for a list;
6650        // every other `ArithOp` still rejects one via the numeric-only
6651        // fallback below (and at compile time, `semantic.rs`'s own
6652        // `ReturnExpr::Arith` check).
6653        match (a, b) {
6654            (Value::List(xs), Value::List(ys)) => {
6655                let mut combined = xs.clone();
6656                combined.extend(ys.iter().cloned());
6657                return Ok(Value::List(combined));
6658            }
6659            (Value::List(xs), scalar) => {
6660                let mut combined = xs.clone();
6661                combined.push(scalar.clone());
6662                return Ok(Value::List(combined));
6663            }
6664            (scalar, Value::List(ys)) => {
6665                let mut combined = vec![scalar.clone()];
6666                combined.extend(ys.iter().cloned());
6667                return Ok(Value::List(combined));
6668            }
6669            _ => {}
6670        }
6671        if let (Some(sa), Some(sb)) = (as_arith_str(a), as_arith_str(b)) {
6672            return Ok(Value::Property(PropertyValue::String(format!("{sa}{sb}"))));
6673        }
6674    }
6675    if let Some(result) = apply_temporal_arith(op, a, b)? {
6676        return Ok(result);
6677    }
6678    let (Some(na), Some(nb)) = (as_arith_num(a), as_arith_num(b)) else {
6679        return Err(QueryError::Type(format!(
6680            "arithmetic needs two numbers (or, for +, two strings) -- got {a:?} and {b:?}"
6681        )));
6682    };
6683    // `^` always produces a Float, even for two Ints (real Cypher's own
6684    // rule) -- handled up front, separately from the Int/Int-stays-Int
6685    // branch below, rather than folding it into that match's own `op`
6686    // dispatch.
6687    if op == ArithOp::Pow {
6688        let to_f64 = |n: ArithNum| match n {
6689            ArithNum::Int(i) => i as f64,
6690            ArithNum::Float(f) => f,
6691        };
6692        return Ok(Value::Property(PropertyValue::Float(
6693            to_f64(na).powf(to_f64(nb)),
6694        )));
6695    }
6696    // Int/Int stays Int (truncating division/modulo, matching Rust's `/`/
6697    // `%` on integers) -- any Float operand promotes the whole expression
6698    // to Float, same numeric-promotion rule `compare()` already follows.
6699    Ok(match (na, nb) {
6700        (ArithNum::Int(x), ArithNum::Int(y)) => {
6701            if matches!(op, ArithOp::Div | ArithOp::Mod) && y == 0 {
6702                return Err(QueryError::Type("division by zero".into()));
6703            }
6704            let value = match op {
6705                ArithOp::Add => x.checked_add(y),
6706                ArithOp::Sub => x.checked_sub(y),
6707                ArithOp::Mul => x.checked_mul(y),
6708                ArithOp::Div => x.checked_div(y),
6709                ArithOp::Mod => x.checked_rem(y),
6710                ArithOp::Pow => unreachable!("handled above"),
6711            }
6712            .ok_or_else(|| QueryError::Type("integer arithmetic overflow".into()))?;
6713            Value::Property(PropertyValue::Int(value))
6714        }
6715        (x, y) => {
6716            let x = match x {
6717                ArithNum::Int(i) => i as f64,
6718                ArithNum::Float(f) => f,
6719            };
6720            let y = match y {
6721                ArithNum::Int(i) => i as f64,
6722                ArithNum::Float(f) => f,
6723            };
6724            Value::Property(PropertyValue::Float(match op {
6725                ArithOp::Add => x + y,
6726                ArithOp::Sub => x - y,
6727                ArithOp::Mul => x * y,
6728                ArithOp::Div => x / y,
6729                ArithOp::Mod => x % y,
6730                ArithOp::Pow => unreachable!("handled above"),
6731            }))
6732        }
6733    })
6734}
6735
6736fn as_date(v: &Value) -> Option<i32> {
6737    match v {
6738        Value::Property(PropertyValue::Date(d)) => Some(*d),
6739        _ => None,
6740    }
6741}
6742
6743fn as_duration(v: &Value) -> Option<temporal::DurationParts> {
6744    match v {
6745        Value::Property(PropertyValue::Duration {
6746            months,
6747            days,
6748            seconds,
6749            nanos,
6750        }) => Some((*months, *days, *seconds, *nanos)),
6751        _ => None,
6752    }
6753}
6754
6755fn duration_value((months, days, seconds, nanos): temporal::DurationParts) -> Value {
6756    Value::Property(PropertyValue::Duration {
6757        months,
6758        days,
6759        seconds,
6760        nanos,
6761    })
6762}
6763
6764fn as_local_time(v: &Value) -> Option<i64> {
6765    match v {
6766        Value::Property(PropertyValue::LocalTime(n)) => Some(*n),
6767        _ => None,
6768    }
6769}
6770
6771fn as_time(v: &Value) -> Option<(i64, i32)> {
6772    match v {
6773        Value::Property(PropertyValue::Time {
6774            nanos_of_day,
6775            offset_seconds,
6776        }) => Some((*nanos_of_day, *offset_seconds)),
6777        _ => None,
6778    }
6779}
6780
6781fn as_local_date_time(v: &Value) -> Option<(i64, i32)> {
6782    match v {
6783        Value::Property(PropertyValue::LocalDateTime {
6784            epoch_seconds,
6785            nanos,
6786        }) => Some((*epoch_seconds, *nanos)),
6787        _ => None,
6788    }
6789}
6790
6791fn as_date_time(v: &Value) -> Option<(i64, i32, temporal::TzId)> {
6792    match v {
6793        Value::Property(PropertyValue::DateTime {
6794            epoch_seconds,
6795            nanos,
6796            zone,
6797        }) => Some((*epoch_seconds, *nanos, tz_from_graph(zone))),
6798        _ => None,
6799    }
6800}
6801
6802/// `marsdb_graph::TzId` <-> `temporal::TzId` -- two independent, same-
6803/// shaped types (`temporal.rs` deliberately doesn't depend on
6804/// `marsdb_graph`, see its own module doc comment), converted at this
6805/// storage/query-layer boundary.
6806fn tz_from_graph(zone: &GraphTzId) -> temporal::TzId {
6807    match zone {
6808        GraphTzId::Offset(o) => temporal::TzId::Offset(*o),
6809        GraphTzId::Named(name) => temporal::TzId::Named(name.clone()),
6810    }
6811}
6812
6813fn tz_to_graph(zone: temporal::TzId) -> GraphTzId {
6814    match zone {
6815        temporal::TzId::Offset(o) => GraphTzId::Offset(o),
6816        temporal::TzId::Named(name) => GraphTzId::Named(name),
6817    }
6818}
6819
6820/// The `Date`/`Duration`/`LocalTime`/`Time`/`LocalDateTime`/`DateTime`
6821/// cases of `+`/`-`/`*`/`/` -- tried before `apply_arith`'s generic
6822/// numeric path, since none of these are ever an `ArithNum`. Returns
6823/// `Ok(None)` (not an error) for any operand-type combination it doesn't
6824/// recognize, so `apply_arith` falls through to its own "not two
6825/// numbers" error with the *original* operands in the message, rather
6826/// than this function needing to duplicate that error text.
6827///
6828/// `<temporal> - <temporal>` (real Cypher's `duration.between(...)` is
6829/// the actual spelling for that, itself out of scope -- see the README)
6830/// is deliberately *not* handled for any of the 5 non-Duration types,
6831/// falling through to the same "not two numbers" error a truly
6832/// nonsensical subtraction would already get.
6833fn apply_temporal_arith(op: ArithOp, a: &Value, b: &Value) -> Result<Option<Value>, QueryError> {
6834    let date_plus_duration =
6835        |d: i32, dur: temporal::DurationParts, negate: bool| -> Result<Value, QueryError> {
6836            let (months, days, seconds, nanos) = dur;
6837            temporal::add_duration_to_date(d, months, days, seconds, nanos, negate)
6838                .map(|d| Value::Property(PropertyValue::Date(d)))
6839                .ok_or_else(|| {
6840                    QueryError::Type("date +/- duration produced an out-of-range date".into())
6841                })
6842        };
6843    let local_time_plus_duration = |t: i64, dur: temporal::DurationParts, negate: bool| -> Value {
6844        let (_, _, seconds, nanos) = dur;
6845        Value::Property(PropertyValue::LocalTime(temporal::add_duration_to_time(
6846            t, seconds, nanos, negate,
6847        )))
6848    };
6849    let time_plus_duration =
6850        |(t, offset): (i64, i32), dur: temporal::DurationParts, negate: bool| -> Value {
6851            let (_, _, seconds, nanos) = dur;
6852            Value::Property(PropertyValue::Time {
6853                nanos_of_day: temporal::add_duration_to_time(t, seconds, nanos, negate),
6854                offset_seconds: offset,
6855            })
6856        };
6857    let local_date_time_plus_duration = |(epoch_seconds, existing_nanos): (i64, i32),
6858                                         dur: temporal::DurationParts,
6859                                         negate: bool|
6860     -> Result<Value, QueryError> {
6861        let (months, days, seconds, nanos) = dur;
6862        temporal::add_duration_to_local_date_time(
6863            epoch_seconds,
6864            existing_nanos,
6865            months,
6866            days,
6867            seconds,
6868            nanos,
6869            negate,
6870        )
6871        .map(|(epoch_seconds, nanos)| {
6872            Value::Property(PropertyValue::LocalDateTime {
6873                epoch_seconds,
6874                nanos,
6875            })
6876        })
6877        .ok_or_else(|| {
6878            QueryError::Type("local date-time +/- duration produced an out-of-range value".into())
6879        })
6880    };
6881    // `Named` zone arithmetic is only ever a single fixed-offset op, not
6882    // a full DST-crossing re-resolution -- the offset is resolved once
6883    // (at the *pre*-arithmetic instant) via `resolve_offset` and carried
6884    // through unchanged, same as `Offset`'s own behavior; no TCK scenario
6885    // exercises arithmetic on a `Named`-zone `DateTime` at all, so this
6886    // is a real, deliberately narrow scope, not silently wrong for a
6887    // tested case.
6888    let date_time_plus_duration =
6889        |(epoch_seconds, existing_nanos, zone): (i64, i32, temporal::TzId),
6890         dur: temporal::DurationParts,
6891         negate: bool|
6892         -> Result<Value, QueryError> {
6893            let (months, days, seconds, nanos) = dur;
6894            let offset_seconds = temporal::resolve_offset(&zone, epoch_seconds);
6895            temporal::add_duration_to_local_date_time(
6896                epoch_seconds + offset_seconds as i64,
6897                existing_nanos,
6898                months,
6899                days,
6900                seconds,
6901                nanos,
6902                negate,
6903            )
6904            .map(|(local_epoch_seconds, nanos)| {
6905                Value::Property(PropertyValue::DateTime {
6906                    epoch_seconds: local_epoch_seconds - offset_seconds as i64,
6907                    nanos,
6908                    zone: tz_to_graph(zone),
6909                })
6910            })
6911            .ok_or_else(|| {
6912                QueryError::Type("date-time +/- duration produced an out-of-range value".into())
6913            })
6914        };
6915    Ok(match op {
6916        ArithOp::Add => {
6917            if let (Some(d), Some(dur)) = (as_date(a), as_duration(b)) {
6918                Some(date_plus_duration(d, dur, false)?)
6919            } else if let (Some(dur), Some(d)) = (as_duration(a), as_date(b)) {
6920                Some(date_plus_duration(d, dur, false)?)
6921            } else if let (Some(t), Some(dur)) = (as_local_time(a), as_duration(b)) {
6922                Some(local_time_plus_duration(t, dur, false))
6923            } else if let (Some(dur), Some(t)) = (as_duration(a), as_local_time(b)) {
6924                Some(local_time_plus_duration(t, dur, false))
6925            } else if let (Some(t), Some(dur)) = (as_time(a), as_duration(b)) {
6926                Some(time_plus_duration(t, dur, false))
6927            } else if let (Some(dur), Some(t)) = (as_duration(a), as_time(b)) {
6928                Some(time_plus_duration(t, dur, false))
6929            } else if let (Some(dt), Some(dur)) = (as_local_date_time(a), as_duration(b)) {
6930                Some(local_date_time_plus_duration(dt, dur, false)?)
6931            } else if let (Some(dur), Some(dt)) = (as_duration(a), as_local_date_time(b)) {
6932                Some(local_date_time_plus_duration(dt, dur, false)?)
6933            } else if let (Some(dt), Some(dur)) = (as_date_time(a), as_duration(b)) {
6934                Some(date_time_plus_duration(dt, dur, false)?)
6935            } else if let (Some(dur), Some(dt)) = (as_duration(a), as_date_time(b)) {
6936                Some(date_time_plus_duration(dt, dur, false)?)
6937            } else if let (Some(x), Some(y)) = (as_duration(a), as_duration(b)) {
6938                Some(duration_value(temporal::add_duration(x, y).ok_or_else(
6939                    || QueryError::Type("duration addition overflow".into()),
6940                )?))
6941            } else {
6942                None
6943            }
6944        }
6945        ArithOp::Sub => {
6946            if let (Some(d), Some(dur)) = (as_date(a), as_duration(b)) {
6947                Some(date_plus_duration(d, dur, true)?)
6948            } else if let (Some(t), Some(dur)) = (as_local_time(a), as_duration(b)) {
6949                Some(local_time_plus_duration(t, dur, true))
6950            } else if let (Some(t), Some(dur)) = (as_time(a), as_duration(b)) {
6951                Some(time_plus_duration(t, dur, true))
6952            } else if let (Some(dt), Some(dur)) = (as_local_date_time(a), as_duration(b)) {
6953                Some(local_date_time_plus_duration(dt, dur, true)?)
6954            } else if let (Some(dt), Some(dur)) = (as_date_time(a), as_duration(b)) {
6955                Some(date_time_plus_duration(dt, dur, true)?)
6956            } else if let (Some(x), Some(y)) = (as_duration(a), as_duration(b)) {
6957                Some(duration_value(temporal::sub_duration(x, y).ok_or_else(
6958                    || QueryError::Type("duration subtraction overflow".into()),
6959                )?))
6960            } else {
6961                None
6962            }
6963        }
6964        ArithOp::Mul => {
6965            if let (Some(dur), Some(f)) = (as_duration(a), value_as_f64(b)) {
6966                Some(duration_value(temporal::scale_duration(dur, f)))
6967            } else if let (Some(f), Some(dur)) = (value_as_f64(a), as_duration(b)) {
6968                Some(duration_value(temporal::scale_duration(dur, f)))
6969            } else {
6970                None
6971            }
6972        }
6973        ArithOp::Div => {
6974            if let (Some(dur), Some(f)) = (as_duration(a), value_as_f64(b)) {
6975                if f == 0.0 {
6976                    return Err(QueryError::Type("division by zero".into()));
6977                }
6978                Some(duration_value(temporal::scale_duration(dur, 1.0 / f)))
6979            } else {
6980                None
6981            }
6982        }
6983        ArithOp::Mod => None,
6984        // `^` is never meaningful for a date/duration/etc operand --
6985        // real Cypher has no temporal exponentiation, so this always
6986        // falls through to `apply_arith`'s own numeric-only rejection.
6987        ArithOp::Pow => None,
6988    })
6989}
6990
6991/// `list[index]` -- a negative index counts from the end (`-1` is the
6992/// last element). Out of bounds either way is `Null`, not an error --
6993/// matches real Cypher (`[1,2,3][10]` is `null`, not a failure), and is
6994/// the only sane behavior for an index that's itself a runtime expression
6995/// rather than a literal a human could sanity-check up front.
6996fn apply_index(list: &Value, index: &Value) -> Result<Value, QueryError> {
6997    if matches!(list, Value::Null) || matches!(index, Value::Null) {
6998        return Ok(Value::Null);
6999    }
7000    // `map[key]` -- real Cypher's dynamic map-field access (`map['name']`,
7001    // as opposed to `map.name`'s static form -- `lookup_prop`/`ReturnExpr
7002    // ::Prop` above). Unlike `.prop`, this can return a full nested
7003    // `Value` (a list/map field value), not just a scalar `PropertyValue`
7004    // -- `apply_index`'s return type already allows that, no narrowing
7005    // needed the way `map_value_as_property` has to for `.prop`.
7006    if let Value::Map(entries) = list {
7007        let Some(key) = as_arith_str(index) else {
7008            return Err(QueryError::Type(format!(
7009                "a map index must be a string, got {index:?}"
7010            )));
7011        };
7012        return Ok(entries.get(key).cloned().unwrap_or(Value::Null));
7013    }
7014    // `n['name']` -- dynamic property access on a node/relationship/
7015    // temporal value, same as `n.name`'s static form but with a computed
7016    // key (TCK's Graph7 `[1]`-`[3]`). Reuses `property_of_value` exactly
7017    // -- the only actual difference from `.prop` is where the key string
7018    // comes from.
7019    if matches!(list, Value::Node(_) | Value::Edge(_) | Value::Property(_)) {
7020        let Some(key) = as_arith_str(index) else {
7021            return Err(QueryError::Type(format!(
7022                "a property index must be a string, got {index:?}"
7023            )));
7024        };
7025        return property_of_value(list, key);
7026    }
7027    let Value::List(items) = list else {
7028        return Err(QueryError::Type(format!(
7029            "[] indexing needs a list or map, got {list:?}"
7030        )));
7031    };
7032    let Some(ArithNum::Int(i)) = as_arith_num(index) else {
7033        return Err(QueryError::Type(format!(
7034            "a list index must be an integer, got {index:?}"
7035        )));
7036    };
7037    let len = items.len() as i64;
7038    let i = if i < 0 { i + len } else { i };
7039    if i < 0 || i >= len {
7040        return Ok(Value::Null);
7041    }
7042    Ok(items[i as usize].clone())
7043}
7044
7045/// `list[start..end]` -- same negative-counts-from-end rule as
7046/// `apply_index`, but bounds clamp to `[0, len]` instead of nulling out
7047/// (`[1,2,3][-5..5]` is the whole list, not `null`), and a start at or
7048/// past the (clamped) end yields `[]` rather than erroring
7049/// (`[1,2,3][3..1]` is `[]`) -- both match real Cypher, and both were
7050/// real TCK scenarios, not guessed behavior.
7051fn apply_slice(
7052    list: &Value,
7053    start: Option<&Value>,
7054    end: Option<&Value>,
7055) -> Result<Value, QueryError> {
7056    if matches!(list, Value::Null) {
7057        return Ok(Value::Null);
7058    }
7059    let Value::List(items) = list else {
7060        return Err(QueryError::Type(format!(
7061            "[..] slicing needs a list, got {list:?}"
7062        )));
7063    };
7064    let len = items.len() as i64;
7065    let clamp = |i: i64| -> i64 {
7066        let i = if i < 0 { i + len } else { i };
7067        i.clamp(0, len)
7068    };
7069    let bound_index = |v: Option<&Value>, default: i64| -> Result<Option<i64>, QueryError> {
7070        match v {
7071            None => Ok(Some(default)),
7072            Some(Value::Null) => Ok(None),
7073            Some(other) => match as_arith_num(other) {
7074                Some(ArithNum::Int(i)) => Ok(Some(clamp(i))),
7075                _ => Err(QueryError::Type(format!(
7076                    "a slice bound must be an integer, got {other:?}"
7077                ))),
7078            },
7079        }
7080    };
7081    // A null bound (as opposed to an *omitted* one, already handled by
7082    // `start`/`end` being `None` at the AST level) propagates -- same
7083    // null-handling convention as every other operator here.
7084    let (Some(start_idx), Some(end_idx)) = (bound_index(start, 0)?, bound_index(end, len)?) else {
7085        return Ok(Value::Null);
7086    };
7087    if start_idx >= end_idx {
7088        return Ok(Value::List(Vec::new()));
7089    }
7090    Ok(Value::List(
7091        items[start_idx as usize..end_idx as usize].to_vec(),
7092    ))
7093}
7094
7095fn call_builtin(
7096    name: &str,
7097    args: &[Value],
7098    now: temporal::NowSnapshot,
7099) -> Result<Value, QueryError> {
7100    match name.to_ascii_lowercase().as_str() {
7101        "coalesce" => Ok(args
7102            .iter()
7103            .find(|v| !matches!(v, Value::Null))
7104            .cloned()
7105            .unwrap_or(Value::Null)),
7106        "tointeger" => match args.first() {
7107            Some(v) => to_integer(v),
7108            None => Ok(Value::Null),
7109        },
7110        "tostring" => match args.first() {
7111            Some(v) => to_string_value(v),
7112            None => Ok(Value::Null),
7113        },
7114        "date" => date_builtin(args, now),
7115        "date.transaction" | "date.statement" | "date.realtime" => Ok(now_or_null(args, || {
7116            Value::Property(PropertyValue::Date(now.epoch_day))
7117        })),
7118        "duration" => duration_builtin(args),
7119        "localtime" => local_time_builtin(args, now),
7120        "localtime.transaction" | "localtime.statement" | "localtime.realtime" => {
7121            Ok(now_or_null(args, || {
7122                Value::Property(PropertyValue::LocalTime(now.nanos_of_day))
7123            }))
7124        }
7125        "time" => time_builtin(args, now),
7126        "time.transaction" | "time.statement" | "time.realtime" => {
7127            // No-arg time() defaults to UTC offset (real Cypher's statement default timezone)
7128            Ok(now_or_null(args, || {
7129                Value::Property(PropertyValue::Time {
7130                    nanos_of_day: now.nanos_of_day,
7131                    offset_seconds: 0,
7132                })
7133            }))
7134        }
7135        "localdatetime" => local_date_time_builtin(args, now),
7136        "localdatetime.transaction" | "localdatetime.statement" | "localdatetime.realtime" => {
7137            Ok(now_or_null(args, || {
7138                Value::Property(PropertyValue::LocalDateTime {
7139                    epoch_seconds: now.epoch_seconds,
7140                    nanos: now.nanos,
7141                })
7142            }))
7143        }
7144        "datetime" => date_time_builtin(args, now),
7145        "datetime.transaction" | "datetime.statement" | "datetime.realtime" => {
7146            // No-arg datetime() defaults to UTC offset (real Cypher's statement default timezone)
7147            Ok(now_or_null(args, || {
7148                Value::Property(PropertyValue::DateTime {
7149                    epoch_seconds: now.epoch_seconds,
7150                    nanos: now.nanos,
7151                    zone: GraphTzId::Offset(0),
7152                })
7153            }))
7154        }
7155        "datetime.fromepoch" => {
7156            let seconds = require_int_arg(args.first(), "datetime.fromepoch")?;
7157            let nanos = require_int_arg(args.get(1), "datetime.fromepoch")?;
7158            Ok(Value::Property(PropertyValue::DateTime {
7159                epoch_seconds: seconds,
7160                nanos: nanos as i32,
7161                zone: GraphTzId::Offset(0),
7162            }))
7163        }
7164        "datetime.fromepochmillis" => {
7165            let millis = require_int_arg(args.first(), "datetime.fromepochmillis")?;
7166            Ok(Value::Property(PropertyValue::DateTime {
7167                epoch_seconds: millis.div_euclid(1000),
7168                nanos: (millis.rem_euclid(1000) * 1_000_000) as i32,
7169                zone: GraphTzId::Offset(0),
7170            }))
7171        }
7172        "duration.between" => {
7173            duration_between_builtin("duration.between", args, temporal::duration_between)
7174        }
7175        "duration.inmonths" => {
7176            duration_between_builtin("duration.inMonths", args, temporal::duration_in_months)
7177        }
7178        "duration.indays" => {
7179            duration_between_builtin("duration.inDays", args, temporal::duration_in_days)
7180        }
7181        "duration.inseconds" => {
7182            duration_between_builtin("duration.inSeconds", args, temporal::duration_in_seconds)
7183        }
7184        "date.truncate" => date_truncate_builtin(args),
7185        "localtime.truncate" => local_time_truncate_builtin(args),
7186        "time.truncate" => time_truncate_builtin(args),
7187        "localdatetime.truncate" => local_date_time_truncate_builtin(args),
7188        "datetime.truncate" => date_time_truncate_builtin(args),
7189        // The dominant real-world use of shortestPath() is measuring it
7190        // (degrees-of-separation queries), not returning/rendering the
7191        // raw path object — path elements alternate node/edge/.../node,
7192        // so edge count is (elements.len() - 1) / 2.
7193        "length" => Ok(match args.first() {
7194            Some(Value::Path(elems)) => {
7195                Value::Property(PropertyValue::Int(((elems.len().max(1) - 1) / 2) as i64))
7196            }
7197            Some(Value::Null) | None => Value::Null,
7198            Some(other) => {
7199                return Err(QueryError::Type(format!(
7200                    "length() expects a path, got {other:?}"
7201                )))
7202            }
7203        }),
7204        "keys" => keys_builtin(args.first()),
7205        "labels" => labels_builtin(args.first()),
7206        "type" => type_builtin(args.first()),
7207        "properties" => properties_builtin(args.first()),
7208        "id" => id_builtin(args.first()),
7209        "size" => size_builtin(args.first()),
7210        "nodes" => nodes_builtin(args.first()),
7211        "relationships" => relationships_builtin(args.first()),
7212        "head" => list_edge_builtin(args.first(), "head", |items| items.first().cloned()),
7213        "last" => list_edge_builtin(args.first(), "last", |items| items.last().cloned()),
7214        "tail" => match args.first() {
7215            Some(Value::List(items)) => Ok(Value::List(
7216                items.iter().skip(1).cloned().collect::<Vec<_>>(),
7217            )),
7218            Some(Value::Null) | None => Ok(Value::Null),
7219            Some(other) => Err(QueryError::Type(format!(
7220                "tail() expects a list, got {other:?}"
7221            ))),
7222        },
7223        "range" => range_builtin(args),
7224        "exists" => Ok(Value::Literal(Literal::Bool(!matches!(
7225            args.first(),
7226            None | Some(Value::Null)
7227        )))),
7228        "toupper" | "upper" => string_transform(args.first(), "toUpper", str::to_uppercase),
7229        "tolower" | "lower" => string_transform(args.first(), "toLower", str::to_lowercase),
7230        "trim" => string_transform(args.first(), "trim", |s| s.trim().to_string()),
7231        "ltrim" => string_transform(args.first(), "ltrim", |s| s.trim_start().to_string()),
7232        "rtrim" => string_transform(args.first(), "rtrim", |s| s.trim_end().to_string()),
7233        "reverse" => reverse_builtin(args.first()),
7234        "replace" => replace_builtin(args),
7235        "split" => split_builtin(args),
7236        "substring" => substring_builtin(args),
7237        "left" => left_right_builtin(args, true),
7238        "right" => left_right_builtin(args, false),
7239        "tofloat" => match args.first() {
7240            Some(v) => to_float(v),
7241            None => Ok(Value::Null),
7242        },
7243        "toboolean" => match args.first() {
7244            Some(v) => to_boolean(v),
7245            None => Ok(Value::Null),
7246        },
7247        "abs" => match args.first() {
7248            Some(Value::Property(PropertyValue::Int(i)))
7249            | Some(Value::Literal(Literal::Int(i))) => {
7250                Ok(Value::Property(PropertyValue::Int(i.abs())))
7251            }
7252            Some(Value::Null) | None => Ok(Value::Null),
7253            Some(other) => match value_as_f64(other) {
7254                Some(f) => Ok(Value::Property(PropertyValue::Float(f.abs()))),
7255                None => Err(QueryError::Type(format!(
7256                    "abs() expects a number, got {other:?}"
7257                ))),
7258            },
7259        },
7260        "ceil" => float_math_fn(args.first(), "ceil", f64::ceil),
7261        "floor" => float_math_fn(args.first(), "floor", f64::floor),
7262        "round" => float_math_fn(args.first(), "round", f64::round),
7263        "sqrt" => float_math_fn(args.first(), "sqrt", f64::sqrt),
7264        "sign" => match args.first() {
7265            Some(Value::Null) | None => Ok(Value::Null),
7266            Some(other) => match value_as_f64(other) {
7267                Some(f) => Ok(Value::Property(PropertyValue::Int(if f > 0.0 {
7268                    1
7269                } else if f < 0.0 {
7270                    -1
7271                } else {
7272                    0
7273                }))),
7274                None => Err(QueryError::Type(format!(
7275                    "sign() expects a number, got {other:?}"
7276                ))),
7277            },
7278        },
7279        "rand" => Ok(Value::Property(PropertyValue::Float(rand_f64()))),
7280        other => Err(QueryError::Semantic(format!("unknown function: {other}"))),
7281    }
7282}
7283
7284/// `rand()` -- a fresh pseudo-random `f64` in `[0, 1)` on every call (no
7285/// memoization like `now()`/`date()`'s `NowSnapshot` -- real Cypher's
7286/// `rand()` is independently random each time it's evaluated, even
7287/// multiple times in the same query). No external RNG crate: combines an
7288/// atomic per-process counter with `RandomState`'s own already-randomized
7289/// per-construction seed (the same source `HashMap`'s DoS-resistant
7290/// default hasher draws from), good enough for a general-purpose
7291/// `rand()` without pulling in a dependency for one function.
7292fn rand_f64() -> f64 {
7293    use std::collections::hash_map::RandomState;
7294    use std::hash::{BuildHasher, Hasher};
7295    use std::sync::atomic::{AtomicU64, Ordering};
7296    static COUNTER: AtomicU64 = AtomicU64::new(0);
7297    let mut hasher = RandomState::new().build_hasher();
7298    hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed));
7299    let bits = hasher.finish();
7300    (bits >> 11) as f64 / (1u64 << 53) as f64
7301}
7302
7303fn keys_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7304    Ok(match arg {
7305        Some(Value::Node(n)) => Value::List(
7306            n.props
7307                .keys()
7308                .map(|k| Value::Property(PropertyValue::String(k.clone())))
7309                .collect(),
7310        ),
7311        Some(Value::Edge(e)) => Value::List(
7312            e.props
7313                .keys()
7314                .map(|k| Value::Property(PropertyValue::String(k.clone())))
7315                .collect(),
7316        ),
7317        Some(Value::Map(m)) => Value::List(
7318            m.keys()
7319                .map(|k| Value::Property(PropertyValue::String(k.clone())))
7320                .collect(),
7321        ),
7322        Some(Value::Null) | None => Value::Null,
7323        Some(other) => {
7324            return Err(QueryError::Type(format!(
7325                "keys() expects a node, relationship, or map, got {other:?}"
7326            )))
7327        }
7328    })
7329}
7330
7331fn labels_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7332    Ok(match arg {
7333        Some(Value::Node(n)) => Value::List(
7334            n.labels
7335                .iter()
7336                .map(|l| Value::Property(PropertyValue::String(l.clone())))
7337                .collect(),
7338        ),
7339        Some(Value::Null) | None => Value::Null,
7340        Some(other) => {
7341            return Err(QueryError::Type(format!(
7342                "labels() expects a node, got {other:?}"
7343            )))
7344        }
7345    })
7346}
7347
7348fn type_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7349    Ok(match arg {
7350        Some(Value::Edge(e)) => Value::Property(PropertyValue::String(e.label.clone())),
7351        Some(Value::Null) | None => Value::Null,
7352        Some(other) => {
7353            return Err(QueryError::Type(format!(
7354                "type() expects a relationship, got {other:?}"
7355            )))
7356        }
7357    })
7358}
7359
7360fn properties_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7361    Ok(match arg {
7362        Some(Value::Node(n)) => Value::Map(
7363            n.props
7364                .iter()
7365                .map(|(k, v)| (k.clone(), property_value_to_value(v.clone())))
7366                .collect(),
7367        ),
7368        Some(Value::Edge(e)) => Value::Map(
7369            e.props
7370                .iter()
7371                .map(|(k, v)| (k.clone(), property_value_to_value(v.clone())))
7372                .collect(),
7373        ),
7374        Some(Value::Map(m)) => Value::Map(m.clone()),
7375        Some(Value::Null) | None => Value::Null,
7376        Some(other) => {
7377            return Err(QueryError::Type(format!(
7378                "properties() expects a node, relationship, or map, got {other:?}"
7379            )))
7380        }
7381    })
7382}
7383
7384fn id_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7385    Ok(match arg {
7386        Some(Value::Node(n)) => Value::Property(PropertyValue::Int(n.id.0 as i64)),
7387        Some(Value::Edge(e)) => Value::Property(PropertyValue::Int(e.id.0 as i64)),
7388        Some(Value::Null) | None => Value::Null,
7389        Some(other) => {
7390            return Err(QueryError::Type(format!(
7391                "id() expects a node or relationship, got {other:?}"
7392            )))
7393        }
7394    })
7395}
7396
7397fn size_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7398    Ok(match arg {
7399        Some(Value::List(items)) => Value::Property(PropertyValue::Int(items.len() as i64)),
7400        Some(Value::Null) | None => Value::Null,
7401        Some(other) => match as_arith_str(other) {
7402            Some(s) => Value::Property(PropertyValue::Int(s.chars().count() as i64)),
7403            None => {
7404                return Err(QueryError::Type(format!(
7405                    "size() expects a list or string, got {other:?}"
7406                )))
7407            }
7408        },
7409    })
7410}
7411
7412fn nodes_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7413    Ok(match arg {
7414        Some(Value::Path(elems)) => Value::List(
7415            elems
7416                .iter()
7417                .filter_map(|e| match e {
7418                    PathElem::Node(n) => Some(Value::Node(n.clone())),
7419                    PathElem::Edge(_) => None,
7420                })
7421                .collect(),
7422        ),
7423        Some(Value::Null) | None => Value::Null,
7424        Some(other) => {
7425            return Err(QueryError::Type(format!(
7426                "nodes() expects a path, got {other:?}"
7427            )))
7428        }
7429    })
7430}
7431
7432fn relationships_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7433    Ok(match arg {
7434        Some(Value::Path(elems)) => Value::List(
7435            elems
7436                .iter()
7437                .filter_map(|e| match e {
7438                    PathElem::Edge(e) => Some(Value::Edge(e.clone())),
7439                    PathElem::Node(_) => None,
7440                })
7441                .collect(),
7442        ),
7443        Some(Value::Null) | None => Value::Null,
7444        Some(other) => {
7445            return Err(QueryError::Type(format!(
7446                "relationships() expects a path, got {other:?}"
7447            )))
7448        }
7449    })
7450}
7451
7452/// Shared shape for `head()`/`last()` -- `[]` (an empty list) is `null`,
7453/// same as any other out-of-bounds list access in this codebase
7454/// (`apply_index`'s docs), not an error.
7455fn list_edge_builtin(
7456    arg: Option<&Value>,
7457    fn_name: &str,
7458    pick: impl Fn(&[Value]) -> Option<Value>,
7459) -> Result<Value, QueryError> {
7460    Ok(match arg {
7461        Some(Value::List(items)) => pick(items).unwrap_or(Value::Null),
7462        Some(Value::Null) | None => Value::Null,
7463        Some(other) => {
7464            return Err(QueryError::Type(format!(
7465                "{fn_name}() expects a list, got {other:?}"
7466            )))
7467        }
7468    })
7469}
7470
7471/// `range(start, end[, step])` -- both bounds inclusive (real Cypher's own
7472/// convention, unlike Rust's exclusive-end ranges), `step` defaults to 1
7473/// and may be negative for a descending range. A zero step has no
7474/// sensible iteration direction -- a real error, not an infinite/empty
7475/// silent result.
7476fn range_builtin(args: &[Value]) -> Result<Value, QueryError> {
7477    let int_arg = |v: &Value, which: &str| -> Result<i64, QueryError> {
7478        value_as_i64(v).ok_or_else(|| {
7479            QueryError::Type(format!("range()'s {which} must be an integer, got {v:?}"))
7480        })
7481    };
7482    let start = int_arg(
7483        args.first()
7484            .ok_or_else(|| QueryError::Semantic("range() requires at least 2 arguments".into()))?,
7485        "start",
7486    )?;
7487    let end = int_arg(
7488        args.get(1)
7489            .ok_or_else(|| QueryError::Semantic("range() requires at least 2 arguments".into()))?,
7490        "end",
7491    )?;
7492    let step = match args.get(2) {
7493        Some(v) => int_arg(v, "step")?,
7494        None => 1,
7495    };
7496    if step == 0 {
7497        return Err(QueryError::Type("range()'s step can't be 0".into()));
7498    }
7499    let mut out = Vec::new();
7500    let mut i = start;
7501    if step > 0 {
7502        while i <= end {
7503            out.push(Value::Property(PropertyValue::Int(i)));
7504            i += step;
7505        }
7506    } else {
7507        while i >= end {
7508            out.push(Value::Property(PropertyValue::Int(i)));
7509            i += step;
7510        }
7511    }
7512    Ok(Value::List(out))
7513}
7514
7515fn string_transform(
7516    arg: Option<&Value>,
7517    fn_name: &str,
7518    f: impl FnOnce(&str) -> String,
7519) -> Result<Value, QueryError> {
7520    Ok(match arg {
7521        Some(Value::Null) | None => Value::Null,
7522        Some(other) => match as_arith_str(other) {
7523            Some(s) => Value::Property(PropertyValue::String(f(s))),
7524            None => {
7525                return Err(QueryError::Type(format!(
7526                    "{fn_name}() expects a string, got {other:?}"
7527                )))
7528            }
7529        },
7530    })
7531}
7532
7533fn reverse_builtin(arg: Option<&Value>) -> Result<Value, QueryError> {
7534    Ok(match arg {
7535        Some(Value::Null) | None => Value::Null,
7536        Some(Value::List(items)) => Value::List(items.iter().rev().cloned().collect()),
7537        Some(other) => match as_arith_str(other) {
7538            Some(s) => Value::Property(PropertyValue::String(s.chars().rev().collect())),
7539            None => {
7540                return Err(QueryError::Type(format!(
7541                    "reverse() expects a string or list, got {other:?}"
7542                )))
7543            }
7544        },
7545    })
7546}
7547
7548/// A closure can't express `str_arg`'s independent-lifetimes signature
7549/// (the returned `&str` borrows from `v`, not `which`) the way a real
7550/// `fn` item can -- see `replace_builtin`'s only caller of this.
7551fn replace_str_arg<'a>(v: &'a Value, which: &str) -> Result<&'a str, QueryError> {
7552    as_arith_str(v)
7553        .ok_or_else(|| QueryError::Type(format!("replace()'s {which} must be a string, got {v:?}")))
7554}
7555
7556fn replace_builtin(args: &[Value]) -> Result<Value, QueryError> {
7557    if args.iter().any(|v| matches!(v, Value::Null)) {
7558        return Ok(Value::Null);
7559    }
7560    let original = replace_str_arg(
7561        args.first()
7562            .ok_or_else(|| QueryError::Semantic("replace() requires 3 arguments".into()))?,
7563        "original",
7564    )?;
7565    let search = replace_str_arg(
7566        args.get(1)
7567            .ok_or_else(|| QueryError::Semantic("replace() requires 3 arguments".into()))?,
7568        "search",
7569    )?;
7570    let replacement = replace_str_arg(
7571        args.get(2)
7572            .ok_or_else(|| QueryError::Semantic("replace() requires 3 arguments".into()))?,
7573        "replacement",
7574    )?;
7575    Ok(Value::Property(PropertyValue::String(
7576        original.replace(search, replacement),
7577    )))
7578}
7579
7580fn split_builtin(args: &[Value]) -> Result<Value, QueryError> {
7581    if args.iter().any(|v| matches!(v, Value::Null)) {
7582        return Ok(Value::Null);
7583    }
7584    let s = args
7585        .first()
7586        .and_then(as_arith_str)
7587        .ok_or_else(|| QueryError::Type("split()'s first argument must be a string".into()))?;
7588    let delim = args
7589        .get(1)
7590        .and_then(as_arith_str)
7591        .ok_or_else(|| QueryError::Type("split()'s second argument must be a string".into()))?;
7592    let parts = if delim.is_empty() {
7593        s.split("").filter(|p| !p.is_empty()).collect::<Vec<_>>()
7594    } else {
7595        s.split(delim).collect::<Vec<_>>()
7596    };
7597    Ok(Value::List(
7598        parts
7599            .into_iter()
7600            .map(|p| Value::Property(PropertyValue::String(p.to_string())))
7601            .collect(),
7602    ))
7603}
7604
7605/// `substring(s, start[, length])` -- 0-indexed, both `start` and
7606/// `length` clamp to the string's bounds rather than erroring (matches
7607/// real Cypher: an out-of-range `substring` call is well-defined, not a
7608/// failure). Indexes by Unicode scalar (`char`), not byte offset, so a
7609/// multi-byte character never gets split.
7610fn substring_builtin(args: &[Value]) -> Result<Value, QueryError> {
7611    if matches!(args.first(), Some(Value::Null)) {
7612        return Ok(Value::Null);
7613    }
7614    let s = args
7615        .first()
7616        .and_then(as_arith_str)
7617        .ok_or_else(|| QueryError::Type("substring()'s first argument must be a string".into()))?;
7618    let chars: Vec<char> = s.chars().collect();
7619    let start = args
7620        .get(1)
7621        .and_then(value_as_i64)
7622        .ok_or_else(|| QueryError::Type("substring()'s start must be an integer".into()))?
7623        .max(0) as usize;
7624    let start = start.min(chars.len());
7625    let end = match args.get(2) {
7626        Some(v) => {
7627            let len = value_as_i64(v)
7628                .ok_or_else(|| QueryError::Type("substring()'s length must be an integer".into()))?
7629                .max(0) as usize;
7630            (start + len).min(chars.len())
7631        }
7632        None => chars.len(),
7633    };
7634    Ok(Value::Property(PropertyValue::String(
7635        chars[start..end].iter().collect(),
7636    )))
7637}
7638
7639/// `left(s, n)`/`right(s, n)` -- the first/last `n` characters, clamped
7640/// to the string's length rather than erroring on an over-long `n`.
7641fn left_right_builtin(args: &[Value], from_left: bool) -> Result<Value, QueryError> {
7642    if matches!(args.first(), Some(Value::Null)) {
7643        return Ok(Value::Null);
7644    }
7645    let fn_name = if from_left { "left" } else { "right" };
7646    let s = args.first().and_then(as_arith_str).ok_or_else(|| {
7647        QueryError::Type(format!("{fn_name}()'s first argument must be a string"))
7648    })?;
7649    let n = args
7650        .get(1)
7651        .and_then(value_as_i64)
7652        .ok_or_else(|| {
7653            QueryError::Type(format!("{fn_name}()'s second argument must be an integer"))
7654        })?
7655        .max(0) as usize;
7656    let chars: Vec<char> = s.chars().collect();
7657    let n = n.min(chars.len());
7658    let slice = if from_left {
7659        &chars[..n]
7660    } else {
7661        &chars[chars.len() - n..]
7662    };
7663    Ok(Value::Property(PropertyValue::String(
7664        slice.iter().collect(),
7665    )))
7666}
7667
7668fn float_math_fn(
7669    arg: Option<&Value>,
7670    fn_name: &str,
7671    f: impl FnOnce(f64) -> f64,
7672) -> Result<Value, QueryError> {
7673    Ok(match arg {
7674        Some(Value::Null) | None => Value::Null,
7675        Some(other) => match value_as_f64(other) {
7676            Some(x) => Value::Property(PropertyValue::Float(f(x))),
7677            None => {
7678                return Err(QueryError::Type(format!(
7679                    "{fn_name}() expects a number, got {other:?}"
7680                )))
7681            }
7682        },
7683    })
7684}
7685
7686fn to_float(v: &Value) -> Result<Value, QueryError> {
7687    Ok(match v {
7688        Value::Property(PropertyValue::Int(i)) => Value::Property(PropertyValue::Float(*i as f64)),
7689        Value::Property(PropertyValue::Float(f)) => Value::Property(PropertyValue::Float(*f)),
7690        Value::Literal(Literal::Int(i)) => Value::Property(PropertyValue::Float(*i as f64)),
7691        Value::Literal(Literal::Float(f)) => Value::Property(PropertyValue::Float(*f)),
7692        Value::Property(PropertyValue::String(s)) | Value::Literal(Literal::String(s)) => {
7693            match s.trim().parse::<f64>() {
7694                Ok(f) => Value::Property(PropertyValue::Float(f)),
7695                Err(_) => Value::Null,
7696            }
7697        }
7698        Value::Property(PropertyValue::Null) | Value::Literal(Literal::Null) | Value::Null => {
7699            Value::Null
7700        }
7701        Value::Literal(Literal::Param(name)) => {
7702            unreachable!("param ${name} must be substituted before execution — see params::substitute_params")
7703        }
7704        // `Bool` is a real, deliberate type error, not `null` -- unlike
7705        // an unparseable *string*, which real Cypher does treat as
7706        // `null` (a string always at least plausibly *could* be numeric
7707        // text), a boolean never could be (TCK's TypeConversion3 [6]).
7708        other => {
7709            return Err(QueryError::Type(format!(
7710                "toFloat() cannot convert {other:?} to a float"
7711            )))
7712        }
7713    })
7714}
7715
7716fn to_boolean(v: &Value) -> Result<Value, QueryError> {
7717    Ok(match v {
7718        Value::Property(PropertyValue::Bool(b)) | Value::Literal(Literal::Bool(b)) => {
7719            Value::Literal(Literal::Bool(*b))
7720        }
7721        Value::Property(PropertyValue::String(s)) | Value::Literal(Literal::String(s)) => {
7722            match s.trim().to_ascii_lowercase().as_str() {
7723                "true" => Value::Literal(Literal::Bool(true)),
7724                "false" => Value::Literal(Literal::Bool(false)),
7725                _ => Value::Null,
7726            }
7727        }
7728        Value::Property(PropertyValue::Null) | Value::Literal(Literal::Null) | Value::Null => {
7729            Value::Null
7730        }
7731        Value::Literal(Literal::Param(name)) => {
7732            unreachable!("param ${name} must be substituted before execution — see params::substitute_params")
7733        }
7734        other => {
7735            return Err(QueryError::Type(format!(
7736                "toBoolean() cannot convert {other:?} to a boolean"
7737            )))
7738        }
7739    })
7740}
7741
7742/// A quantifier's own per-element truthiness check when it has no `WHERE`
7743/// at all (`ANY(x IN list)`, not `ANY(x IN list WHERE ...)`) -- three-
7744/// valued, same as a real `WHERE` predicate: `null` propagates as
7745/// "unknown" (`None`), a literal bool passes through, anything else
7746/// (non-bool, non-null) is definitely-false, same convention `CASE`'s
7747/// subject-less `WHEN` branch already uses for a non-bool test value.
7748fn item_truthy(v: &Value) -> Option<bool> {
7749    match v {
7750        Value::Null => None,
7751        Value::Literal(Literal::Bool(b)) | Value::Property(PropertyValue::Bool(b)) => Some(*b),
7752        _ => Some(false),
7753    }
7754}
7755
7756/// Real Cypher quantifiers use three-valued logic, not a simple count --
7757/// a single definite `true`/`false` among the elements can already decide
7758/// the answer even in the presence of other `null` elements, and only
7759/// "no definite answer, but at least one unknown" actually yields `null`.
7760/// Confirmed against the real TCK scenarios (Quantifier1-4, scenario 10,
7761/// "... on lists containing nulls") rather than assumed -- a first version
7762/// of this collapsed `null` predicates to `false`, which silently passed
7763/// every non-null-list scenario but produced 19 real wrong answers on
7764/// exactly these null-list cases.
7765fn eval_quantifier(kind: QuantifierKind, preds: &[Option<bool>]) -> Option<bool> {
7766    let true_count = preds.iter().filter(|p| **p == Some(true)).count();
7767    let any_false = preds.contains(&Some(false));
7768    let any_null = preds.iter().any(|p| p.is_none());
7769    match kind {
7770        QuantifierKind::Any => {
7771            if true_count > 0 {
7772                Some(true)
7773            } else if any_null {
7774                None
7775            } else {
7776                Some(false)
7777            }
7778        }
7779        QuantifierKind::None => {
7780            if true_count > 0 {
7781                Some(false)
7782            } else if any_null {
7783                None
7784            } else {
7785                Some(true)
7786            }
7787        }
7788        QuantifierKind::All => {
7789            if any_false {
7790                Some(false)
7791            } else if any_null {
7792                None
7793            } else {
7794                Some(true)
7795            }
7796        }
7797        QuantifierKind::Single => {
7798            if true_count >= 2 {
7799                Some(false)
7800            } else if any_null {
7801                None
7802            } else {
7803                Some(true_count == 1)
7804            }
7805        }
7806    }
7807}
7808
7809fn to_integer(v: &Value) -> Result<Value, QueryError> {
7810    // A float-formatted string ('1.7', '2.9') isn't an i64, but real
7811    // Cypher's toInteger() still accepts it -- parse as a float and
7812    // truncate, same as the Float arm below, rather than failing straight
7813    // to null the way a bare `i64::parse` would (found via a real TCK
7814    // scenario: `toInteger('1.7')` must be `1`, not `null`).
7815    let as_str_parse = |s: &str| match s.trim().parse::<i64>() {
7816        Ok(i) => Value::Property(PropertyValue::Int(i)),
7817        Err(_) => match s.trim().parse::<f64>() {
7818            Ok(f) => Value::Property(PropertyValue::Int(f as i64)),
7819            Err(_) => Value::Null,
7820        },
7821    };
7822    Ok(match v {
7823        Value::Property(PropertyValue::Int(i)) => Value::Property(PropertyValue::Int(*i)),
7824        Value::Property(PropertyValue::Float(f)) => Value::Property(PropertyValue::Int(*f as i64)),
7825        Value::Property(PropertyValue::String(s)) => as_str_parse(s),
7826        Value::Literal(Literal::Int(i)) => Value::Property(PropertyValue::Int(*i)),
7827        Value::Literal(Literal::Float(f)) => Value::Property(PropertyValue::Int(*f as i64)),
7828        Value::Literal(Literal::String(s)) => as_str_parse(s),
7829        Value::Property(PropertyValue::Bool(_) | PropertyValue::Null)
7830        | Value::Literal(Literal::Bool(_) | Literal::Null)
7831        | Value::Null => Value::Null,
7832        Value::Literal(Literal::Param(name)) => {
7833            unreachable!("param ${name} must be substituted before execution — see params::substitute_params")
7834        }
7835        // A node/edge/list/map/path has no numeric conversion at all -- a
7836        // real error (found via a real TCK scenario expecting exactly
7837        // this), not a silent null the way an out-of-range/unparseable
7838        // scalar is.
7839        Value::Property(
7840            PropertyValue::Date(_)
7841            | PropertyValue::Duration { .. }
7842            | PropertyValue::LocalTime(_)
7843            | PropertyValue::Time { .. }
7844            | PropertyValue::LocalDateTime { .. }
7845            | PropertyValue::DateTime { .. }
7846            | PropertyValue::List(_)
7847            | PropertyValue::Map(_),
7848        )
7849        | Value::Node(_)
7850        | Value::Edge(_)
7851        | Value::List(_)
7852        | Value::Map(_)
7853        | Value::Path(_) => {
7854            return Err(QueryError::Type(format!(
7855                "toInteger() cannot convert {v:?} to an integer"
7856            )))
7857        }
7858    })
7859}
7860
7861/// `toString(...)` — Int/Float/Bool render the same as their `Display`
7862/// impl already does elsewhere (`marsdb-cli`'s `format_property`/
7863/// `format_literal`); `Date`/`Duration` go through `temporal::format_*`.
7864/// Null propagates, while graph, collection, map, and path values are a
7865/// runtime type error rather than silently becoming null (TypeConversion4
7866/// scenario [10]).
7867fn to_string_value(v: &Value) -> Result<Value, QueryError> {
7868    let s = match v {
7869        Value::Property(PropertyValue::String(s)) | Value::Literal(Literal::String(s)) => s.clone(),
7870        Value::Property(PropertyValue::Int(i)) | Value::Literal(Literal::Int(i)) => i.to_string(),
7871        Value::Property(PropertyValue::Float(f)) | Value::Literal(Literal::Float(f)) => {
7872            f.to_string()
7873        }
7874        Value::Property(PropertyValue::Bool(b)) | Value::Literal(Literal::Bool(b)) => b.to_string(),
7875        Value::Property(PropertyValue::Date(d)) => temporal::format_date(*d),
7876        Value::Property(PropertyValue::Duration {
7877            months,
7878            days,
7879            seconds,
7880            nanos,
7881        }) => temporal::format_duration(*months, *days, *seconds, *nanos),
7882        Value::Property(PropertyValue::LocalTime(nanos_of_day)) => {
7883            temporal::format_local_time(*nanos_of_day)
7884        }
7885        Value::Property(PropertyValue::Time {
7886            nanos_of_day,
7887            offset_seconds,
7888        }) => temporal::format_time(*nanos_of_day, *offset_seconds),
7889        Value::Property(PropertyValue::LocalDateTime {
7890            epoch_seconds,
7891            nanos,
7892        }) => temporal::format_local_date_time(*epoch_seconds, *nanos),
7893        Value::Property(PropertyValue::DateTime {
7894            epoch_seconds,
7895            nanos,
7896            zone,
7897        }) => temporal::format_date_time(*epoch_seconds, *nanos, &tz_from_graph(zone)),
7898        Value::Property(PropertyValue::Null) | Value::Literal(Literal::Null) | Value::Null => {
7899            return Ok(Value::Null);
7900        }
7901        Value::Literal(Literal::Param(name)) => {
7902            unreachable!("param ${name} must be substituted before execution — see params::substitute_params")
7903        }
7904        Value::Property(PropertyValue::List(_) | PropertyValue::Map(_))
7905        | Value::Node(_)
7906        | Value::Edge(_)
7907        | Value::List(_)
7908        | Value::Map(_)
7909        | Value::Path(_) => {
7910            return Err(QueryError::Type(format!(
7911                "toString() cannot convert {v:?} to a string"
7912            )))
7913        }
7914    };
7915    Ok(Value::Property(PropertyValue::String(s)))
7916}
7917
7918/// `date()` — zero args (today, UTC, from the `Executor`-cached
7919/// `temporal::NowSnapshot` — see its docs for why every no-arg temporal
7920/// call within one query shares the same captured instant), a string
7921/// (`date('2015-07-21')`, the calendar forms `temporal::
7922/// parse_date` supports), a map (`date({year: 1984, month: 10, day:
7923/// 11})`, calendar construction only), or another `Date` (identity —
7924/// `date(d)` where `d` is already a `Date`, e.g. from `toString`
7925/// round-tripping through `date(toString(d))`). Deliberately does *not*
7926/// support the week-date/ordinal-date/quarter map or string construction
7927/// forms real Cypher also has (`date({year: 2015, week: 1})`,
7928/// `date('2015-W30-2')`, ...) — a real, documented gap (see the README),
7929/// not a silent wrong answer: both `parse_date` and `date_from_map`
7930/// return a clear error/`None` for those rather than guessing.
7931/// `date.transaction()`/`.statement()`/`.realtime()` and their siblings
7932/// for the other 4 temporal types conceptually take no argument (they
7933/// always return the current transaction/statement/realtime instant) --
7934/// but real Cypher still requires them to propagate a `null` argument
7935/// (TCK's Temporal4 [13] "Should propagate null"), same as every other
7936/// temporal constructor. Found via the TCK: pest's own grammar couldn't
7937/// parse these namespaced calls with an argument at all, so this always-
7938/// ignore-args behavior was untested until ANTLR's grammar (which does
7939/// support it) newly exposed it as a silent wrong answer instead of null.
7940fn now_or_null(args: &[Value], now_value: impl FnOnce() -> Value) -> Value {
7941    if matches!(args.first(), Some(Value::Null)) {
7942        Value::Null
7943    } else {
7944        now_value()
7945    }
7946}
7947
7948fn date_builtin(args: &[Value], now: temporal::NowSnapshot) -> Result<Value, QueryError> {
7949    if args.len() > 1 {
7950        return Err(QueryError::Semantic(format!(
7951            "date() expects zero or one argument, got {}",
7952            args.len()
7953        )));
7954    }
7955    let Some(arg) = args.first() else {
7956        return Ok(Value::Property(PropertyValue::Date(now.epoch_day)));
7957    };
7958    if matches!(arg, Value::Null) {
7959        return Ok(Value::Null);
7960    }
7961    if let Value::Property(PropertyValue::Date(d)) = arg {
7962        return Ok(Value::Property(PropertyValue::Date(*d)));
7963    }
7964    // `date(otherTemporal)` -- a bare `LocalDateTime`/`DateTime` argument
7965    // projects its own date part, same as `date({date: otherTemporal})`
7966    // (TCK's Temporal3 [1]).
7967    if matches!(
7968        arg,
7969        Value::Property(PropertyValue::LocalDateTime { .. } | PropertyValue::DateTime { .. })
7970    ) {
7971        let epoch_day = extract_date_base_epoch_day("date() argument", arg)?;
7972        return Ok(Value::Property(PropertyValue::Date(epoch_day)));
7973    }
7974    if let Some(s) = as_arith_str(arg) {
7975        let d = temporal::parse_date(s).ok_or_else(|| {
7976            QueryError::Type(format!(
7977                "'{s}' isn't a date string MarsDB can parse -- only the calendar forms YYYY-MM-DD/YYYYMMDD/\
7978                 YYYY-MM/YYYYMM/YYYY, week-date forms YYYY-Www[-D]/YYYYWww[D], and ordinal-date forms \
7979                 YYYY-DDD/YYYYDDD are supported"
7980            ))
7981        })?;
7982        return Ok(Value::Property(PropertyValue::Date(d)));
7983    }
7984    if let Value::Map(m) = arg {
7985        return Ok(Value::Property(PropertyValue::Date(date_from_map(m)?)));
7986    }
7987    Err(QueryError::Type(format!(
7988        "date() doesn't support this argument: {arg:?}"
7989    )))
7990}
7991
7992/// Pulls the local (offset-adjusted for `DateTime`) epoch-day out of a
7993/// `Date`/`LocalDateTime`/`DateTime` value -- the "base" a `date`/
7994/// `datetime` map key projects its calendar fields from
7995/// (`date({date: other, day: 5})`, `localdatetime({date: other, hour:
7996/// 10, ...})`, ...). Returns the raw epoch-day (not a pre-split
7997/// `(year, month, day)`) so a caller can read *any* calendar component
7998/// off it (`weekYear`/`week`/`dayOfWeek`/`quarter`/`dayOfQuarter`/
7999/// `ordinalDay` via `date_component`), for defaulting the alternate
8000/// week/ordinal/quarter-date map-construction forms (see
8001/// `calendar_fields_from_map`).
8002fn extract_date_base_epoch_day(key: &str, v: &Value) -> Result<i32, QueryError> {
8003    match v {
8004        Value::Property(PropertyValue::Date(d)) => Ok(*d),
8005        Value::Property(PropertyValue::LocalDateTime { epoch_seconds, .. }) => {
8006            Ok(temporal::split_epoch_seconds(*epoch_seconds).0)
8007        }
8008        Value::Property(PropertyValue::DateTime {
8009            epoch_seconds,
8010            zone,
8011            ..
8012        }) => {
8013            let offset_seconds = temporal::resolve_offset(&tz_from_graph(zone), *epoch_seconds);
8014            Ok(temporal::split_epoch_seconds(epoch_seconds + offset_seconds as i64).0)
8015        }
8016        other => Err(QueryError::Type(format!(
8017            "'{key}' must be a Date, LocalDateTime, or DateTime, got {other:?}"
8018        ))),
8019    }
8020}
8021
8022/// `(hour, minute, second, nanos, zone)` pulled out of a `LocalTime`/
8023/// `Time`/`LocalDateTime`/`DateTime` value -- the "base" a `time`/
8024/// `datetime` map key projects its clock fields from. `nanos` here is
8025/// just the nanosecond-of-second remainder (not the whole nanos-of-day),
8026/// matching the map constructors' own `nanosecond` field. `zone` is
8027/// `Some((original_zone, resolved_offset_seconds))` only for `Time`/
8028/// `DateTime` sources -- both are kept, not just the resolved number, so
8029/// a caller that projects this base *without* an explicit `timezone`
8030/// override (`{time: t}`, `{datetime: dt}`) can preserve the source's
8031/// own zone *identity* (a `Named` zone stays `Named`, TCK's Temporal3
8032/// [9]/[11] `{datetime: other}` rows), while a caller that only ever
8033/// needs a plain number (`time_builtin`'s cross-type conversion, `TIME`
8034/// structurally can't hold a name) uses the resolved half directly.
8035type ClockBase = (i64, i64, i64, i64, Option<(temporal::TzId, i32)>);
8036
8037fn extract_time_base(key: &str, v: &Value) -> Result<ClockBase, QueryError> {
8038    let hms_nanos = |nanos_of_day: i64| {
8039        (
8040            temporal::local_time_component(nanos_of_day, "hour").unwrap(),
8041            temporal::local_time_component(nanos_of_day, "minute").unwrap(),
8042            temporal::local_time_component(nanos_of_day, "second").unwrap(),
8043            temporal::local_time_component(nanos_of_day, "nanosecond").unwrap(),
8044        )
8045    };
8046    match v {
8047        Value::Property(PropertyValue::LocalTime(n)) => {
8048            let (h, m, s, ns) = hms_nanos(*n);
8049            Ok((h, m, s, ns, None))
8050        }
8051        Value::Property(PropertyValue::Time {
8052            nanos_of_day,
8053            offset_seconds,
8054        }) => {
8055            let (h, m, s, ns) = hms_nanos(*nanos_of_day);
8056            Ok((
8057                h,
8058                m,
8059                s,
8060                ns,
8061                Some((temporal::TzId::Offset(*offset_seconds), *offset_seconds)),
8062            ))
8063        }
8064        Value::Property(PropertyValue::LocalDateTime {
8065            epoch_seconds,
8066            nanos,
8067        }) => {
8068            let (_, nanos_of_day) = temporal::split_epoch_seconds(*epoch_seconds);
8069            let (h, m, s, _) = hms_nanos(nanos_of_day);
8070            Ok((h, m, s, *nanos as i64, None))
8071        }
8072        Value::Property(PropertyValue::DateTime {
8073            epoch_seconds,
8074            nanos,
8075            zone,
8076        }) => {
8077            let tz = tz_from_graph(zone);
8078            let offset_seconds = temporal::resolve_offset(&tz, *epoch_seconds);
8079            let local = epoch_seconds + offset_seconds as i64;
8080            let (_, nanos_of_day) = temporal::split_epoch_seconds(local);
8081            let (h, m, s, _) = hms_nanos(nanos_of_day);
8082            Ok((h, m, s, *nanos as i64, Some((tz, offset_seconds))))
8083        }
8084        other => Err(QueryError::Type(format!(
8085            "'{key}' must be a LocalTime, Time, LocalDateTime, or DateTime, got {other:?}"
8086        ))),
8087    }
8088}
8089
8090const DATE_ALLOWED_KEYS: &[&str] = &[
8091    "year",
8092    "month",
8093    "day",
8094    "week",
8095    "dayOfWeek",
8096    "ordinalDay",
8097    "quarter",
8098    "dayOfQuarter",
8099    "date",
8100];
8101
8102fn date_from_map(m: &BTreeMap<String, Value>) -> Result<i32, QueryError> {
8103    let (year, month, day) = calendar_fields_from_map("date", m, DATE_ALLOWED_KEYS)?;
8104    temporal::epoch_day_from_ymd(year, month, day).ok_or_else(|| {
8105        QueryError::Type(format!(
8106            "{year:04}-{month:02}-{day:02} isn't a valid calendar date"
8107        ))
8108    })
8109}
8110
8111/// Computes `(year, month, day)` from a map that specifies one of four
8112/// mutually exclusive ways to pin a calendar day -- the plain calendar
8113/// form (`year`/`month`/`day`, each optionally defaulted from a `date`/
8114/// `datetime` base's own value), ISO week-date (`week`/`dayOfWeek`,
8115/// defaulted from the base's `weekYear`/`week`/`dayOfWeek`), ordinal-date
8116/// (`ordinalDay`, year defaulted from the base's `year`), or quarter-date
8117/// (`quarter`/`dayOfQuarter`, defaulted from the base's `quarter`/
8118/// `dayOfQuarter`) -- real Cypher's four alternate ways to construct a
8119/// date, all reducible to the same `(year, month, day)` triple
8120/// `epoch_day_from_ymd` needs. Shared by `date()`'s own map form and
8121/// `localdatetime()`/`datetime()`'s map forms (`allowed` differs only in
8122/// whether clock/timezone keys are also permitted in the same map -- this
8123/// function only ever looks at the date-shaped keys).
8124fn calendar_fields_from_map(
8125    caller: &str,
8126    m: &BTreeMap<String, Value>,
8127    allowed: &[&str],
8128) -> Result<(i32, u32, u32), QueryError> {
8129    if let Some(bad) = m.keys().find(|k| !allowed.contains(&k.as_str())) {
8130        return Err(QueryError::Type(format!(
8131            "{caller}({{...}}) key '{bad}' isn't a recognized field"
8132        )));
8133    }
8134    let int_field = |key: &str, value: &Value| {
8135        value_as_i64(value).ok_or_else(|| {
8136            QueryError::Type(format!("{caller}({{...}})'s '{key}' must be an integer"))
8137        })
8138    };
8139    let base_epoch_day = m
8140        .get("date")
8141        .map(|v| ("date", v))
8142        .or_else(|| m.get("datetime").map(|v| ("datetime", v)))
8143        .map(|(k, v)| extract_date_base_epoch_day(k, v))
8144        .transpose()?;
8145    let epoch_day_from_component =
8146        |prop: &str| base_epoch_day.map(|ed| temporal::date_component(ed, prop).unwrap());
8147
8148    if m.contains_key("week") || m.contains_key("dayOfWeek") {
8149        let week_year = match m.get("year") {
8150            Some(v) => i32::try_from(int_field("year", v)?).map_err(|_| {
8151                QueryError::Type(format!("{caller}({{...}})'s 'year' is out of range"))
8152            })?,
8153            None => i32::try_from(epoch_day_from_component("weekYear").ok_or_else(|| {
8154                QueryError::Type(format!("{caller}({{...}}) requires a 'year' key"))
8155            })?)
8156            .unwrap(),
8157        };
8158        let week = match m.get("week") {
8159            Some(v) => u32::try_from(int_field("week", v)?).map_err(|_| {
8160                QueryError::Type(format!("{caller}({{...}})'s 'week' is out of range"))
8161            })?,
8162            None => u32::try_from(epoch_day_from_component("week").ok_or_else(|| {
8163                QueryError::Type(format!("{caller}({{...}}) requires a 'week' key"))
8164            })?)
8165            .unwrap(),
8166        };
8167        let day_of_week = match m.get("dayOfWeek") {
8168            Some(v) => int_field("dayOfWeek", v)?,
8169            None => epoch_day_from_component("dayOfWeek").unwrap_or(1),
8170        };
8171        let epoch_day = temporal::epoch_day_from_week_fields(week_year, week, day_of_week)
8172            .ok_or_else(|| {
8173                QueryError::Type(format!(
8174                    "{caller}({{...}}) has an out-of-range week-date field"
8175                ))
8176            })?;
8177        return Ok((
8178            temporal::date_component(epoch_day, "year").unwrap() as i32,
8179            temporal::date_component(epoch_day, "month").unwrap() as u32,
8180            temporal::date_component(epoch_day, "day").unwrap() as u32,
8181        ));
8182    }
8183
8184    if m.contains_key("ordinalDay") {
8185        let year = match m.get("year") {
8186            Some(v) => i32::try_from(int_field("year", v)?).map_err(|_| {
8187                QueryError::Type(format!("{caller}({{...}})'s 'year' is out of range"))
8188            })?,
8189            None => i32::try_from(epoch_day_from_component("year").ok_or_else(|| {
8190                QueryError::Type(format!("{caller}({{...}}) requires a 'year' key"))
8191            })?)
8192            .unwrap(),
8193        };
8194        let ordinal_raw = int_field("ordinalDay", m.get("ordinalDay").unwrap())?;
8195        let ordinal_day = u32::try_from(ordinal_raw).map_err(|_| {
8196            QueryError::Type(format!("{caller}({{...}})'s 'ordinalDay' is out of range"))
8197        })?;
8198        let epoch_day =
8199            temporal::epoch_day_from_ordinal_fields(year, ordinal_day).ok_or_else(|| {
8200                QueryError::Type(format!(
8201                    "{caller}({{...}}) has an out-of-range ordinalDay field"
8202                ))
8203            })?;
8204        return Ok((
8205            year,
8206            temporal::date_component(epoch_day, "month").unwrap() as u32,
8207            temporal::date_component(epoch_day, "day").unwrap() as u32,
8208        ));
8209    }
8210
8211    if m.contains_key("quarter") || m.contains_key("dayOfQuarter") {
8212        let year = match m.get("year") {
8213            Some(v) => i32::try_from(int_field("year", v)?).map_err(|_| {
8214                QueryError::Type(format!("{caller}({{...}})'s 'year' is out of range"))
8215            })?,
8216            None => i32::try_from(epoch_day_from_component("year").ok_or_else(|| {
8217                QueryError::Type(format!("{caller}({{...}}) requires a 'year' key"))
8218            })?)
8219            .unwrap(),
8220        };
8221        let quarter = match m.get("quarter") {
8222            Some(v) => u32::try_from(int_field("quarter", v)?).map_err(|_| {
8223                QueryError::Type(format!("{caller}({{...}})'s 'quarter' is out of range"))
8224            })?,
8225            None => u32::try_from(epoch_day_from_component("quarter").ok_or_else(|| {
8226                QueryError::Type(format!("{caller}({{...}}) requires a 'quarter' key"))
8227            })?)
8228            .unwrap(),
8229        };
8230        let day_of_quarter = match m.get("dayOfQuarter") {
8231            Some(v) => int_field("dayOfQuarter", v)?,
8232            None => epoch_day_from_component("dayOfQuarter").unwrap_or(1),
8233        };
8234        let epoch_day = temporal::epoch_day_from_quarter_fields(year, quarter, day_of_quarter)
8235            .ok_or_else(|| {
8236                QueryError::Type(format!(
8237                    "{caller}({{...}}) has an out-of-range quarter-date field"
8238                ))
8239            })?;
8240        return Ok((
8241            year,
8242            temporal::date_component(epoch_day, "month").unwrap() as u32,
8243            temporal::date_component(epoch_day, "day").unwrap() as u32,
8244        ));
8245    }
8246
8247    let year_raw = match m.get("year") {
8248        Some(v) => int_field("year", v)?,
8249        None => epoch_day_from_component("year")
8250            .ok_or_else(|| QueryError::Type(format!("{caller}({{...}}) requires a 'year' key")))?,
8251    };
8252    let year = i32::try_from(year_raw).map_err(|_| {
8253        QueryError::Type(format!(
8254            "{caller}({{...}})'s 'year' is out of range: {year_raw}"
8255        ))
8256    })?;
8257    let month_raw = match m.get("month") {
8258        Some(v) => int_field("month", v)?,
8259        None => epoch_day_from_component("month").unwrap_or(1),
8260    };
8261    let month = u32::try_from(month_raw).map_err(|_| {
8262        QueryError::Type(format!(
8263            "{caller}({{...}})'s 'month' is out of range: {month_raw}"
8264        ))
8265    })?;
8266    let day_raw = match m.get("day") {
8267        Some(v) => int_field("day", v)?,
8268        None => epoch_day_from_component("day").unwrap_or(1),
8269    };
8270    let day = u32::try_from(day_raw).map_err(|_| {
8271        QueryError::Type(format!(
8272            "{caller}({{...}})'s 'day' is out of range: {day_raw}"
8273        ))
8274    })?;
8275    Ok((year, month, day))
8276}
8277
8278/// `duration(...)` — a string (ISO-8601 `'P...'` text, `temporal::
8279/// parse_duration`) or a map (`duration({days: 14, hours: 16})`,
8280/// `temporal::normalize_duration`). No zero-arg form (real Cypher has
8281/// none either — a duration has no "current" value the way a date/time
8282/// does).
8283fn duration_builtin(args: &[Value]) -> Result<Value, QueryError> {
8284    if args.len() != 1 {
8285        return Err(QueryError::Semantic(format!(
8286            "duration() expects exactly one argument, got {}",
8287            args.len()
8288        )));
8289    }
8290    let arg = &args[0];
8291    if matches!(arg, Value::Null) {
8292        return Ok(Value::Null);
8293    }
8294    let (months, days, seconds, nanos) = if let Some(s) = as_arith_str(arg) {
8295        temporal::parse_duration(s).ok_or_else(|| {
8296            QueryError::Type(format!(
8297                "'{s}' isn't a duration string MarsDB can parse -- only ISO-8601 'PnYnMnWnDTnHnMnS' text is \
8298                 supported, not the alternate combined date-time duration syntax"
8299            ))
8300        })?
8301    } else if let Value::Map(m) = arg {
8302        temporal::normalize_duration(duration_fields_from_map(m)?)
8303    } else {
8304        return Err(QueryError::Type(format!(
8305            "duration() doesn't support this argument: {arg:?}"
8306        )));
8307    };
8308    Ok(Value::Property(PropertyValue::Duration {
8309        months,
8310        days,
8311        seconds,
8312        nanos,
8313    }))
8314}
8315
8316fn duration_fields_from_map(
8317    m: &BTreeMap<String, Value>,
8318) -> Result<temporal::DurationFields, QueryError> {
8319    const ALLOWED: &[&str] = &[
8320        "years",
8321        "months",
8322        "weeks",
8323        "days",
8324        "hours",
8325        "minutes",
8326        "seconds",
8327        "milliseconds",
8328        "microseconds",
8329        "nanoseconds",
8330    ];
8331    if let Some(bad) = m.keys().find(|k| !ALLOWED.contains(&k.as_str())) {
8332        return Err(QueryError::Type(format!(
8333            "duration({{...}}) key '{bad}' isn't a recognized duration unit"
8334        )));
8335    }
8336    let field = |key: &str| -> Result<f64, QueryError> {
8337        match m.get(key) {
8338            None => Ok(0.0),
8339            Some(v) => value_as_f64(v).ok_or_else(|| {
8340                QueryError::Type(format!("duration({{...}})'s '{key}' must be a number"))
8341            }),
8342        }
8343    };
8344    Ok(temporal::DurationFields {
8345        years: field("years")?,
8346        months: field("months")?,
8347        weeks: field("weeks")?,
8348        days: field("days")?,
8349        hours: field("hours")?,
8350        minutes: field("minutes")?,
8351        seconds: field("seconds")?,
8352        milliseconds: field("milliseconds")?,
8353        microseconds: field("microseconds")?,
8354        nanoseconds: field("nanoseconds")?,
8355    })
8356}
8357
8358/// Sums the 3 sub-second map keys (`millisecond`/`microsecond`/
8359/// `nanosecond`) shared by every one-of-day-or-later temporal map
8360/// constructor into one nanosecond count -- each key independently
8361/// *additive* (matching real Cypher's own construction semantics,
8362/// e.g. `{millisecond: 645, nanosecond: 123}` is `645ms + 123ns`, not
8363/// "645ms, ignore the usual nanosecond digit position"), separate from
8364/// `duration`'s own `nanoseconds` field of the same name.
8365///
8366/// `base_fraction_ns` (`0..1_000_000_000`) is the fractional-second
8367/// part of whatever this map is *overriding* (a `time`/`datetime`
8368/// projection key, or a `.truncate()` call's already-truncated value)
8369/// -- `0` for plain from-scratch construction, where there's no base to
8370/// inherit from. Any of the 3 keys the map doesn't set defaults to that
8371/// *digit group* of the base (millisecond/microsecond/nanosecond each
8372/// their own `0..999` slice), not to `0` outright -- found as a real
8373/// bug: `{nanosecond: 2}` alone on a base with a real millisecond value
8374/// was silently dropping that millisecond instead of keeping it, only
8375/// the nanosecond digit was meant to change.
8376fn sub_second_nanos_from_map(
8377    base_fraction_ns: i64,
8378    m: &BTreeMap<String, Value>,
8379) -> Result<i64, QueryError> {
8380    let base_ms = base_fraction_ns / 1_000_000;
8381    let base_us = (base_fraction_ns / 1_000) % 1000;
8382    let base_ns = base_fraction_ns % 1000;
8383    let ms = int_field(m, "millisecond", base_ms)?;
8384    let us = int_field(m, "microsecond", base_us)?;
8385    let ns = int_field(m, "nanosecond", base_ns)?;
8386    Ok(ms * 1_000_000 + us * 1_000 + ns)
8387}
8388
8389fn int_field(m: &BTreeMap<String, Value>, key: &str, default: i64) -> Result<i64, QueryError> {
8390    match m.get(key) {
8391        None => Ok(default),
8392        Some(v) => {
8393            value_as_i64(v).ok_or_else(|| QueryError::Type(format!("'{key}' must be an integer")))
8394        }
8395    }
8396}
8397
8398/// Computes `(hour, minute, second, nanos, offset_seconds)` for a
8399/// `localtime`/`time`/`localdatetime`/`datetime` map constructor -- a
8400/// `time`/`datetime` key (if present) projects its clock fields as the
8401/// default, explicit `hour`/`minute`/`second`/`millisecond`/
8402/// `microsecond`/`nanosecond` keys override individual fields on top of
8403/// that (`{time: other, second: 42}` keeps everything from `other`
8404/// except `second`). No base key falls back to all-zero defaults,
8405/// matching the plain (non-projecting) map form.
8406///
8407/// If the base carries an offset (`Time`/`DateTime`) and an explicit
8408/// `timezone` key names a *different* one, the wall-clock is shifted
8409/// first to preserve the same instant (`{time: other, timezone:
8410/// '+05:00'}` on a `+01:00` base advances the hour by 4) -- real
8411/// Cypher's rule, confirmed against Temporal3's own examples -- and
8412/// only *then* do explicit hour/minute/second overrides apply, on top
8413/// of the shifted result, not the original.
8414/// `epoch_day` is the calendar date the resulting clock fields will be
8415/// combined with -- only needed to resolve a *shift into a named zone*
8416/// (its real, DST-aware offset depends on the date, TCK's Temporal3 [9]
8417/// row: `{time: t+01:00, second: 42, timezone: 'Pacific/Honolulu'}`),
8418/// `None` for callers with no date at all (`time()`'s own map form,
8419/// which can't shift into a named zone regardless -- its caller rejects
8420/// that case itself) or that don't care about the resolved zone
8421/// (`localdatetime()`'s map form, which discards it).
8422/// The 5th element is `Some((effective_zone, effective_offset))` --
8423/// `effective_zone` preserves a `Named` base's identity when no
8424/// explicit `timezone` override is given (needed by `DATETIME`, which
8425/// can hold one); `effective_offset` is always a plain resolved number,
8426/// usable directly by a caller that structurally can't hold a zone name
8427/// (`TIME`) regardless of which case produced it.
8428fn clock_fields_from_map(
8429    m: &BTreeMap<String, Value>,
8430    epoch_day: Option<i32>,
8431) -> Result<ClockBase, QueryError> {
8432    let (base_h, base_m, base_s, base_ns, base_zone) = if let Some(v) = m.get("time") {
8433        extract_time_base("time", v)?
8434    } else if let Some(v) = m.get("datetime") {
8435        extract_time_base("datetime", v)?
8436    } else {
8437        (0, 0, 0, 0, None)
8438    };
8439    let has_explicit_timezone = m.contains_key("timezone");
8440    let effective_zone = match m.get("timezone") {
8441        Some(v) => Some(timezone_value_to_tzid(v)?),
8442        // No explicit override -- preserve the base's own zone
8443        // *identity* (a `Named` zone stays `Named`), not just its
8444        // resolved offset (TCK's Temporal3 [9]/[11] `{datetime: other}`
8445        // rows, where `other` is itself a named-zone value).
8446        None => base_zone.as_ref().map(|(tz, _)| tz.clone()),
8447    };
8448    // The wall-clock is only ever *shifted* by an *explicit* `timezone`
8449    // override that actually changes the zone -- with no override, the
8450    // literal local time passes straight through unchanged even if the
8451    // base's own zone's real offset differs for the (possibly
8452    // day-overridden) new date, e.g. a DST boundary crossed by a `day`
8453    // override (TCK's Temporal3 [10]: a `Named` base carried through
8454    // with no `timezone` key keeps its `12:00` wall-clock as `12:00`,
8455    // just re-displayed with whatever offset that zone now resolves to
8456    // -- it does *not* shift to a different wall-clock hour).
8457    let base_nanos_of_day =
8458        base_h * 3_600_000_000_000 + base_m * 60_000_000_000 + base_s * 1_000_000_000 + base_ns;
8459    let (base_h, base_m, base_s, base_ns, effective_offset) = if has_explicit_timezone {
8460        // The base's own offset, re-resolved against the *new* date --
8461        // not its own original instant's offset (`extract_time_base`'s
8462        // `Named` resolution, which used the *source* value's own
8463        // epoch_seconds/date, not necessarily this one -- a `day`
8464        // override can move the result to a different date than the
8465        // base's, potentially across a DST boundary for the *same*
8466        // zone, TCK's Temporal3 [10] row 337).
8467        let from_offset = match base_zone.as_ref() {
8468            Some((temporal::TzId::Offset(o), _)) => Some(*o),
8469            Some((zone @ temporal::TzId::Named(_), resolved)) => Some(match epoch_day {
8470                Some(ed) => temporal::resolve_offset(
8471                    zone,
8472                    temporal::combine_epoch_day_and_nanos_of_day(ed, base_nanos_of_day),
8473                ),
8474                None => *resolved,
8475            }),
8476            None => None,
8477        };
8478        let to_offset = match (from_offset, effective_zone.as_ref(), epoch_day) {
8479            (Some(_), Some(temporal::TzId::Offset(to)), _) => Some(*to),
8480            (Some(from), Some(zone @ temporal::TzId::Named(_)), Some(ed)) => {
8481                let approx_epoch_seconds =
8482                    temporal::combine_epoch_day_and_nanos_of_day(ed, base_nanos_of_day)
8483                        - from as i64;
8484                Some(temporal::resolve_offset(zone, approx_epoch_seconds))
8485            }
8486            _ => None,
8487        };
8488        match (from_offset, to_offset) {
8489            (Some(from), Some(to)) if from != to => {
8490                let shifted = (base_nanos_of_day + (to - from) as i64 * 1_000_000_000)
8491                    .rem_euclid(86_400_000_000_000);
8492                (
8493                    shifted / 3_600_000_000_000,
8494                    (shifted / 60_000_000_000) % 60,
8495                    (shifted / 1_000_000_000) % 60,
8496                    shifted % 1_000_000_000,
8497                    to_offset.unwrap_or(0),
8498                )
8499            }
8500            _ => (base_h, base_m, base_s, base_ns, to_offset.unwrap_or(0)),
8501        }
8502    } else {
8503        // No override -- the resolved offset is just the base's own
8504        // (unchanged, no re-resolution -- a caller that can't hold a
8505        // zone name, `TIME`, degrades a `Named` base to this number
8506        // silently, TCK's Temporal3 [3] row 125: `{time: t}` where `t`
8507        // is a named-zone `DateTime` -> the plain offset, no error).
8508        (
8509            base_h,
8510            base_m,
8511            base_s,
8512            base_ns,
8513            base_zone.as_ref().map_or(0, |(_, o)| *o),
8514        )
8515    };
8516    Ok((
8517        int_field(m, "hour", base_h)?,
8518        int_field(m, "minute", base_m)?,
8519        int_field(m, "second", base_s)?,
8520        sub_second_nanos_from_map(base_ns, m)?,
8521        effective_zone.map(|z| (z, effective_offset)),
8522    ))
8523}
8524
8525/// `localtime(...)` -- zero args (now, UTC), a string (`temporal::
8526/// parse_local_time`), a map (`localtime({hour: 21, minute: 40, ...})`,
8527/// optionally projected from another temporal value via a `time` key),
8528/// or another `LocalTime` (identity, e.g. round-tripping through
8529/// `toString`).
8530fn local_time_builtin(args: &[Value], now: temporal::NowSnapshot) -> Result<Value, QueryError> {
8531    if args.len() > 1 {
8532        return Err(QueryError::Semantic(format!(
8533            "localtime() expects zero or one argument, got {}",
8534            args.len()
8535        )));
8536    }
8537    let Some(arg) = args.first() else {
8538        return Ok(Value::Property(PropertyValue::LocalTime(now.nanos_of_day)));
8539    };
8540    if matches!(arg, Value::Null) {
8541        return Ok(Value::Null);
8542    }
8543    if let Value::Property(PropertyValue::LocalTime(t)) = arg {
8544        return Ok(Value::Property(PropertyValue::LocalTime(*t)));
8545    }
8546    // `localtime(otherTemporal)` -- a bare `Time`/`LocalDateTime`/
8547    // `DateTime` argument projects its own time-of-day part (offset
8548    // dropped, same as `{time: otherTemporal}`), TCK's Temporal3 [2].
8549    if matches!(
8550        arg,
8551        Value::Property(
8552            PropertyValue::Time { .. }
8553                | PropertyValue::LocalDateTime { .. }
8554                | PropertyValue::DateTime { .. }
8555        )
8556    ) {
8557        let (hour, minute, second, nanos, _) = extract_time_base("localtime() argument", arg)?;
8558        let t = temporal::local_time_nanos_from_fields(hour, minute, second, nanos).ok_or_else(
8559            || QueryError::Type("localtime() argument has an out-of-range field".into()),
8560        )?;
8561        return Ok(Value::Property(PropertyValue::LocalTime(t)));
8562    }
8563    if let Some(s) = as_arith_str(arg) {
8564        let t = temporal::parse_local_time(s).ok_or_else(|| {
8565            QueryError::Type(format!("'{s}' isn't a local time string MarsDB can parse"))
8566        })?;
8567        return Ok(Value::Property(PropertyValue::LocalTime(t)));
8568    }
8569    if let Value::Map(m) = arg {
8570        const ALLOWED: &[&str] = &[
8571            "hour",
8572            "minute",
8573            "second",
8574            "millisecond",
8575            "microsecond",
8576            "nanosecond",
8577            "time",
8578        ];
8579        if let Some(bad) = m.keys().find(|k| !ALLOWED.contains(&k.as_str())) {
8580            return Err(QueryError::Type(format!(
8581                "localtime({{...}}) key '{bad}' isn't a recognized field"
8582            )));
8583        }
8584        let (hour, minute, second, nanos, _) = clock_fields_from_map(m, None)?;
8585        let t = temporal::local_time_nanos_from_fields(hour, minute, second, nanos)
8586            .ok_or_else(|| QueryError::Type("localtime({...}) has an out-of-range field".into()))?;
8587        return Ok(Value::Property(PropertyValue::LocalTime(t)));
8588    }
8589    Err(QueryError::Type(format!(
8590        "localtime() doesn't support this argument: {arg:?}"
8591    )))
8592}
8593
8594/// `time(...)` -- same shapes as `localtime(...)`, but every form
8595/// (except identity) requires a `timezone` map key / string offset
8596/// suffix. A bracketed named-zone suffix (`[Europe/Stockholm]`) gets a
8597/// specific "not supported" error rather than the generic parse-failure
8598/// message, since that's a real (if out of scope) Cypher form, not
8599/// malformed input.
8600fn time_builtin(args: &[Value], now: temporal::NowSnapshot) -> Result<Value, QueryError> {
8601    if args.len() > 1 {
8602        return Err(QueryError::Semantic(format!(
8603            "time() expects zero or one argument, got {}",
8604            args.len()
8605        )));
8606    }
8607    let Some(arg) = args.first() else {
8608        return Ok(Value::Property(PropertyValue::Time {
8609            nanos_of_day: now.nanos_of_day,
8610            offset_seconds: 0,
8611        }));
8612    };
8613    if matches!(arg, Value::Null) {
8614        return Ok(Value::Null);
8615    }
8616    if let Value::Property(PropertyValue::Time {
8617        nanos_of_day,
8618        offset_seconds,
8619    }) = arg
8620    {
8621        return Ok(Value::Property(PropertyValue::Time {
8622            nanos_of_day: *nanos_of_day,
8623            offset_seconds: *offset_seconds,
8624        }));
8625    }
8626    // `time(otherTemporal)` -- a bare `LocalTime`/`LocalDateTime`/
8627    // `DateTime` argument projects its own time part, defaulting the
8628    // offset to UTC when the source has none (`LocalTime`/
8629    // `LocalDateTime`), same as `{time: otherTemporal}` (TCK's
8630    // Temporal3 [3]).
8631    if matches!(
8632        arg,
8633        Value::Property(
8634            PropertyValue::LocalTime(_)
8635                | PropertyValue::LocalDateTime { .. }
8636                | PropertyValue::DateTime { .. }
8637        )
8638    ) {
8639        let (hour, minute, second, nanos, zone) = extract_time_base("time() argument", arg)?;
8640        let nanos_of_day = temporal::local_time_nanos_from_fields(hour, minute, second, nanos)
8641            .ok_or_else(|| QueryError::Type("time() argument has an out-of-range field".into()))?;
8642        return Ok(Value::Property(PropertyValue::Time {
8643            nanos_of_day,
8644            // `TIME` structurally can't carry a zone name -- degrades a
8645            // `Named` source to its resolved numeric offset (TCK's
8646            // Temporal3 [3] `datetime({..., timezone: 'Europe/
8647            // Stockholm'})` -> `time(other)` = `'12:00+01:00'`, the
8648            // offset alone, no bracket).
8649            offset_seconds: zone.map_or(0, |(_, o)| o),
8650        }));
8651    }
8652    if let Some(s) = as_arith_str(arg) {
8653        if s.contains('[') {
8654            return Err(QueryError::Type(
8655                "time('...'): named timezones (e.g. '[Europe/Stockholm]') aren't supported, only a fixed UTC \
8656                 offset like '+01:00'"
8657                    .into(),
8658            ));
8659        }
8660        let (nanos_of_day, offset_seconds) = temporal::parse_time(s).ok_or_else(|| {
8661            QueryError::Type(format!("'{s}' isn't a time string MarsDB can parse"))
8662        })?;
8663        return Ok(Value::Property(PropertyValue::Time {
8664            nanos_of_day,
8665            offset_seconds,
8666        }));
8667    }
8668    if let Value::Map(m) = arg {
8669        const ALLOWED: &[&str] = &[
8670            "hour",
8671            "minute",
8672            "second",
8673            "millisecond",
8674            "microsecond",
8675            "nanosecond",
8676            "timezone",
8677            "time",
8678        ];
8679        if let Some(bad) = m.keys().find(|k| !ALLOWED.contains(&k.as_str())) {
8680            return Err(QueryError::Type(format!(
8681                "time({{...}}) key '{bad}' isn't a recognized field"
8682            )));
8683        }
8684        let (hour, minute, second, nanos, zone) = clock_fields_from_map(m, None)?;
8685        let offset_seconds = match zone {
8686            None => 0,
8687            // A `Named` zone reaching here with no *explicit* `timezone`
8688            // key was just carried through from a projected `time`/
8689            // `datetime` base (`{time: namedZoneDateTime}`) -- `TIME`
8690            // can't hold a name, so it silently degrades to the base's
8691            // own resolved offset, same as the cross-type positional
8692            // form already does (TCK's Temporal3 [3] row 125). An
8693            // *explicit* named-zone request, though, is a real error --
8694            // there's no calendar date here to resolve it against.
8695            Some((_, o)) if !m.contains_key("timezone") => o,
8696            Some((temporal::TzId::Offset(o), _)) => o,
8697            Some((temporal::TzId::Named(name), _)) => {
8698                return Err(QueryError::Type(format!(
8699                    "'timezone': '{name}' looks like a named timezone (e.g. 'Europe/Stockholm') -- TIME has \
8700                     no calendar date to resolve a named zone's DST-dependent offset against, only a fixed \
8701                     UTC offset like '+01:00' is supported"
8702                )));
8703            }
8704        };
8705        let nanos_of_day = temporal::local_time_nanos_from_fields(hour, minute, second, nanos)
8706            .ok_or_else(|| QueryError::Type("time({...}) has an out-of-range field".into()))?;
8707        return Ok(Value::Property(PropertyValue::Time {
8708            nanos_of_day,
8709            offset_seconds,
8710        }));
8711    }
8712    Err(QueryError::Type(format!(
8713        "time() doesn't support this argument: {arg:?}"
8714    )))
8715}
8716
8717/// `{timezone: '+01:00'}`'s value -- a fixed UTC offset, or an IANA zone
8718/// name (`'Europe/Stockholm'`). Both forms are always syntactically
8719/// disjoint (an offset always starts with `+`/`-`/`Z`, a zone name never
8720/// does), so there's no ambiguity to resolve between them. A caller that
8721/// can't accept a `Named` zone (`time_builtin`'s map form -- `TIME` has
8722/// no calendar date to resolve a named zone's DST-dependent offset
8723/// against) rejects it itself, after this succeeds.
8724fn timezone_value_to_tzid(v: &Value) -> Result<temporal::TzId, QueryError> {
8725    let s = as_arith_str(v).ok_or_else(|| {
8726        QueryError::Type(
8727            "'timezone' must be a string offset or IANA zone name, e.g. '+01:00' or \
8728             'Europe/Stockholm'"
8729                .into(),
8730        )
8731    })?;
8732    if let Some(offset) = temporal::parse_offset_seconds(s) {
8733        return Ok(temporal::TzId::Offset(offset));
8734    }
8735    if temporal::parse_timezone_name(s).is_some() {
8736        return Ok(temporal::TzId::Named(s.to_string()));
8737    }
8738    Err(QueryError::Type(format!(
8739        "'timezone': '{s}' isn't a valid UTC offset or a recognized IANA zone name"
8740    )))
8741}
8742
8743/// `localdatetime(...)` -- zero args (now, UTC), a string, a map
8744/// (`localdatetime({year, month, day, hour, minute, second, ...})`), or
8745/// another `LocalDateTime` (identity).
8746fn local_date_time_builtin(
8747    args: &[Value],
8748    now: temporal::NowSnapshot,
8749) -> Result<Value, QueryError> {
8750    if args.len() > 1 {
8751        return Err(QueryError::Semantic(format!(
8752            "localdatetime() expects zero or one argument, got {}",
8753            args.len()
8754        )));
8755    }
8756    let Some(arg) = args.first() else {
8757        return Ok(Value::Property(PropertyValue::LocalDateTime {
8758            epoch_seconds: now.epoch_seconds,
8759            nanos: now.nanos,
8760        }));
8761    };
8762    if matches!(arg, Value::Null) {
8763        return Ok(Value::Null);
8764    }
8765    if let Value::Property(PropertyValue::LocalDateTime {
8766        epoch_seconds,
8767        nanos,
8768    }) = arg
8769    {
8770        return Ok(Value::Property(PropertyValue::LocalDateTime {
8771            epoch_seconds: *epoch_seconds,
8772            nanos: *nanos,
8773        }));
8774    }
8775    // `localdatetime(otherTemporal)` -- a bare `DateTime` argument drops
8776    // its offset and keeps its local date+time, same as
8777    // `{datetime: otherTemporal}` (TCK's Temporal3 [7]).
8778    if matches!(arg, Value::Property(PropertyValue::DateTime { .. })) {
8779        let epoch_day = extract_date_base_epoch_day("localdatetime() argument", arg)?;
8780        let year = temporal::date_component(epoch_day, "year").unwrap() as i32;
8781        let month = temporal::date_component(epoch_day, "month").unwrap() as u32;
8782        let day = temporal::date_component(epoch_day, "day").unwrap() as u32;
8783        let (hour, minute, second, nanos, _) = extract_time_base("localdatetime() argument", arg)?;
8784        let (epoch_seconds, nanos) =
8785            temporal::local_date_time_from_fields(temporal::CalendarDateTime {
8786                year,
8787                month,
8788                day,
8789                hour,
8790                minute,
8791                second,
8792                nanos,
8793            })
8794            .ok_or_else(|| {
8795                QueryError::Type("localdatetime() argument has an out-of-range field".into())
8796            })?;
8797        return Ok(Value::Property(PropertyValue::LocalDateTime {
8798            epoch_seconds,
8799            nanos,
8800        }));
8801    }
8802    if let Some(s) = as_arith_str(arg) {
8803        let (epoch_seconds, nanos) = temporal::parse_local_date_time(s).ok_or_else(|| {
8804            QueryError::Type(format!(
8805                "'{s}' isn't a local date-time string MarsDB can parse"
8806            ))
8807        })?;
8808        return Ok(Value::Property(PropertyValue::LocalDateTime {
8809            epoch_seconds,
8810            nanos,
8811        }));
8812    }
8813    if let Value::Map(m) = arg {
8814        let (year, month, day) =
8815            calendar_fields_from_map("localdatetime", m, DATE_TIME_ALLOWED_KEYS)?;
8816        let (hour, minute, second, nanos, _) = clock_fields_from_map(m, None)?;
8817        let (epoch_seconds, nanos) =
8818            temporal::local_date_time_from_fields(temporal::CalendarDateTime {
8819                year,
8820                month,
8821                day,
8822                hour,
8823                minute,
8824                second,
8825                nanos,
8826            })
8827            .ok_or_else(|| {
8828                QueryError::Type("localdatetime({...}) has an out-of-range field".into())
8829            })?;
8830        return Ok(Value::Property(PropertyValue::LocalDateTime {
8831            epoch_seconds,
8832            nanos,
8833        }));
8834    }
8835    Err(QueryError::Type(format!(
8836        "localdatetime() doesn't support this argument: {arg:?}"
8837    )))
8838}
8839
8840const DATE_TIME_ALLOWED_KEYS: &[&str] = &[
8841    "year",
8842    "month",
8843    "day",
8844    "week",
8845    "dayOfWeek",
8846    "ordinalDay",
8847    "quarter",
8848    "dayOfQuarter",
8849    "hour",
8850    "minute",
8851    "second",
8852    "millisecond",
8853    "microsecond",
8854    "nanosecond",
8855    "timezone",
8856    "date",
8857    "time",
8858    "datetime",
8859];
8860
8861/// `datetime(...)` -- zero args (now, UTC), a string, a map
8862/// (`datetime({year, ..., timezone: '+01:00'})` or `{..., timezone:
8863/// 'Europe/Stockholm'}`), or another `DateTime` (identity). Requires a
8864/// `timezone` for every constructed form except identity (defaults to
8865/// UTC, `TzId::Offset(0)`, if the map omits it -- matches `date()`'s own
8866/// "no timezone info -> UTC" convention).
8867fn date_time_builtin(args: &[Value], now: temporal::NowSnapshot) -> Result<Value, QueryError> {
8868    if args.len() > 1 {
8869        return Err(QueryError::Semantic(format!(
8870            "datetime() expects zero or one argument, got {}",
8871            args.len()
8872        )));
8873    }
8874    let Some(arg) = args.first() else {
8875        return Ok(Value::Property(PropertyValue::DateTime {
8876            epoch_seconds: now.epoch_seconds,
8877            nanos: now.nanos,
8878            zone: GraphTzId::Offset(0),
8879        }));
8880    };
8881    if matches!(arg, Value::Null) {
8882        return Ok(Value::Null);
8883    }
8884    if let Value::Property(PropertyValue::DateTime {
8885        epoch_seconds,
8886        nanos,
8887        zone,
8888    }) = arg
8889    {
8890        return Ok(Value::Property(PropertyValue::DateTime {
8891            epoch_seconds: *epoch_seconds,
8892            nanos: *nanos,
8893            zone: zone.clone(),
8894        }));
8895    }
8896    // `datetime(otherLocalDateTime)` -- a bare `LocalDateTime` argument
8897    // has no zone of its own, defaults to UTC, same as `{datetime:
8898    // otherLocalDateTime}` (TCK's Temporal3 [11]).
8899    if let Value::Property(PropertyValue::LocalDateTime {
8900        epoch_seconds,
8901        nanos,
8902    }) = arg
8903    {
8904        return Ok(Value::Property(PropertyValue::DateTime {
8905            epoch_seconds: *epoch_seconds,
8906            nanos: *nanos,
8907            zone: GraphTzId::Offset(0),
8908        }));
8909    }
8910    if let Some(s) = as_arith_str(arg) {
8911        let (epoch_seconds, nanos, zone) = temporal::parse_date_time(s).ok_or_else(|| {
8912            QueryError::Type(format!("'{s}' isn't a date-time string MarsDB can parse"))
8913        })?;
8914        return Ok(Value::Property(PropertyValue::DateTime {
8915            epoch_seconds,
8916            nanos,
8917            zone: tz_to_graph(zone),
8918        }));
8919    }
8920    if let Value::Map(m) = arg {
8921        let (year, month, day) = calendar_fields_from_map("datetime", m, DATE_TIME_ALLOWED_KEYS)?;
8922        let epoch_day = temporal::epoch_day_from_ymd(year, month, day);
8923        let (hour, minute, second, nanos, zone) = clock_fields_from_map(m, epoch_day)?;
8924        let zone = zone.map_or(temporal::TzId::Offset(0), |(z, _)| z);
8925        let (epoch_seconds, nanos) = temporal::date_time_from_fields(
8926            temporal::CalendarDateTime {
8927                year,
8928                month,
8929                day,
8930                hour,
8931                minute,
8932                second,
8933                nanos,
8934            },
8935            &zone,
8936        )
8937        .ok_or_else(|| QueryError::Type("datetime({...}) has an out-of-range field".into()))?;
8938        return Ok(Value::Property(PropertyValue::DateTime {
8939            epoch_seconds,
8940            nanos,
8941            zone: tz_to_graph(zone),
8942        }));
8943    }
8944    Err(QueryError::Type(format!(
8945        "datetime() doesn't support this argument: {arg:?}"
8946    )))
8947}
8948
8949/// Reduces any of the 5 non-`Duration` temporal types to `(epoch_day,
8950/// nanos_of_day, offset_seconds)`, each independently `None` when that
8951/// value has no such component -- e.g. `LocalTime` is `(None, Some(_),
8952/// None)`, bare `Date` is `(Some(_), None, None)`. `DateTime`'s
8953/// date/time components use its *local* (offset-adjusted) reading,
8954/// matching every other `DateTime` component access (see
8955/// `date_time_component`'s docs); its real offset is *also* returned
8956/// (not disregarded) since `duration.between`'s own instant-aware
8957/// reconciliation needs it when both operands carry one -- see
8958/// `temporal::between_components`'s docs for exactly when it applies.
8959fn between_operand(name: &str, v: &Value) -> Result<BetweenOperand, QueryError> {
8960    match v {
8961        Value::Property(PropertyValue::Date(d)) => Ok((Some(*d), None, None)),
8962        Value::Property(PropertyValue::LocalTime(n)) => Ok((None, Some(*n), None)),
8963        Value::Property(PropertyValue::Time {
8964            nanos_of_day,
8965            offset_seconds,
8966        }) => Ok((
8967            None,
8968            Some(*nanos_of_day),
8969            Some(temporal::TzId::Offset(*offset_seconds)),
8970        )),
8971        Value::Property(PropertyValue::LocalDateTime {
8972            epoch_seconds,
8973            nanos,
8974        }) => {
8975            let (d, n) = temporal::split_epoch_seconds(*epoch_seconds);
8976            Ok((Some(d), Some(n + *nanos as i64), None))
8977        }
8978        Value::Property(PropertyValue::DateTime {
8979            epoch_seconds,
8980            nanos,
8981            zone,
8982        }) => {
8983            let tz = tz_from_graph(zone);
8984            let offset_seconds = temporal::resolve_offset(&tz, *epoch_seconds);
8985            let local = epoch_seconds + offset_seconds as i64;
8986            let (d, n) = temporal::split_epoch_seconds(local);
8987            Ok((Some(d), Some(n + *nanos as i64), Some(tz)))
8988        }
8989        other => Err(QueryError::Type(format!(
8990            "{name}() needs a Date, LocalTime, Time, LocalDateTime, or DateTime, got {other:?}"
8991        ))),
8992    }
8993}
8994
8995/// `(epoch_day, nanos_of_day, zone)`, see `between_operand`'s docs.
8996type BetweenOperand = (Option<i32>, Option<i64>, Option<temporal::TzId>);
8997
8998/// `(a_epoch_day, a_nanos_of_day, a_zone, b_epoch_day,
8999/// b_nanos_of_day, b_zone) -> DurationParts` -- the shape
9000/// every `temporal::duration_between`/`duration_in_months`/
9001/// `duration_in_days`/`duration_in_seconds` function shares.
9002type BetweenFn = fn(
9003    Option<i32>,
9004    Option<i64>,
9005    Option<&temporal::TzId>,
9006    Option<i32>,
9007    Option<i64>,
9008    Option<&temporal::TzId>,
9009) -> temporal::DurationParts;
9010
9011/// Shared dispatch for `duration.between`/`.inMonths`/`.inDays`/
9012/// `.inSeconds` -- all 4 take exactly 2 temporal args and differ only
9013/// in which `temporal.rs` decomposition function turns the pair into a
9014/// `Duration`.
9015fn duration_between_builtin(name: &str, args: &[Value], f: BetweenFn) -> Result<Value, QueryError> {
9016    if args.len() != 2 {
9017        return Err(QueryError::Semantic(format!(
9018            "{name}() expects exactly two arguments, got {}",
9019            args.len()
9020        )));
9021    }
9022    if matches!(args[0], Value::Null) || matches!(args[1], Value::Null) {
9023        return Ok(Value::Null);
9024    }
9025    let (a_date, a_time, a_zone) = between_operand(name, &args[0])?;
9026    let (b_date, b_time, b_zone) = between_operand(name, &args[1])?;
9027    Ok(duration_value(f(
9028        a_date,
9029        a_time,
9030        a_zone.as_ref(),
9031        b_date,
9032        b_time,
9033        b_zone.as_ref(),
9034    )))
9035}
9036
9037/// `<type>.truncate(unit, value, map?)`'s first two/three args -- `unit`
9038/// is a string literal, `value` the source temporal value, and the
9039/// trailing map (if present and non-null) carries field overrides
9040/// applied *after* truncation.
9041type TruncateArgs<'a> = (&'a str, &'a Value, Option<&'a BTreeMap<String, Value>>);
9042
9043fn parse_truncate_args<'a>(name: &str, args: &'a [Value]) -> Result<TruncateArgs<'a>, QueryError> {
9044    if args.len() < 2 || args.len() > 3 {
9045        return Err(QueryError::Semantic(format!(
9046            "{name}() expects 2 or 3 arguments, got {}",
9047            args.len()
9048        )));
9049    }
9050    let unit = as_arith_str(&args[0]).ok_or_else(|| {
9051        QueryError::Type(format!("{name}()'s first argument must be a unit string"))
9052    })?;
9053    let map = match args.get(2) {
9054        None | Some(Value::Null) => None,
9055        Some(Value::Map(m)) => Some(m),
9056        Some(other) => {
9057            return Err(QueryError::Type(format!(
9058                "{name}()'s third argument must be a map, got {other:?}"
9059            )))
9060        }
9061    };
9062    Ok((unit, &args[1], map))
9063}
9064
9065/// `year`/`month`/`day`/`dayOfWeek` overrides shared by every
9066/// `.truncate()` builtin's optional trailing map -- any key the map
9067/// doesn't set keeps the truncated base's own value (`date.truncate(
9068/// 'month', d, {day: 5})` keeps the truncated year/month, only `day`
9069/// is overridden). `dayOfWeek` applies *after* year/month/day (moving
9070/// within the resulting date's own ISO week, see `set_iso_weekday`'s
9071/// docs) -- other week/quarter/ordinal-day override keys stay
9072/// unsupported, the same pre-existing construction gap as `date_from_map`.
9073fn apply_date_overrides(
9074    base_epoch_day: i32,
9075    map: Option<&BTreeMap<String, Value>>,
9076) -> Result<i32, QueryError> {
9077    let base_y = temporal::date_component(base_epoch_day, "year").unwrap();
9078    let base_m = temporal::date_component(base_epoch_day, "month").unwrap();
9079    let base_d = temporal::date_component(base_epoch_day, "day").unwrap();
9080    let Some(m) = map else {
9081        return Ok(base_epoch_day);
9082    };
9083    let year_raw = int_field(m, "year", base_y)?;
9084    let year = i32::try_from(year_raw)
9085        .map_err(|_| QueryError::Type(format!("'year' is out of range: {year_raw}")))?;
9086    let month_raw = int_field(m, "month", base_m)?;
9087    let month = u32::try_from(month_raw)
9088        .map_err(|_| QueryError::Type(format!("'month' is out of range: {month_raw}")))?;
9089    let day_raw = int_field(m, "day", base_d)?;
9090    let day = u32::try_from(day_raw)
9091        .map_err(|_| QueryError::Type(format!("'day' is out of range: {day_raw}")))?;
9092    let result = temporal::epoch_day_from_ymd(year, month, day).ok_or_else(|| {
9093        QueryError::Type(format!(
9094            "{year:04}-{month:02}-{day:02} isn't a valid calendar date"
9095        ))
9096    })?;
9097    match m.get("dayOfWeek") {
9098        None => Ok(result),
9099        Some(v) => {
9100            let dow = value_as_i64(v)
9101                .ok_or_else(|| QueryError::Type("'dayOfWeek' must be an integer".into()))?;
9102            temporal::set_iso_weekday(result, dow).ok_or_else(|| {
9103                QueryError::Type(format!(
9104                    "'dayOfWeek' must be 1..7 (Monday..Sunday), got {dow}"
9105                ))
9106            })
9107        }
9108    }
9109}
9110
9111/// `hour`/`minute`/`second`/`millisecond`/`microsecond`/`nanosecond`
9112/// overrides shared by every `.truncate()` builtin's optional trailing
9113/// map -- same "unset key keeps the truncated base's value" rule as
9114/// `apply_date_overrides`.
9115fn apply_time_overrides(
9116    base_nanos_of_day: i64,
9117    map: Option<&BTreeMap<String, Value>>,
9118) -> Result<i64, QueryError> {
9119    let base_h = temporal::local_time_component(base_nanos_of_day, "hour").unwrap();
9120    let base_min = temporal::local_time_component(base_nanos_of_day, "minute").unwrap();
9121    let base_s = temporal::local_time_component(base_nanos_of_day, "second").unwrap();
9122    let base_ns = temporal::local_time_component(base_nanos_of_day, "nanosecond").unwrap();
9123    let Some(m) = map else {
9124        return Ok(base_nanos_of_day);
9125    };
9126    let nanos = sub_second_nanos_from_map(base_ns, m)?;
9127    let hour = int_field(m, "hour", base_h)?;
9128    let minute = int_field(m, "minute", base_min)?;
9129    let second = int_field(m, "second", base_s)?;
9130    temporal::local_time_nanos_from_fields(hour, minute, second, nanos)
9131        .ok_or_else(|| QueryError::Type("truncate(...)'s map has an out-of-range field".into()))
9132}
9133
9134/// Rejects a `.truncate()` map key that this specific target type has
9135/// no field for (e.g. `hour` on `date.truncate`'s result, which is a
9136/// bare `Date`) -- each of the 5 truncate builtins passes its own real
9137/// field list, since `apply_date_overrides`/`apply_time_overrides`
9138/// themselves are shared and don't know which caller's result shape
9139/// makes a given key meaningful.
9140fn validate_truncate_map_keys(
9141    name: &str,
9142    map: Option<&BTreeMap<String, Value>>,
9143    allowed: &[&str],
9144) -> Result<(), QueryError> {
9145    let Some(m) = map else { return Ok(()) };
9146    if let Some(bad) = m.keys().find(|k| !allowed.contains(&k.as_str())) {
9147        return Err(QueryError::Type(format!(
9148            "{name}(...)'s map has an unrecognized field '{bad}'"
9149        )));
9150    }
9151    Ok(())
9152}
9153
9154fn date_truncate_builtin(args: &[Value]) -> Result<Value, QueryError> {
9155    let (unit, other, map) = parse_truncate_args("date.truncate", args)?;
9156    validate_truncate_map_keys("date.truncate", map, &["year", "month", "day", "dayOfWeek"])?;
9157    if matches!(other, Value::Null) {
9158        return Ok(Value::Null);
9159    }
9160    let (base_date, _, _) = between_operand("date.truncate", other)?;
9161    let base_date = base_date.ok_or_else(|| {
9162        QueryError::Type(
9163            "date.truncate() needs a value with a calendar date (Date, LocalDateTime, or DateTime)"
9164                .into(),
9165        )
9166    })?;
9167    let truncated = temporal::truncate_date_unit(base_date, unit).ok_or_else(|| {
9168        QueryError::Type(format!(
9169            "date.truncate(): '{unit}' isn't a recognized date unit"
9170        ))
9171    })?;
9172    Ok(Value::Property(PropertyValue::Date(apply_date_overrides(
9173        truncated, map,
9174    )?)))
9175}
9176
9177const TIME_TRUNCATE_MAP_KEYS: &[&str] = &[
9178    "hour",
9179    "minute",
9180    "second",
9181    "millisecond",
9182    "microsecond",
9183    "nanosecond",
9184];
9185
9186fn local_time_truncate_builtin(args: &[Value]) -> Result<Value, QueryError> {
9187    let (unit, other, map) = parse_truncate_args("localtime.truncate", args)?;
9188    validate_truncate_map_keys("localtime.truncate", map, TIME_TRUNCATE_MAP_KEYS)?;
9189    if matches!(other, Value::Null) {
9190        return Ok(Value::Null);
9191    }
9192    let (_, base_time, _) = between_operand("localtime.truncate", other)?;
9193    let base_time = base_time.ok_or_else(|| {
9194        QueryError::Type(
9195            "localtime.truncate() needs a value with a time-of-day (LocalTime, Time, \
9196             LocalDateTime, or DateTime)"
9197                .into(),
9198        )
9199    })?;
9200    let truncated = temporal::truncate_time_unit(base_time, unit).ok_or_else(|| {
9201        QueryError::Type(format!(
9202            "localtime.truncate(): '{unit}' isn't a recognized time unit"
9203        ))
9204    })?;
9205    Ok(Value::Property(PropertyValue::LocalTime(
9206        apply_time_overrides(truncated, map)?,
9207    )))
9208}
9209
9210fn time_truncate_builtin(args: &[Value]) -> Result<Value, QueryError> {
9211    let (unit, other, map) = parse_truncate_args("time.truncate", args)?;
9212    validate_truncate_map_keys(
9213        "time.truncate",
9214        map,
9215        &[
9216            "hour",
9217            "minute",
9218            "second",
9219            "millisecond",
9220            "microsecond",
9221            "nanosecond",
9222            "timezone",
9223        ],
9224    )?;
9225    if matches!(other, Value::Null) {
9226        return Ok(Value::Null);
9227    }
9228    let (_, base_time, base_offset) = between_operand("time.truncate", other)?;
9229    let base_time = base_time.ok_or_else(|| {
9230        QueryError::Type(
9231            "time.truncate() needs a value with a time-of-day (LocalTime, Time, LocalDateTime, \
9232             or DateTime)"
9233                .into(),
9234        )
9235    })?;
9236    let truncated = temporal::truncate_time_unit(base_time, unit).ok_or_else(|| {
9237        QueryError::Type(format!(
9238            "time.truncate(): '{unit}' isn't a recognized time unit"
9239        ))
9240    })?;
9241    let nanos_of_day = apply_time_overrides(truncated, map)?;
9242    let offset_seconds = match map.and_then(|m| m.get("timezone")) {
9243        Some(v) => match timezone_value_to_tzid(v)? {
9244            temporal::TzId::Offset(o) => o,
9245            temporal::TzId::Named(name) => {
9246                return Err(QueryError::Type(format!(
9247                    "'timezone': '{name}' looks like a named timezone (e.g. 'Europe/Stockholm') -- TIME has \
9248                     no calendar date to resolve a named zone's DST-dependent offset against, only a fixed \
9249                     UTC offset like '+01:00' is supported"
9250                )));
9251            }
9252        },
9253        None => match base_offset {
9254            Some(temporal::TzId::Offset(o)) => o,
9255            _ => 0,
9256        },
9257    };
9258    Ok(Value::Property(PropertyValue::Time {
9259        nanos_of_day,
9260        offset_seconds,
9261    }))
9262}
9263
9264/// Shared by `localdatetime.truncate`/`datetime.truncate`: a calendar-
9265/// scale `unit` (`year`, `month`, ...) truncates the date and resets
9266/// the time-of-day to midnight; a clock-scale `unit` (`hour`,
9267/// `minute`, ...) leaves the date untouched and truncates just the
9268/// time. `day` is both at once (`truncate_date_unit`'s own `day` arm
9269/// already returns the date unchanged), so trying the date-unit path
9270/// first handles it correctly without a separate case.
9271fn truncate_date_time(base_date: i32, base_time: i64, unit: &str) -> Option<(i32, i64)> {
9272    if let Some(d) = temporal::truncate_date_unit(base_date, unit) {
9273        Some((d, 0))
9274    } else {
9275        temporal::truncate_time_unit(base_time, unit).map(|t| (base_date, t))
9276    }
9277}
9278
9279fn local_date_time_truncate_builtin(args: &[Value]) -> Result<Value, QueryError> {
9280    let (unit, other, map) = parse_truncate_args("localdatetime.truncate", args)?;
9281    validate_truncate_map_keys(
9282        "localdatetime.truncate",
9283        map,
9284        &[
9285            "year",
9286            "month",
9287            "day",
9288            "dayOfWeek",
9289            "hour",
9290            "minute",
9291            "second",
9292            "millisecond",
9293            "microsecond",
9294            "nanosecond",
9295        ],
9296    )?;
9297    if matches!(other, Value::Null) {
9298        return Ok(Value::Null);
9299    }
9300    let (base_date, base_time, _) = between_operand("localdatetime.truncate", other)?;
9301    let base_date = base_date.ok_or_else(|| {
9302        QueryError::Type(
9303            "localdatetime.truncate() needs a value with a calendar date (Date, LocalDateTime, \
9304             or DateTime)"
9305                .into(),
9306        )
9307    })?;
9308    let (trunc_date, trunc_time) = truncate_date_time(base_date, base_time.unwrap_or(0), unit)
9309        .ok_or_else(|| {
9310            QueryError::Type(format!(
9311                "localdatetime.truncate(): '{unit}' isn't a recognized unit"
9312            ))
9313        })?;
9314    let final_date = apply_date_overrides(trunc_date, map)?;
9315    let final_time = apply_time_overrides(trunc_time, map)?;
9316    let (epoch_seconds, nanos) = temporal::combine_date_and_time(final_date, final_time);
9317    Ok(Value::Property(PropertyValue::LocalDateTime {
9318        epoch_seconds,
9319        nanos,
9320    }))
9321}
9322
9323fn date_time_truncate_builtin(args: &[Value]) -> Result<Value, QueryError> {
9324    let (unit, other, map) = parse_truncate_args("datetime.truncate", args)?;
9325    validate_truncate_map_keys(
9326        "datetime.truncate",
9327        map,
9328        &[
9329            "year",
9330            "month",
9331            "day",
9332            "dayOfWeek",
9333            "hour",
9334            "minute",
9335            "second",
9336            "millisecond",
9337            "microsecond",
9338            "nanosecond",
9339            "timezone",
9340        ],
9341    )?;
9342    if matches!(other, Value::Null) {
9343        return Ok(Value::Null);
9344    }
9345    let (base_date, base_time, base_offset) = between_operand("datetime.truncate", other)?;
9346    let base_date = base_date.ok_or_else(|| {
9347        QueryError::Type(
9348            "datetime.truncate() needs a value with a calendar date (Date, LocalDateTime, or \
9349             DateTime)"
9350                .into(),
9351        )
9352    })?;
9353    let (trunc_date, trunc_time) = truncate_date_time(base_date, base_time.unwrap_or(0), unit)
9354        .ok_or_else(|| {
9355            QueryError::Type(format!(
9356                "datetime.truncate(): '{unit}' isn't a recognized unit"
9357            ))
9358        })?;
9359    let final_date = apply_date_overrides(trunc_date, map)?;
9360    let final_time = apply_time_overrides(trunc_time, map)?;
9361    let zone = match map.and_then(|m| m.get("timezone")) {
9362        Some(v) => timezone_value_to_tzid(v)?,
9363        None => base_offset.unwrap_or(temporal::TzId::Offset(0)),
9364    };
9365    let calendar = temporal::CalendarDateTime {
9366        year: temporal::date_component(final_date, "year").unwrap() as i32,
9367        month: temporal::date_component(final_date, "month").unwrap() as u32,
9368        day: temporal::date_component(final_date, "day").unwrap() as u32,
9369        hour: temporal::local_time_component(final_time, "hour").unwrap(),
9370        minute: temporal::local_time_component(final_time, "minute").unwrap(),
9371        second: temporal::local_time_component(final_time, "second").unwrap(),
9372        nanos: temporal::local_time_component(final_time, "nanosecond").unwrap(),
9373    };
9374    let (epoch_seconds, nanos) =
9375        temporal::date_time_from_fields(calendar, &zone).ok_or_else(|| {
9376            QueryError::Type("datetime.truncate() produced an out-of-range value".into())
9377        })?;
9378    Ok(Value::Property(PropertyValue::DateTime {
9379        epoch_seconds,
9380        nanos,
9381        zone: tz_to_graph(zone),
9382    }))
9383}
9384
9385fn value_as_i64(v: &Value) -> Option<i64> {
9386    match v {
9387        Value::Property(PropertyValue::Int(i)) | Value::Literal(Literal::Int(i)) => Some(*i),
9388        _ => None,
9389    }
9390}
9391
9392fn value_as_f64(v: &Value) -> Option<f64> {
9393    match as_arith_num(v)? {
9394        ArithNum::Int(i) => Some(i as f64),
9395        ArithNum::Float(f) => Some(f),
9396    }
9397}
9398
9399/// Shared `Date`/`Duration` component access for `d.<prop>` — used by
9400/// both `lookup_prop` (a bound row variable, e.g. `WITH v.date AS d ...
9401/// d.year`) and `eval_projected_expr`'s `Prop` arm (the post-projection/
9402/// ORDER BY path). Returns `None` for any property name that isn't a
9403/// recognized component (or a non-temporal `PropertyValue`), the same
9404/// "treat as absent, not an error" convention every other `.prop` access
9405/// already follows for an unknown property.
9406/// True for the 6 `PropertyValue` variants that have a real `.prop`
9407/// component-access interface (`temporal_component`) -- distinguishes
9408/// "a temporal value with an *unrecognized* property name" (still `null`,
9409/// same as a node/edge's own missing-property rule) from "a plain scalar
9410/// with *no* `.prop` interface at all" (a real type error, see
9411/// `lookup_prop_value`'s docs) -- `temporal_component` alone can't tell
9412/// these apart, since it returns `None` for both.
9413fn is_temporal_property_value(pv: &PropertyValue) -> bool {
9414    matches!(
9415        pv,
9416        PropertyValue::Date(_)
9417            | PropertyValue::Duration { .. }
9418            | PropertyValue::LocalTime(_)
9419            | PropertyValue::Time { .. }
9420            | PropertyValue::LocalDateTime { .. }
9421            | PropertyValue::DateTime { .. }
9422    )
9423}
9424
9425/// `<expr>.prop` where `<expr>` isn't a bare row variable (`ReturnExpr::
9426/// PropOf`, e.g. `startNode(r).id`, `head(nodes(p)).name`, `{a: 1}.a`) --
9427/// unlike `lookup_prop_value`'s `Prop(PropAccess)` arm, there's no row/txn
9428/// lookup to do here, `v` already *is* the fully-evaluated base value, so
9429/// this reads straight off it. Same node/edge/map/temporal-value-or-error
9430/// shape as `lookup_prop_value`, minus the "unbound variable" case (there's
9431/// no variable name to report -- a `PropOf` base that evaluates to
9432/// `Value::Null` propagates `Null` here the same way a bound-but-null row
9433/// variable's own `.prop` access already does).
9434fn property_of_value(v: &Value, prop: &str) -> Result<Value, QueryError> {
9435    match v {
9436        Value::Node(n) => Ok(n
9437            .props
9438            .get(prop)
9439            .cloned()
9440            .map(property_value_to_value)
9441            .unwrap_or(Value::Null)),
9442        Value::Edge(e) => Ok(e
9443            .props
9444            .get(prop)
9445            .cloned()
9446            .map(property_value_to_value)
9447            .unwrap_or(Value::Null)),
9448        Value::Map(m) => Ok(m.get(prop).cloned().unwrap_or(Value::Null)),
9449        Value::Null => Ok(Value::Null),
9450        Value::Property(PropertyValue::Null) => Ok(Value::Null),
9451        Value::Property(pv) => match temporal_component(pv, prop) {
9452            Some(component) => Ok(Value::Property(component)),
9453            None if is_temporal_property_value(pv) => Ok(Value::Null),
9454            None => Err(QueryError::Type(
9455                "property access requires a node, relationship, map, or temporal value".into(),
9456            )),
9457        },
9458        Value::List(_) | Value::Path(_) => Err(QueryError::Type(
9459            "property access requires a node, relationship, map, or temporal value, not a list \
9460             or path"
9461                .into(),
9462        )),
9463        Value::Literal(_) => Err(QueryError::Type(
9464            "property access requires a node, relationship, map, or temporal value".into(),
9465        )),
9466    }
9467}
9468
9469fn temporal_component(pv: &PropertyValue, prop: &str) -> Option<PropertyValue> {
9470    match pv {
9471        PropertyValue::Date(d) => temporal::date_component(*d, prop).map(PropertyValue::Int),
9472        PropertyValue::Duration {
9473            months,
9474            days,
9475            seconds,
9476            nanos,
9477        } => temporal::duration_component(*months, *days, *seconds, *nanos, prop)
9478            .map(PropertyValue::Int),
9479        PropertyValue::LocalTime(nanos_of_day) => {
9480            temporal::local_time_component(*nanos_of_day, prop).map(PropertyValue::Int)
9481        }
9482        PropertyValue::Time {
9483            nanos_of_day,
9484            offset_seconds,
9485        } => time_component(*nanos_of_day, *offset_seconds, prop),
9486        PropertyValue::LocalDateTime {
9487            epoch_seconds,
9488            nanos,
9489        } => date_time_component(*epoch_seconds, *nanos, None, prop),
9490        PropertyValue::DateTime {
9491            epoch_seconds,
9492            nanos,
9493            zone,
9494        } => date_time_component(*epoch_seconds, *nanos, Some(&tz_from_graph(zone)), prop),
9495        _ => None,
9496    }
9497}
9498
9499/// `Time`'s own component set: `LocalTime`'s fields plus the offset
9500/// ones (`timezone`/`offset` as text, `offsetSeconds`/`offsetMinutes`
9501/// as integers).
9502fn time_component(nanos_of_day: i64, offset_seconds: i32, prop: &str) -> Option<PropertyValue> {
9503    match prop {
9504        "timezone" | "offset" => Some(PropertyValue::String(temporal::format_offset(
9505            offset_seconds,
9506        ))),
9507        "offsetSeconds" => Some(PropertyValue::Int(offset_seconds as i64)),
9508        "offsetMinutes" => Some(PropertyValue::Int(offset_seconds as i64 / 60)),
9509        _ => temporal::local_time_component(nanos_of_day, prop).map(PropertyValue::Int),
9510    }
9511}
9512
9513/// `LocalDateTime`/`DateTime`'s shared component set: every `Date`
9514/// component, every `LocalTime` component, and (only when
9515/// `offset_seconds` is `Some`, i.e. a real `DateTime`) the same offset/
9516/// epoch fields `Time`/this-function's own `epochSeconds`/`epochMillis`
9517/// add on top.
9518///
9519/// Calendar/clock components (`year`..`nanosecond`) are computed against
9520/// the *local* (offset-adjusted) wall-clock reading, not the stored UTC
9521/// instant -- `datetime({..., hour: 12, timezone: '+01:00'}).hour` must
9522/// answer `12` (what was written/displayed), not `11` (the UTC hour) --
9523/// same "display the local reading" rule `format_date_time` already
9524/// follows. `epochSeconds`/`epochMillis` are the one exception,
9525/// deliberately using the raw (UTC) `epoch_seconds` -- "epoch" always
9526/// means the UTC instant, regardless of offset.
9527fn date_time_component(
9528    epoch_seconds: i64,
9529    nanos: i32,
9530    zone: Option<&temporal::TzId>,
9531    prop: &str,
9532) -> Option<PropertyValue> {
9533    if let Some(zone) = zone {
9534        let offset_seconds = temporal::resolve_offset(zone, epoch_seconds);
9535        match prop {
9536            // `.timezone` is the zone *identifier* as written -- the
9537            // zone name for a `Named` zone, or the offset text itself
9538            // for a fixed `Offset` (there's no separate name); `.offset`
9539            // is always the *resolved* offset text, so the two only
9540            // diverge for a `Named` zone (TCK's Temporal5's `d.timezone`
9541            // = `'Europe/Stockholm'` vs `d.offset` = `'+01:00'`).
9542            "timezone" => {
9543                let text = match zone {
9544                    temporal::TzId::Named(name) => name.clone(),
9545                    temporal::TzId::Offset(_) => temporal::format_offset(offset_seconds),
9546                };
9547                return Some(PropertyValue::String(text));
9548            }
9549            "offset" => {
9550                return Some(PropertyValue::String(temporal::format_offset(
9551                    offset_seconds,
9552                )))
9553            }
9554            "offsetSeconds" => return Some(PropertyValue::Int(offset_seconds as i64)),
9555            "offsetMinutes" => return Some(PropertyValue::Int(offset_seconds as i64 / 60)),
9556            "epochSeconds" => return Some(PropertyValue::Int(epoch_seconds)),
9557            "epochMillis" => {
9558                return Some(PropertyValue::Int(
9559                    temporal::epoch_seconds_and_millis(epoch_seconds, nanos).1,
9560                ))
9561            }
9562            _ => {}
9563        }
9564    }
9565    let offset_seconds = zone.map_or(0, |z| temporal::resolve_offset(z, epoch_seconds));
9566    let local_epoch_seconds = epoch_seconds + offset_seconds as i64;
9567    temporal::date_time_calendar_component(local_epoch_seconds, prop)
9568        .or_else(|| temporal::date_time_clock_component(local_epoch_seconds, nanos, prop))
9569        .map(PropertyValue::Int)
9570}
9571
9572/// Sorts `rows` (already-projected `RETURN`/`WITH` output, `columns`
9573/// aligned by index) by `order_by`, which evaluates against the projected
9574/// column names — never the raw pattern `BindingRow` — since every ORDER BY
9575/// key in practice is a RETURN/WITH alias, not a bare pattern variable.
9576fn apply_order_by(
9577    rows: Vec<Vec<Value>>,
9578    columns: &[String],
9579    order_by: &[(ReturnExpr, SortDir)],
9580    items: Option<&[ReturnItem]>,
9581    skip: Option<i64>,
9582    limit: Option<i64>,
9583) -> Result<Vec<Vec<Value>>, QueryError> {
9584    // An ORDER BY expression that repeats a returned expression verbatim
9585    // (`RETURN n.name, count(*) AS foo ORDER BY n.name`) names a real
9586    // output column by its default name -- match it directly by position
9587    // rather than re-evaluating the expression, which would need bindings
9588    // (e.g. `n`) that only the pre-aggregation rows had and are gone by
9589    // this post-projection point. That name-based match only works for an
9590    // *unaliased* item (its column name literally is its default name) --
9591    // an aliased item repeated verbatim (`RETURN sum(x) AS s ORDER BY
9592    // sum(x)`, TCK's WithOrderBy4 [11]) needs a structural match against
9593    // the item's own expression instead, falling back to position in
9594    // `items` (1:1 with `columns`, one column per return item).
9595    let order_by_col: Vec<Option<usize>> = order_by
9596        .iter()
9597        .map(|(expr, _)| {
9598            columns
9599                .iter()
9600                .position(|c| *c == default_column_name(expr, 0))
9601                .or_else(|| {
9602                    items.and_then(|items| items.iter().position(|item| item.expr == *expr))
9603                })
9604        })
9605        .collect();
9606    let mut keyed: Vec<(Vec<Value>, Vec<Value>)> = Vec::with_capacity(rows.len());
9607    for row in rows {
9608        let row_map: HashMap<String, Value> =
9609            columns.iter().cloned().zip(row.iter().cloned()).collect();
9610        let keys = order_by
9611            .iter()
9612            .zip(&order_by_col)
9613            .map(|((expr, _), col)| match col {
9614                Some(i) => Ok(row[*i].clone()),
9615                None => eval_projected_expr(expr, &row_map),
9616            })
9617            .collect::<Result<Vec<_>, _>>()?;
9618        keyed.push((keys, row));
9619    }
9620    Ok(top_k_by(keyed, order_by, skip, limit)
9621        .into_iter()
9622        .map(|(_, row)| row)
9623        .collect())
9624}
9625
9626/// Same expression shape as `eval_return_expr`, but resolves `Var`/`Prop`
9627/// against already-projected output columns instead of the graph-bound
9628/// `BindingRow` — no `WriteTransaction`/`GraphStore` access needed, since a
9629/// projected `Value::Node`/`Value::Edge` already carries its full record
9630/// (including props) from when it was first materialized.
9631fn eval_projected_expr(
9632    expr: &ReturnExpr,
9633    row: &HashMap<String, Value>,
9634) -> Result<Value, QueryError> {
9635    match expr {
9636        ReturnExpr::Var(name) => row
9637            .get(name)
9638            .cloned()
9639            .ok_or_else(|| QueryError::UnboundVariable(name.clone())),
9640        ReturnExpr::Prop(pa) => {
9641            let base = row
9642                .get(&pa.var)
9643                .ok_or_else(|| QueryError::UnboundVariable(pa.var.clone()))?;
9644            match base {
9645                Value::Map(m) => Ok(m.get(&pa.prop).cloned().unwrap_or(Value::Null)),
9646                Value::Node(n) => Ok(match n.props.get(&pa.prop).cloned() {
9647                    Some(PropertyValue::Null) | None => Value::Null,
9648                    Some(v) => property_value_to_value(v),
9649                }),
9650                Value::Edge(e) => Ok(match e.props.get(&pa.prop).cloned() {
9651                    Some(PropertyValue::Null) | None => Value::Null,
9652                    Some(v) => property_value_to_value(v),
9653                }),
9654                // `d.year`/`d.months`/etc component access on a `Date`/
9655                // `Duration` in projected/ORDER BY position -- mirrors
9656                // `lookup_prop_value`'s equivalent `Binding::Value(pv)`
9657                // handling for the pre-projection path.
9658                Value::Property(pv) => Ok(match temporal_component(pv, &pa.prop) {
9659                    Some(component) => Value::Property(component),
9660                    None => Value::Null,
9661                }),
9662                _ => Ok(Value::Null),
9663            }
9664        }
9665        ReturnExpr::PropOf(base, prop) => {
9666            let v = eval_projected_expr(base, row)?;
9667            property_of_value(&v, prop)
9668        }
9669        ReturnExpr::Lit(lit) => Ok(match lit {
9670            Literal::Null => Value::Null,
9671            other => Value::Literal(other.clone()),
9672        }),
9673        ReturnExpr::Call { name, args, .. } => {
9674            // Same internal-consistency stance as `eval_return_expr`'s
9675            // `Call` arm: by the time ORDER BY runs, aggregation has
9676            // already resolved into ordinary named output columns
9677            // (referenced here via `Var`), so a raw aggregate `Call`
9678            // reaching this point means it wasn't top-level as
9679            // `validate_return_items` requires.
9680            if is_aggregate_name(name) {
9681                return Err(QueryError::Semantic(format!(
9682                    "aggregate function '{name}' can only be used as a return item's top-level expression"
9683                )));
9684            }
9685            let arg_values = args
9686                .iter()
9687                .map(|a| eval_projected_expr(a, row))
9688                .collect::<Result<Vec<_>, _>>()?;
9689            // No `Executor` (and so no cached `now_snapshot()`) reachable
9690            // from this post-projection/ORDER BY path -- a fresh capture
9691            // here is a real, narrow inconsistency (a no-arg `date()`/
9692            // etc re-evaluated from *inside* an ORDER BY expression could
9693            // in principle read a different instant than the same call
9694            // during `RETURN`'s own projection), but reaching this
9695            // specific shape at all is a rare, arguably degenerate query.
9696            call_builtin(name, &arg_values, temporal::capture_now())
9697        }
9698        ReturnExpr::CountStar => Err(QueryError::Semantic(
9699            "count(*) can only be used as a return item's top-level expression".into(),
9700        )),
9701        ReturnExpr::Case { test, whens, else_ } => {
9702            let test_value = match test {
9703                Some(t) => Some(eval_projected_expr(t, row)?),
9704                None => None,
9705            };
9706            for (when, then) in whens {
9707                let when_value = eval_projected_expr(when, row)?;
9708                let matched = match &test_value {
9709                    Some(tv) => value_eq(tv, &when_value),
9710                    None => matches!(when_value, Value::Literal(Literal::Bool(true))),
9711                };
9712                if matched {
9713                    return eval_projected_expr(then, row);
9714                }
9715            }
9716            match else_ {
9717                Some(e) => eval_projected_expr(e, row),
9718                None => Ok(Value::Null),
9719            }
9720        }
9721        ReturnExpr::Arith(l, op, r) => {
9722            let lv = eval_projected_expr(l, row)?;
9723            let rv = eval_projected_expr(r, row)?;
9724            apply_arith(*op, &lv, &rv)
9725        }
9726        ReturnExpr::Neg(e) => {
9727            let v = eval_projected_expr(e, row)?;
9728            apply_neg(&v)
9729        }
9730        ReturnExpr::ListLit(items) => Ok(Value::List(
9731            items
9732                .iter()
9733                .map(|item| eval_projected_expr(item, row))
9734                .collect::<Result<Vec<_>, _>>()?,
9735        )),
9736        ReturnExpr::Index(base, index) => {
9737            let base_v = eval_projected_expr(base, row)?;
9738            let index_v = eval_projected_expr(index, row)?;
9739            apply_index(&base_v, &index_v)
9740        }
9741        ReturnExpr::Slice(base, start, end) => {
9742            let base_v = eval_projected_expr(base, row)?;
9743            let start_v = start
9744                .as_deref()
9745                .map(|s| eval_projected_expr(s, row))
9746                .transpose()?;
9747            let end_v = end
9748                .as_deref()
9749                .map(|e| eval_projected_expr(e, row))
9750                .transpose()?;
9751            apply_slice(&base_v, start_v.as_ref(), end_v.as_ref())
9752        }
9753        ReturnExpr::ListComp {
9754            var,
9755            source,
9756            where_clause,
9757            project,
9758        } => {
9759            let source_v = eval_projected_expr(source, row)?;
9760            let items = match source_v {
9761                Value::List(items) => items,
9762                Value::Null => return Ok(Value::Null),
9763                other => {
9764                    return Err(QueryError::Type(format!(
9765                        "list comprehension source must be a list, got {other:?}"
9766                    )))
9767                }
9768            };
9769            let mut result = Vec::with_capacity(items.len());
9770            for item in items {
9771                let mut scoped_row = row.clone();
9772                scoped_row.insert(var.clone(), item.clone());
9773                let keep = match where_clause {
9774                    Some(w) => value_to_bool3(&eval_projected_expr(w, &scoped_row)?)? == Some(true),
9775                    None => true,
9776                };
9777                if !keep {
9778                    continue;
9779                }
9780                result.push(match project {
9781                    Some(p) => eval_projected_expr(p, &scoped_row)?,
9782                    None => item,
9783                });
9784            }
9785            Ok(Value::List(result))
9786        }
9787        ReturnExpr::Quantifier {
9788            kind,
9789            var,
9790            source,
9791            where_clause,
9792        } => {
9793            let source_v = eval_projected_expr(source, row)?;
9794            let items = match source_v {
9795                Value::List(items) => items,
9796                Value::Null => return Ok(Value::Null),
9797                other => {
9798                    return Err(QueryError::Type(format!(
9799                        "quantifier source must be a list, got {other:?}"
9800                    )))
9801                }
9802            };
9803            let mut preds = Vec::with_capacity(items.len());
9804            for item in &items {
9805                let mut scoped_row = row.clone();
9806                scoped_row.insert(var.clone(), item.clone());
9807                preds.push(match where_clause {
9808                    Some(w) => value_to_bool3(&eval_projected_expr(w, &scoped_row)?)?,
9809                    None => item_truthy(item),
9810                });
9811            }
9812            Ok(match eval_quantifier(*kind, &preds) {
9813                Some(b) => Value::Literal(Literal::Bool(b)),
9814                None => Value::Null,
9815            })
9816        }
9817        ReturnExpr::MapLit(entries) => {
9818            let mut map = BTreeMap::new();
9819            for (k, v) in entries {
9820                map.insert(k.clone(), eval_projected_expr(v, row)?);
9821            }
9822            Ok(Value::Map(map))
9823        }
9824        ReturnExpr::And(l, r) => Ok(bool3_to_value(and3(
9825            value_to_bool3(&eval_projected_expr(l, row)?)?,
9826            value_to_bool3(&eval_projected_expr(r, row)?)?,
9827        ))),
9828        ReturnExpr::Or(l, r) => Ok(bool3_to_value(or3(
9829            value_to_bool3(&eval_projected_expr(l, row)?)?,
9830            value_to_bool3(&eval_projected_expr(r, row)?)?,
9831        ))),
9832        ReturnExpr::Xor(l, r) => Ok(bool3_to_value(xor3(
9833            value_to_bool3(&eval_projected_expr(l, row)?)?,
9834            value_to_bool3(&eval_projected_expr(r, row)?)?,
9835        ))),
9836        ReturnExpr::Not(e) => Ok(bool3_to_value(
9837            value_to_bool3(&eval_projected_expr(e, row)?)?.map(|b| !b),
9838        )),
9839        ReturnExpr::Compare(l, op, r) => {
9840            let lv = eval_projected_expr(l, row)?;
9841            let rv = eval_projected_expr(r, row)?;
9842            Ok(bool3_to_value(compare_values(&lv, *op, &rv)))
9843        }
9844        ReturnExpr::IsNull(e) => Ok(Value::Literal(Literal::Bool(matches!(
9845            eval_projected_expr(e, row)?,
9846            Value::Null
9847        )))),
9848        ReturnExpr::In(needle, haystack) => {
9849            let nv = eval_projected_expr(needle, row)?;
9850            let hv = eval_projected_expr(haystack, row)?;
9851            Ok(bool3_to_value(list_membership_ternary(&nv, &hv)?))
9852        }
9853        ReturnExpr::HasLabel(var, labels) => {
9854            let binding = row
9855                .get(var)
9856                .ok_or_else(|| QueryError::UnboundVariable(var.clone()))?;
9857            match binding {
9858                Value::Node(n) => Ok(Value::Literal(Literal::Bool(
9859                    labels.iter().all(|l| n.labels.contains(l)),
9860                ))),
9861                Value::Null => Ok(Value::Null),
9862                other => Err(QueryError::Type(format!(
9863                    "'{var}' isn't a node — (n:Label) needs a node binding, got {other:?}"
9864                ))),
9865            }
9866        }
9867        ReturnExpr::PatternPredicate(_) => Err(QueryError::Semantic(
9868            "a pattern predicate (`(n)-->()` etc) can only be used inside WHERE".into(),
9869        )),
9870        // No `Txn`/`ExecutionGuard` reachable from this post-projection
9871        // path (same "no `Executor`" limitation as the `Call` arm above)
9872        // -- a pattern comprehension needs a real graph traversal to
9873        // re-evaluate, which this function structurally can't do. Only
9874        // reachable for an ORDER BY key that references a pattern
9875        // comprehension *without* repeating a RETURN/WITH item verbatim
9876        // (the verbatim case matches by column position before ever
9877        // reaching here -- see `apply_order_by`'s `order_by_col`) --
9878        // not exercised by any current TCK scenario.
9879        ReturnExpr::PatternComprehension { .. } => Err(QueryError::Semantic(
9880            "a pattern comprehension can only be used in RETURN/WITH position, or as an ORDER BY \
9881             key that repeats one of their items verbatim"
9882                .into(),
9883        )),
9884        ReturnExpr::ExistsPattern { .. } | ReturnExpr::ExistsSubquery(_) => Err(
9885            QueryError::Semantic("an exists {} subquery can only be used inside WHERE".into()),
9886        ),
9887    }
9888}
9889
9890/// `RETURN DISTINCT`'s result-set-level dedup -- structural equality of
9891/// the whole row (same `HashKey` machinery `DISTINCT` inside an aggregate
9892/// call and `resolve_grouped_rows`' grouping already use, not `value_eq`'s
9893/// definite-equality-only comparison, since a `HashSet` needs `Hash` too).
9894/// Keeps the first occurrence of each distinct row, preserving order --
9895/// what every other DB's `DISTINCT` does, and what a human reading the
9896/// query would expect.
9897fn dedup_rows(rows: Vec<Vec<Value>>) -> Result<Vec<Vec<Value>>, QueryError> {
9898    let mut seen: HashSet<Vec<HashKey>> = HashSet::with_capacity(rows.len());
9899    let mut out = Vec::with_capacity(rows.len());
9900    for row in rows {
9901        let key = row
9902            .iter()
9903            .map(value_hash_key)
9904            .collect::<Result<Vec<_>, _>>()?;
9905        if seen.insert(key) {
9906            out.push(row);
9907        }
9908    }
9909    Ok(out)
9910}
9911
9912/// `WITH DISTINCT`'s result-set-level dedup -- same first-occurrence-wins
9913/// structural equality as `dedup_rows` (`RETURN DISTINCT`), but keyed at
9914/// the `Binding` level via `binding_hash_key` (node/edge identity, not
9915/// re-fetched contents) since a `WITH`-projected row can still carry a
9916/// real `Binding::Node`/`Edge` a later clause keeps traversing from,
9917/// unlike `RETURN`'s already-fully-evaluated `Value` rows.
9918fn dedup_binding_rows(
9919    items: &[ReturnItem],
9920    rows: Vec<BindingRow>,
9921) -> Result<Vec<BindingRow>, QueryError> {
9922    let names: Vec<String> = items
9923        .iter()
9924        .enumerate()
9925        .map(with_item_output_name)
9926        .collect();
9927    let mut seen: HashSet<Vec<HashKey>> = HashSet::with_capacity(rows.len());
9928    let mut out = Vec::with_capacity(rows.len());
9929    for row in rows {
9930        let key = names
9931            .iter()
9932            .map(|name| {
9933                binding_hash_key(row.get(name).unwrap_or_else(|| {
9934                    panic!("DISTINCT row missing its own projected column '{name}'")
9935                }))
9936            })
9937            .collect::<Result<Vec<_>, _>>()?;
9938        if seen.insert(key) {
9939            out.push(row);
9940        }
9941    }
9942    Ok(out)
9943}
9944
9945/// Sorts `keyed` (each entry paired with its precomputed per-column sort
9946/// keys) by `order_by`'s directions, keeping only the first `limit` items
9947/// when one is given and smaller than the row count. When it is, uses
9948/// `select_nth_unstable_by` to partition around the k-th smallest element
9949/// (O(n) average) and sorts only that k-sized prefix (O(k log k)), instead
9950/// of a full O(n log n) sort of every row just to immediately discard all
9951/// but the first few -- the "ORDER BY + LIMIT -> TOP-K" rewrite real query
9952/// engines apply. Shared by all three ORDER BY sites (`WITH`'s own,
9953/// non-aggregating `RETURN`'s, and aggregating `RETURN`'s), which otherwise
9954/// each build the identical `keyed`-then-sort shape around a different row
9955/// type.
9956/// Selects the top `skip + limit` elements by `order_by` (the
9957/// `select_nth_unstable_by` partial-selection optimization still applies
9958/// to that combined bound, not just `limit` alone), sorts just that
9959/// prefix, then drops the first `skip` of it — real Cypher's own
9960/// "SKIP applies after ORDER BY, LIMIT applies after SKIP" rule.
9961fn top_k_by<T>(
9962    mut keyed: Vec<(Vec<Value>, T)>,
9963    order_by: &[(ReturnExpr, SortDir)],
9964    skip: Option<i64>,
9965    limit: Option<i64>,
9966) -> Vec<(Vec<Value>, T)> {
9967    let cmp = |a: &(Vec<Value>, T), b: &(Vec<Value>, T)| -> std::cmp::Ordering {
9968        for (i, (_, dir)) in order_by.iter().enumerate() {
9969            let ord = compare_with_dir(&a.0[i], &b.0[i], *dir);
9970            if ord != std::cmp::Ordering::Equal {
9971                return ord;
9972            }
9973        }
9974        std::cmp::Ordering::Equal
9975    };
9976    let skip_n = skip.unwrap_or(0).max(0) as usize;
9977    match limit {
9978        Some(n) => {
9979            let k = skip_n + n.max(0) as usize;
9980            if k == 0 {
9981                keyed.clear();
9982            } else if k < keyed.len() {
9983                keyed.select_nth_unstable_by(k - 1, cmp);
9984                keyed.truncate(k);
9985                keyed.sort_by(cmp);
9986            } else {
9987                keyed.sort_by(cmp);
9988            }
9989        }
9990        None => keyed.sort_by(cmp),
9991    }
9992    if skip_n > 0 {
9993        keyed.drain(0..skip_n.min(keyed.len()));
9994    }
9995    keyed
9996}
9997
9998/// `Null` is just the highest-ranked type in `type_rank`'s total order
9999/// (see its docs), not a special case here -- confirmed via TCK's
10000/// `ReturnOrderBy1 [12]`/`WithOrderBy1 [22]` ("sort distinct types...
10001/// descending"), which expect `null` to sort *first* under `DESC`, not
10002/// last. An earlier version of this function hardcoded nulls-last
10003/// regardless of direction (citing Neo4j's docs); that's wrong per the
10004/// TCK's own evidence -- `DESC` is a real reversal of the whole order,
10005/// `null` included, not just of the non-null comparisons.
10006fn compare_with_dir(a: &Value, b: &Value, dir: SortDir) -> std::cmp::Ordering {
10007    let ord = compare_non_null(a, b);
10008    if dir == SortDir::Desc {
10009        ord.reverse()
10010    } else {
10011        ord
10012    }
10013}
10014
10015/// Real Cypher regards `NaN` as larger than every other number (confirmed
10016/// via TCK's `ReturnOrderBy1 [11]`/`[12]`: `NaN` sorts directly below
10017/// `null`, above every finite float, both ASC and DESC) -- plain
10018/// `f64::partial_cmp` returns `None` for any comparison involving `NaN`,
10019/// which `.unwrap_or(Ordering::Equal)` used to paper over by treating
10020/// `NaN` as *equal* to every number. That's not just cosmetically wrong:
10021/// a stable sort over a comparator that calls two genuinely-different
10022/// values "equal" preserves their original relative order instead of
10023/// actually ordering them, and `DESC`'s blanket `.reverse()` of an
10024/// "equal" result is still "equal" -- so `1.5`/`NaN` kept the same
10025/// relative order under both ASC and DESC, when DESC should have
10026/// swapped them.
10027fn cmp_f64_nan_greatest(x: f64, y: f64) -> std::cmp::Ordering {
10028    use std::cmp::Ordering;
10029    match (x.is_nan(), y.is_nan()) {
10030        (true, true) => Ordering::Equal,
10031        (true, false) => Ordering::Greater,
10032        (false, true) => Ordering::Less,
10033        (false, false) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
10034    }
10035}
10036
10037fn compare_non_null(a: &Value, b: &Value) -> std::cmp::Ordering {
10038    use std::cmp::Ordering;
10039    // Real Cypher orders two lists lexicographically (element-by-element,
10040    // shorter-is-less on a common prefix), a genuinely different rule
10041    // from any single scalar comparison -- delegate to its own recursive
10042    // comparator before reaching the scalar-only match below, which would
10043    // otherwise silently treat every pair of lists as "equal" (found via
10044    // TCK's ReturnOrderBy1 `[10]`/WithOrderBy1 `[10]`: `ORDER BY <list
10045    // column>` produced no reordering at all, ASC and DESC alike -- a
10046    // stable sort over an always-`Equal` comparator is a no-op).
10047    if let (Value::List(_), Value::List(_)) = (a, b) {
10048        return list_cmp_asc(a, b);
10049    }
10050    let pa = value_to_comparable(a);
10051    let pb = value_to_comparable(b);
10052    match (pa, pb) {
10053        (Some(PropertyValue::Int(x)), Some(PropertyValue::Int(y))) => x.cmp(&y),
10054        (Some(PropertyValue::Int(x)), Some(PropertyValue::Float(y))) => {
10055            cmp_f64_nan_greatest(x as f64, y)
10056        }
10057        (Some(PropertyValue::Float(x)), Some(PropertyValue::Int(y))) => {
10058            cmp_f64_nan_greatest(x, y as f64)
10059        }
10060        (Some(PropertyValue::Float(x)), Some(PropertyValue::Float(y))) => {
10061            cmp_f64_nan_greatest(x, y)
10062        }
10063        (Some(PropertyValue::String(x)), Some(PropertyValue::String(y))) => x.cmp(&y),
10064        (Some(PropertyValue::Bool(x)), Some(PropertyValue::Bool(y))) => x.cmp(&y),
10065        (Some(PropertyValue::Date(x)), Some(PropertyValue::Date(y))) => x.cmp(&y),
10066        (Some(PropertyValue::LocalTime(x)), Some(PropertyValue::LocalTime(y))) => x.cmp(&y),
10067        (
10068            Some(PropertyValue::Time {
10069                nanos_of_day: x,
10070                offset_seconds: ox,
10071            }),
10072            Some(PropertyValue::Time {
10073                nanos_of_day: y,
10074                offset_seconds: oy,
10075            }),
10076        ) => (x - ox as i64 * 1_000_000_000).cmp(&(y - oy as i64 * 1_000_000_000)),
10077        (
10078            Some(PropertyValue::LocalDateTime {
10079                epoch_seconds: xs,
10080                nanos: xn,
10081            }),
10082            Some(PropertyValue::LocalDateTime {
10083                epoch_seconds: ys,
10084                nanos: yn,
10085            }),
10086        ) => (xs, xn).cmp(&(ys, yn)),
10087        (
10088            Some(PropertyValue::DateTime {
10089                epoch_seconds: xs,
10090                nanos: xn,
10091                ..
10092            }),
10093            Some(PropertyValue::DateTime {
10094                epoch_seconds: ys,
10095                nanos: yn,
10096                ..
10097            }),
10098        ) => (xs, xn).cmp(&(ys, yn)),
10099        // Cross-type scalars (e.g. a String vs a Number) fall through to
10100        // `type_rank`'s real Cypher orderability rank rather than this
10101        // arm's own `Equal` fallback -- see `list_cmp_asc`, the only
10102        // caller that can actually produce a cross-type pair here (a
10103        // top-level ORDER BY key is already one uniform column in
10104        // practice, but a list's *elements* legitimately mix types, e.g.
10105        // `['a', 1]`).
10106        _ => match (type_rank(a), type_rank(b)) {
10107            (Some(ra), Some(rb)) if ra != rb => ra.cmp(&rb),
10108            _ => Ordering::Equal,
10109        },
10110    }
10111}
10112
10113/// Real Cypher's cross-type "orderability" rank (distinct from
10114/// `WHERE`'s three-valued comparison semantics) -- only covers the types
10115/// that can actually reach here with no same-type match already handling
10116/// them (see `compare_non_null`'s cross-type fallback and `list_cmp_asc`).
10117/// Order confirmed against a real TCK scenario (`ReturnOrderBy1`/
10118/// `WithOrderBy1`'s "sort distinct types" scenarios, only reachable once
10119/// `marsdb-tck`'s own harness could parse a path-shaped expected cell --
10120/// previously these scenarios could never even run): `Map < Node <
10121/// Relationship < List < Path < String < Boolean < Number`, `Null` always
10122/// last regardless (`compare_with_dir`'s own separate check). This is
10123/// also a fix, not just an addition -- `Bool`/`String` were previously
10124/// ranked in the wrong relative order (`Bool` before `String`; real
10125/// Cypher has `String` before `Bool`), and `List` sorting before every
10126/// scalar (confirmed separately, `max()`/`min()` over `[1, 'a', null,
10127/// [1, 2], 0.2, 'b']` picks `1` for max and `[1, 2]` for min) still
10128/// holds with `Map`/`Node`/`Relationship` now ranking below it too.
10129/// Temporal types (`Date`.../`Duration`) have no TCK evidence placing
10130/// them anywhere in this cross-type order -- kept after `Number` in
10131/// their pre-existing relative order among themselves, arbitrarily but
10132/// harmlessly (nothing tests a temporal-vs-Map-shaped ORDER BY column).
10133/// `Null` ranks highest of all -- also TCK-confirmed
10134/// (`ReturnOrderBy1 [11]`'s own expected order ends with `null` last),
10135/// and, critically, ranking it here rather than special-casing it in
10136/// `compare_with_dir` is what makes `DESC` correctly put `null` *first*
10137/// (`ReturnOrderBy1 [12]`/`WithOrderBy1 [22]`) -- a hardcoded
10138/// "nulls always last" rule would get the ascending case right and the
10139/// descending case wrong, since real Cypher's `DESC` is a genuine
10140/// reversal of the total order, not just of the non-null comparisons.
10141fn type_rank(v: &Value) -> Option<u8> {
10142    match v {
10143        Value::Map(_) => Some(0),
10144        Value::Node(_) => Some(1),
10145        Value::Edge(_) => Some(2),
10146        Value::List(_) => Some(3),
10147        Value::Path(_) => Some(4),
10148        Value::Literal(Literal::String(_)) | Value::Property(PropertyValue::String(_)) => Some(5),
10149        Value::Literal(Literal::Bool(_)) | Value::Property(PropertyValue::Bool(_)) => Some(6),
10150        Value::Literal(Literal::Int(_))
10151        | Value::Property(PropertyValue::Int(_))
10152        | Value::Literal(Literal::Float(_))
10153        | Value::Property(PropertyValue::Float(_)) => Some(7),
10154        Value::Property(PropertyValue::Date(_)) => Some(8),
10155        Value::Property(PropertyValue::LocalTime(_)) => Some(9),
10156        Value::Property(PropertyValue::Time { .. }) => Some(10),
10157        Value::Property(PropertyValue::LocalDateTime { .. }) => Some(11),
10158        Value::Property(PropertyValue::DateTime { .. }) => Some(12),
10159        Value::Null | Value::Literal(Literal::Null) | Value::Property(PropertyValue::Null) => {
10160            Some(13)
10161        }
10162        _ => None,
10163    }
10164}
10165
10166/// Ascending, element-by-element list comparison for ORDER BY, mirroring
10167/// `compare_with_dir`'s "null sorts last" rule recursively at every
10168/// position (deliberately *not* `value_partial_cmp`'s WHERE-filter
10169/// three-valued semantics, where a null anywhere makes the whole
10170/// comparison undecided instead of a definite presentation order) — a
10171/// shorter list that's a prefix of a longer one sorts first, same
10172/// convention `value_partial_cmp` already uses. `compare_with_dir`
10173/// reverses the *overall* result for `DESC`, not each element
10174/// individually — verified element-by-element against TCK's
10175/// ReturnOrderBy1 `[10]` ("ORDER BY DESC should order lists in the
10176/// expected order").
10177fn list_cmp_asc(a: &Value, b: &Value) -> std::cmp::Ordering {
10178    use std::cmp::Ordering;
10179    let a_null = matches!(a, Value::Null);
10180    let b_null = matches!(b, Value::Null);
10181    match (a_null, b_null) {
10182        (true, true) => return Ordering::Equal,
10183        (true, false) => return Ordering::Greater,
10184        (false, true) => return Ordering::Less,
10185        (false, false) => {}
10186    }
10187    if let (Value::List(xs), Value::List(ys)) = (a, b) {
10188        for (x, y) in xs.iter().zip(ys) {
10189            match list_cmp_asc(x, y) {
10190                Ordering::Equal => continue,
10191                other => return other,
10192            }
10193        }
10194        return xs.len().cmp(&ys.len());
10195    }
10196    compare_non_null(a, b)
10197}
10198
10199fn value_to_comparable(v: &Value) -> Option<PropertyValue> {
10200    match v {
10201        Value::Property(pv) => Some(pv.clone()),
10202        Value::Literal(lit) => Some(literal_to_value(lit)),
10203        _ => None,
10204    }
10205}
10206
10207/// Ordering for `min`/`max` aggregate folding — `None` for values with no
10208/// natural order (`Node`/`Edge`/`Map`/`Path`, or a `Null`, which
10209/// `AggAcc::fold` never passes here anyway since null contributions are
10210/// skipped before folding). The caller turns `None` into a clear error
10211/// rather than an arbitrary "always equal" fallback — unlike ORDER BY's
10212/// `compare_non_null`, which tolerates that for presentation ordering
10213/// (see its docs), silently treating two nodes as "equal" inside an
10214/// aggregate would be a wrong-answer failure mode, not just an
10215/// unhelpful sort order.
10216///
10217/// `List` *is* comparable here (real Cypher's `max()`/`min()` handle a
10218/// list argument, ordered element-by-element the same way ORDER BY
10219/// does — reuses `list_cmp_asc`), and so is a genuine cross-type pair
10220/// (`max()` over `[1, 'a', [1, 2]]`-shaped input), via the same
10221/// `type_rank` fallback `compare_non_null` uses.
10222pub(crate) fn comparable_ordering(a: &Value, b: &Value) -> Option<std::cmp::Ordering> {
10223    if let (Value::List(_), Value::List(_)) = (a, b) {
10224        return Some(list_cmp_asc(a, b));
10225    }
10226    let (pa, pb) = match (value_to_comparable(a), value_to_comparable(b)) {
10227        (Some(pa), Some(pb)) => (pa, pb),
10228        _ => {
10229            return match (type_rank(a), type_rank(b)) {
10230                // Different rank -- a real cross-type comparison (e.g. a
10231                // `List` vs a `String` inside a `max()` fold), safe to
10232                // order by rank.
10233                (Some(ra), Some(rb)) if ra != rb => Some(ra.cmp(&rb)),
10234                // Same rank only ever means both are `Map`/`Node`/`Edge`/
10235                // `Path` here (every type with a real per-value order
10236                // already matched via `value_to_comparable`'s `Some` case
10237                // above, `List` is handled separately at the top) --
10238                // those have no defined per-value order at all. Real for
10239                // ORDER BY's own use of `type_rank` (`compare_non_null`,
10240                // which tolerates "equal" for presentation purposes), but
10241                // silently treating two different `Map`s (or `Node`s,
10242                // ...) as "equal" here would be a wrong-answer failure
10243                // mode for an aggregate, not just an unhelpful sort
10244                // order -- `None` instead (see this function's own docs).
10245                _ => None,
10246            };
10247        }
10248    };
10249    Some(match (pa, pb) {
10250        (PropertyValue::Int(x), PropertyValue::Int(y)) => x.cmp(&y),
10251        (PropertyValue::Int(x), PropertyValue::Float(y)) => cmp_f64_nan_greatest(x as f64, y),
10252        (PropertyValue::Float(x), PropertyValue::Int(y)) => cmp_f64_nan_greatest(x, y as f64),
10253        (PropertyValue::Float(x), PropertyValue::Float(y)) => cmp_f64_nan_greatest(x, y),
10254        (PropertyValue::String(x), PropertyValue::String(y)) => x.cmp(&y),
10255        (PropertyValue::Bool(x), PropertyValue::Bool(y)) => x.cmp(&y),
10256        // `Duration` deliberately has no arm here (falls through to
10257        // `None` below) -- no defined ordering, only equality (see
10258        // `compare_values`'s docs on why months/days/seconds aren't
10259        // fungible enough to order against each other).
10260        (PropertyValue::Date(x), PropertyValue::Date(y)) => x.cmp(&y),
10261        (PropertyValue::LocalTime(x), PropertyValue::LocalTime(y)) => x.cmp(&y),
10262        (
10263            PropertyValue::Time {
10264                nanos_of_day: x,
10265                offset_seconds: ox,
10266            },
10267            PropertyValue::Time {
10268                nanos_of_day: y,
10269                offset_seconds: oy,
10270            },
10271        ) => (x - ox as i64 * 1_000_000_000).cmp(&(y - oy as i64 * 1_000_000_000)),
10272        (
10273            PropertyValue::LocalDateTime {
10274                epoch_seconds: xs,
10275                nanos: xn,
10276            },
10277            PropertyValue::LocalDateTime {
10278                epoch_seconds: ys,
10279                nanos: yn,
10280            },
10281        ) => (xs, xn).cmp(&(ys, yn)),
10282        (
10283            PropertyValue::DateTime {
10284                epoch_seconds: xs,
10285                nanos: xn,
10286                ..
10287            },
10288            PropertyValue::DateTime {
10289                epoch_seconds: ys,
10290                nanos: yn,
10291                ..
10292            },
10293        ) => (xs, xn).cmp(&(ys, yn)),
10294        _ => return None,
10295    })
10296}
10297
10298/// General `lhs op rhs` for `ReturnExpr::Compare` -- unlike `compare()`
10299/// (a `PropertyValue`-vs-`Literal` comparison for pattern-level `WHERE`,
10300/// where the RHS is always a literal), both sides here are already-
10301/// evaluated `Value`s, since either can be a *computed* result (e.g. two
10302/// `date(...)` calls) with no `Literal` able to stand in for it.
10303/// Three-valued like `compare()`: `None` (Cypher's "unknown") for a null
10304/// operand, an operator with no meaning for the operands' types (e.g. `<`
10305/// between two `Duration`s), or a type mismatch.
10306fn compare_values(a: &Value, op: CompareOp, b: &Value) -> Option<bool> {
10307    if matches!(a, Value::Null) || matches!(b, Value::Null) {
10308        return None;
10309    }
10310    match op {
10311        CompareOp::Eq => value_equal_ternary(a, b),
10312        CompareOp::Ne => value_equal_ternary(a, b).map(|eq| !eq),
10313        CompareOp::Lt => ordered_compare(a, b, |o| o == std::cmp::Ordering::Less),
10314        CompareOp::Le => ordered_compare(a, b, |o| o != std::cmp::Ordering::Greater),
10315        CompareOp::Gt => ordered_compare(a, b, |o| o == std::cmp::Ordering::Greater),
10316        CompareOp::Ge => ordered_compare(a, b, |o| o != std::cmp::Ordering::Less),
10317        CompareOp::StartsWith | CompareOp::EndsWith | CompareOp::Contains => {
10318            let (Some(s), Some(p)) = (as_arith_str(a), as_arith_str(b)) else {
10319                return None;
10320            };
10321            Some(match op {
10322                CompareOp::StartsWith => s.starts_with(p),
10323                CompareOp::EndsWith => s.ends_with(p),
10324                CompareOp::Contains => s.contains(p),
10325                _ => unreachable!("only StartsWith/EndsWith/Contains reach this arm"),
10326            })
10327        }
10328    }
10329}
10330
10331/// `<`/`<=`/`>`/`>=` -- numeric operands are special-cased (not folded
10332/// into `value_partial_cmp` below) specifically so `NaN` compares as a
10333/// definite `false` on every operator, matching real Cypher (`0.0/0.0 >
10334/// 1` is `false`, not `null`) -- verified against Comparison2's
10335/// "Comparing NaN" scenario, which is what exposed `comparable_ordering`'s
10336/// `unwrap_or(Equal)` silently making `NaN >= x`/`NaN <= x` both `true`.
10337/// Every other type (`List`, `Date`, `String`, `Bool`, ...) has no NaN-like
10338/// "exists but is unorderable" value, so `None` there really does mean
10339/// Cypher's ordinary "unknown" (a null operand, a null found while
10340/// lexicographically comparing two lists, or a genuine type mismatch),
10341/// not something to special-case to `false`.
10342fn ordered_compare(
10343    a: &Value,
10344    b: &Value,
10345    pred: impl Fn(std::cmp::Ordering) -> bool,
10346) -> Option<bool> {
10347    if let (Some(x), Some(y)) = (value_as_f64(a), value_as_f64(b)) {
10348        return Some(x.partial_cmp(&y).map(pred).unwrap_or(false));
10349    }
10350    value_partial_cmp(a, b).map(pred)
10351}
10352
10353/// `<`/`<=`/`>`/`>=` between two `List`s -- real Cypher orders lists
10354/// lexicographically: the first position where the two lists differ
10355/// decides the result; if every position up to the shorter list's length
10356/// is equal, the shorter list is "less". A `null` found at a
10357/// not-yet-decided position makes the *whole* comparison unknown (`None`)
10358/// -- lexicographic order can't skip past an undecided position to look
10359/// for a later one that happens to differ, since whether that later
10360/// position is even reached depends on what the undecided one turns out
10361/// to be. Verified element-by-element against every row of Comparison2's
10362/// "Comparing lists" scenario (`[1, 2] >= [1, null]` is `null`, not
10363/// `false`, even though `2 >= null` alone would also be `null` -- the
10364/// point is *why*: position 0 is equal, so position 1 is where the
10365/// answer would come from, and it's undecided). Delegates to
10366/// `comparable_ordering` for every non-list, non-numeric pair (`Date`,
10367/// `String`, `Bool`, ...), which has no list case to get wrong.
10368fn value_partial_cmp(a: &Value, b: &Value) -> Option<std::cmp::Ordering> {
10369    use std::cmp::Ordering;
10370    if matches!(a, Value::Null) || matches!(b, Value::Null) {
10371        return None;
10372    }
10373    if let (Value::List(xs), Value::List(ys)) = (a, b) {
10374        for (x, y) in xs.iter().zip(ys) {
10375            match value_partial_cmp(x, y) {
10376                Some(Ordering::Equal) => continue,
10377                other => return other,
10378            }
10379        }
10380        return Some(xs.len().cmp(&ys.len()));
10381    }
10382    // Real Cypher's `<`/`<=`/`>`/`>=` (unlike ORDER BY/`min`/`max`, which
10383    // need a *total* order across every type for presentation purposes --
10384    // see `comparable_ordering`'s own docs) is only ever defined within a
10385    // single comparable type. A genuine cross-type pair (a list against a
10386    // string, a node against a number, ...) must be `null`, not
10387    // `comparable_ordering`'s type-rank fallback -- that fallback exists
10388    // purely for `list_cmp_asc`/`min`/`max`'s total-order needs and must
10389    // not leak into a real WHERE-predicate comparison. Verified against
10390    // Comparison2's own "Comparing across types yields null, except
10391    // numbers" scenario (`[] < 1`/`[] < ''`/`[] < true` were all wrongly
10392    // `true` before this check, since `[]` alone -- not both sides --
10393    // isn't `Value`-to-`PropertyValue` representable, falling through to
10394    // the type-rank fallback).
10395    if value_to_comparable(a).is_none() || value_to_comparable(b).is_none() {
10396        return None;
10397    }
10398    comparable_ordering(a, b)
10399}
10400
10401/// `=`/`<>`'s equality -- three-valued (`None` is Cypher's "unknown"),
10402/// recursing into `List`/`Map` element-by-element so a `null` *inside* a
10403/// list/map only makes the overall result unknown when it actually
10404/// matters, not automatically `false`/`true`: a length/key-set mismatch
10405/// is `false` outright (definite, regardless of any null present --
10406/// `{k: null} = {}` is `false`, not `null`, since the key sets alone
10407/// already prove inequality), a definite element mismatch anywhere makes
10408/// the whole comparison `false` (short-circuits, `false` outranks
10409/// `unknown` the same way `and3`/`or3` already rank them), and only once
10410/// every element is confirmed equal or unknown (never definitely
10411/// unequal) does an unknown element propagate to an unknown overall
10412/// result. Verified against every row of List3's and Comparison1's
10413/// list/map equality scenarios. Scalars fall back to numeric-cross-type-
10414/// aware equality (`1 = 1.0` is `true`, unlike `value_eq`'s plain
10415/// `PropertyValue` equality, which doesn't promote `Int`/`Float` against
10416/// each other) or plain `value_eq` for everything else (`Date`,
10417/// `Duration`'s component equality, `Node`/`Edge` identity, ...).
10418fn value_equal_ternary(a: &Value, b: &Value) -> Option<bool> {
10419    match (a, b) {
10420        (Value::Null, _) | (_, Value::Null) => None,
10421        (Value::List(xs), Value::List(ys)) => {
10422            if xs.len() != ys.len() {
10423                return Some(false);
10424            }
10425            fold_ternary_eq(xs.iter().zip(ys).map(|(x, y)| value_equal_ternary(x, y)))
10426        }
10427        (Value::Map(x), Value::Map(y)) => {
10428            if !x.keys().eq(y.keys()) {
10429                return Some(false);
10430            }
10431            fold_ternary_eq(x.iter().map(|(k, xv)| value_equal_ternary(xv, &y[k])))
10432        }
10433        _ => Some(values_equal_numeric_aware(a, b)),
10434    }
10435}
10436
10437/// `needle IN haystack` -- three-valued like `=`, since it's built from
10438/// `=` per element: a definite match wins outright even past a later
10439/// `null` element (short-circuits, matching `and3`/`or3`'s "false/true
10440/// outranks unknown" convention), no match with at least one `null`
10441/// element compared along the way is "unknown" (not `false` -- that
10442/// element *might* have matched), no match and no `null` anywhere is a
10443/// definite `false`. An empty list is always a definite `false`
10444/// regardless of `needle`'s own nullness (nothing to compare against, no
10445/// unknown comparisons ever happened) -- verified against Comparison5's
10446/// exact empty-list scenarios. `haystack` being `Null` itself (not an
10447/// empty list) is "unknown", matching `=`'s own null-operand rule;
10448/// anything else on the right isn't a list at all, a real type error.
10449fn list_membership_ternary(needle: &Value, haystack: &Value) -> Result<Option<bool>, QueryError> {
10450    match haystack {
10451        Value::Null => Ok(None),
10452        Value::List(items) => {
10453            let mut saw_unknown = false;
10454            for item in items {
10455                match value_equal_ternary(needle, item) {
10456                    Some(true) => return Ok(Some(true)),
10457                    Some(false) => {}
10458                    None => saw_unknown = true,
10459                }
10460            }
10461            Ok(if saw_unknown { None } else { Some(false) })
10462        }
10463        other => Err(QueryError::Type(format!(
10464            "IN requires a list on the right-hand side, got {other:?}"
10465        ))),
10466    }
10467}
10468
10469/// Combines a sequence of per-element three-valued equality results into
10470/// one overall result: any definite `Some(false)` wins outright
10471/// (short-circuits), otherwise `Some(true)` only if every element was a
10472/// definite `Some(true)`, else `None` (at least one element's equality
10473/// was itself unknown, and nothing else disproved the match).
10474fn fold_ternary_eq(mut results: impl Iterator<Item = Option<bool>>) -> Option<bool> {
10475    let mut saw_unknown = false;
10476    for r in results.by_ref() {
10477        match r {
10478            Some(false) => return Some(false),
10479            Some(true) => {}
10480            None => saw_unknown = true,
10481        }
10482    }
10483    if saw_unknown {
10484        None
10485    } else {
10486        Some(true)
10487    }
10488}
10489
10490/// `=`/`<>`'s scalar leaf case: numeric cross-type promotion (`1 = 1.0`
10491/// is `true` in real Cypher, matching `compare()`'s existing `Int`-vs-
10492/// `Float` handling) that `value_eq`'s plain `PropertyValue` equality
10493/// doesn't give (`PropertyValue::Int(1) != PropertyValue::Float(1.0)`,
10494/// different enum variants) -- falls back to `value_eq` for every non-
10495/// numeric pair (`Date`, `Duration`'s component equality, `String`,
10496/// `Bool`, `Node`/`Edge` identity, ...), which is already correct for
10497/// those.
10498fn values_equal_numeric_aware(a: &Value, b: &Value) -> bool {
10499    match (as_arith_num(a), as_arith_num(b)) {
10500        (Some(ArithNum::Int(x)), Some(ArithNum::Int(y))) => x == y,
10501        (Some(ArithNum::Int(x)), Some(ArithNum::Float(y)))
10502        | (Some(ArithNum::Float(y)), Some(ArithNum::Int(x))) => x as f64 == y,
10503        (Some(ArithNum::Float(x)), Some(ArithNum::Float(y))) => x == y,
10504        _ => value_eq(a, b),
10505    }
10506}