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