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