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