marsdb_query/semantic.rs
1//! Statement-level name binding and structural type validation.
2//!
3//! This pass runs after parsing/parameter substitution and before a storage
4//! transaction is opened. It deliberately validates only types knowable from
5//! query structure (node, relationship, list, map, path, scalar); property
6//! value types remain data-dependent runtime checks.
7
8use std::collections::HashMap;
9
10use crate::ast::{
11 is_aggregate_name, ArithOp, CallClause, CallYield, Expr, Literal, MergeClause, NodePattern,
12 Pattern, QueryClause, RemoveItem, ReturnExpr, ReturnItem, ReturnTail, SetItem, Statement, Tail,
13 UnwindClause, WithClause, WithExpr,
14};
15use crate::QueryError;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18enum Kind {
19 Node,
20 Edge,
21 Scalar,
22 List(Box<Kind>),
23 Map,
24 Path,
25 Unknown,
26}
27
28type Scope = HashMap<String, Kind>;
29
30pub fn validate_statement(statement: &Statement) -> Result<(), QueryError> {
31 match statement {
32 // Session-level statements bind nothing and reference nothing --
33 // whether one is *valid right now* (e.g. `COMMIT` with no open
34 // transaction) is session state, which is `marsdb::Database`'s
35 // to check, not a static property of the statement.
36 Statement::Begin | Statement::Commit | Statement::Rollback => Ok(()),
37 Statement::Create(patterns) => {
38 let mut scope = Scope::new();
39 for pattern in patterns {
40 bind_create_pattern(pattern, &mut scope)?;
41 }
42 Ok(())
43 }
44 // No pattern/expression scoping to validate -- label/prop are
45 // plain identifiers.
46 Statement::CreateIndex { .. } => Ok(()),
47 Statement::Explain(inner) => validate_statement(inner),
48 // Each part is independently scoped (no bindings shared across a
49 // UNION boundary), so each just gets its own ordinary validation
50 // pass -- the one UNION-specific check (every part's columns must
51 // match) needs each part's real, evaluated `QueryResult.columns`,
52 // which doesn't exist yet at this pre-execution stage, so it lives
53 // in `executor::materialize_union` instead.
54 Statement::Union { parts, .. } => {
55 for part in parts {
56 validate_statement(part)?;
57 }
58 Ok(())
59 }
60 Statement::Match {
61 clauses,
62 tail,
63 order_by,
64 ..
65 } => validate_match_clauses(clauses, tail, order_by, Scope::new(), true),
66 // Always an empty starting scope -- a standalone CALL *is* the
67 // whole statement, nothing precedes it to shadow (unlike
68 // `QueryClause::Call`'s own in-query form, TCK's Call1 `[15]`).
69 Statement::StandaloneCall(call) => validate_call_clause(call, &mut Scope::new()),
70 }
71}
72
73/// `CALL proc.name(args) [YIELD ...]` -- no procedure registry is
74/// available at this pass (see `executor::ExecutionOptions::procedures`'s
75/// own docs for why arity/existence/argument-type checks have to happen
76/// at execution time instead, once the registry is on hand), so this only
77/// covers what's knowable from AST structure alone: an aggregate inside
78/// an argument expression (real Cypher's `InvalidAggregation`, TCK's
79/// Call1 `[16]`) and a `YIELD` output name that's already bound --
80/// shadowing an outer variable or repeating an earlier item's own output
81/// name within the same `YIELD` are the same check, since `scope` is
82/// mutated as each item is processed (real Cypher's
83/// `VariableAlreadyBound`, TCK's Call1 `[15]`, Call5 `[5]`/`[6]`).
84/// `CallYield::Star` is never reached with a non-empty `scope` in
85/// practice -- the in-query grammar (`queryCallSt`) has no `YIELD *`
86/// alternative at all (only `standaloneCall` does), so it can't shadow
87/// anything; nothing here needs a procedure's real output names to bind
88/// into scope for it either way, since a standalone call is the whole
89/// statement.
90fn validate_call_clause(call: &CallClause, scope: &mut Scope) -> Result<(), QueryError> {
91 if let Some(args) = &call.args {
92 for arg in args {
93 infer_expr(arg, scope)?;
94 if crate::executor::contains_aggregate(arg) {
95 return Err(semantic(
96 "an aggregate function can't be used as a CALL argument",
97 ));
98 }
99 }
100 }
101 if let Some(CallYield::Items(items, where_expr)) = &call.yield_items {
102 for (name, alias) in items {
103 let out_name = alias.clone().unwrap_or_else(|| name.clone());
104 if scope.contains_key(&out_name) {
105 return Err(semantic(format!(
106 "'{out_name}' is already bound -- CALL's YIELD can't reuse an already-bound \
107 name, whether from an outer scope or another output in the same YIELD"
108 )));
109 }
110 scope.insert(out_name, Kind::Unknown);
111 }
112 if let Some(w) = where_expr.as_deref() {
113 validate_pattern_expr(w, scope)?;
114 }
115 }
116 Ok(())
117}
118
119/// The body of `Statement::Match`'s own validation, factored out so
120/// `Expr::ExistsSubquery` (a nested `exists { MATCH ... RETURN ... }`, TCK's
121/// ExistentialSubquery2/3) can reuse it correlated against the enclosing
122/// scope instead of a fresh one, with `allow_mutation: false` -- real
123/// Cypher only allows *reading* clauses inside `exists {}` (an updating
124/// clause there is a compile-time `InvalidClauseComposition`, TCK's
125/// ExistentialSubquery2 `[3]`).
126fn validate_match_clauses(
127 clauses: &[QueryClause],
128 tail: &Option<Tail>,
129 order_by: &Option<Vec<(ReturnExpr, crate::ast::SortDir)>>,
130 mut scope: Scope,
131 allow_mutation: bool,
132) -> Result<(), QueryError> {
133 let reject_mutation = |clause_name: &str| -> Result<(), QueryError> {
134 if allow_mutation {
135 Ok(())
136 } else {
137 Err(semantic(format!(
138 "exists {{}} can't contain an updating clause ({clause_name}) -- only reading \
139 clauses (MATCH/UNWIND/WITH) are allowed inside it"
140 )))
141 }
142 };
143 for clause in clauses {
144 match clause {
145 QueryClause::Match(part) => {
146 let prior_scope = scope.clone();
147 bind_match_pattern(&part.pattern, &mut scope)?;
148 if part.shortest_path {
149 let start = part.pattern.start.var.as_deref().ok_or_else(|| {
150 semantic("shortestPath() start node must have a variable")
151 })?;
152 let end = part
153 .pattern
154 .hops
155 .first()
156 .and_then(|(_, node)| node.var.as_deref())
157 .ok_or_else(|| semantic("shortestPath() end node must have a variable"))?;
158 require_kind(&prior_scope, start, &Kind::Node, "shortestPath endpoint")?;
159 require_kind(&prior_scope, end, &Kind::Node, "shortestPath endpoint")?;
160 }
161 if let Some(path_var) = &part.path_var {
162 bind_kind(&mut scope, path_var, Kind::Path, "path variable")?;
163 }
164 if let Some(expr) = &part.where_clause {
165 validate_pattern_expr(expr, &scope)?;
166 }
167 apply_with(&part.with, &mut scope)?;
168 }
169 QueryClause::Unwind(clause) => bind_unwind(clause, &mut scope)?,
170 QueryClause::Merge(clause) => {
171 reject_mutation("MERGE")?;
172 bind_merge(clause, &mut scope)?
173 }
174 QueryClause::With(with) => scope = project_with(with, &scope)?,
175 QueryClause::Set(items) => {
176 reject_mutation("SET")?;
177 for item in items {
178 validate_set_item(item, &scope)?;
179 }
180 }
181 QueryClause::Delete { items, detach: _ } => {
182 reject_mutation("DELETE")?;
183 for expr in items {
184 validate_delete_target(expr, &scope)?;
185 }
186 }
187 QueryClause::Remove(items) => {
188 reject_mutation("REMOVE")?;
189 for item in items {
190 validate_remove_item(item, &scope)?;
191 }
192 }
193 QueryClause::Create(patterns) => {
194 reject_mutation("CREATE")?;
195 for pattern in patterns {
196 bind_create_pattern(pattern, &mut scope)?;
197 }
198 }
199 // A procedure is opaque to MarsDB -- it might write, same
200 // conservative reasoning `executor::is_read_only` already
201 // applies -- so it's rejected inside `exists {}` too, even
202 // though no current TCK scenario combines the two.
203 QueryClause::Call(call) => {
204 reject_mutation("CALL")?;
205 validate_call_clause(call, &mut scope)?;
206 apply_with(&call.with, &mut scope)?;
207 }
208 }
209 }
210
211 let input_scope = scope.clone();
212 let output_scope = validate_tail(tail, &mut scope, allow_mutation)?;
213 if let Some(order_by) = order_by {
214 let mut order_scope = input_scope;
215 order_scope.extend(output_scope);
216 // Real Cypher: an aggregate in RETURN's ORDER BY is only
217 // legal when RETURN itself is aggregating (the ORDER BY
218 // then runs against the already-collapsed grouped rows,
219 // same as its own WITH/RETURN items would) -- otherwise
220 // it's a compile-time `InvalidAggregation` error (TCK's
221 // ReturnOrderBy2 [14]), not a runtime one.
222 let tail_items: Option<&[ReturnItem]> = match tail {
223 Some(Tail::Return(items, _)) => Some(items),
224 _ => None,
225 };
226 let tail_aggregates = tail_items.is_some_and(crate::executor::has_aggregate);
227 for (expr, _) in order_by {
228 if tail_aggregates {
229 // An ORDER BY item that repeats a RETURN item's
230 // expression *or own alias* (`RETURN sum(x) AS s
231 // ORDER BY sum(x)` / `ORDER BY s`, TCK's
232 // WithOrderBy4 [11]/`ReturnOrderBy3`/
233 // `WithSkipLimit1 [2]`) refers to that
234 // already-aggregated item, not a fresh expression
235 // -- its kind is already known from
236 // `output_scope`, and re-running `infer_expr` on
237 // it would need pre-aggregation bindings (like
238 // `x`'s row) that no longer exist post-grouping.
239 // Unlike `validate_composed_expr`'s own *nested*-
240 // leaf check just below, this whole-expression
241 // match doesn't exclude aggregating items -- an
242 // aggregate's own alias referenced *directly* (not
243 // buried inside a larger expression) is exactly
244 // "reuse this item's already-finished value",
245 // which `materialize_aggregating_return_with_
246 // order`'s matching top-level lookup (executor.rs)
247 // handles the same way.
248 if tail_items
249 .unwrap()
250 .iter()
251 .enumerate()
252 .any(|(i, item)| crate::executor::item_matches_leaf(expr, i, item))
253 {
254 continue;
255 }
256 // Not a verbatim match -- may still be a *composed*
257 // expression (an aggregate combined with other
258 // values, or a plain non-aggregate expression
259 // referencing a pre-aggregation variable, TCK's
260 // ReturnOrderBy6) that `resolve_grouped_rows`/
261 // `rewrite_composed_item` (executor.rs) can
262 // evaluate the same way a composed RETURN item
263 // would -- validated the same way, by the same
264 // function, rather than `infer_expr` against a
265 // scope that structurally can't have pre-
266 // aggregation bindings in it anymore.
267 crate::executor::validate_order_by_composed_expr(expr, tail_items.unwrap())?;
268 continue;
269 }
270 if crate::executor::contains_aggregate(expr) {
271 return Err(semantic(
272 "ORDER BY cannot use an aggregate function unless RETURN itself \
273 is aggregating",
274 ));
275 }
276 infer_expr(expr, &order_scope)?;
277 }
278 }
279 Ok(())
280}
281
282fn bind_unwind(clause: &UnwindClause, scope: &mut Scope) -> Result<(), QueryError> {
283 let source_kind = infer_expr(&clause.source.0, scope)?;
284 let element_kind = match source_kind {
285 Kind::List(element) => *element,
286 // `Scalar` is deliberately not rejected here -- most function
287 // calls (`infer_expr`'s own `Call` arm) type as `Scalar` even
288 // when they in fact return a list at runtime (this codebase's
289 // `Kind` system doesn't model every builtin's real return shape),
290 // so treating it as "unknown, defer to the real runtime
291 // Value::List check in eval_unwind" avoids rejecting legitimate
292 // queries the semantic layer just can't see through.
293 Kind::Unknown | Kind::Scalar => Kind::Unknown,
294 other => {
295 return Err(semantic(format!(
296 "UNWIND source is {}, not a list",
297 kind_name(&other)
298 )))
299 }
300 };
301 scope.insert(clause.var.clone(), element_kind);
302 if let Some(expr) = &clause.where_clause {
303 validate_with_expr(expr, scope)?;
304 }
305 apply_with(&clause.with, scope)
306}
307
308fn bind_merge(clause: &MergeClause, scope: &mut Scope) -> Result<(), QueryError> {
309 let pattern = &clause.pattern;
310 // A bare already-bound node with no relationship at all (`MATCH (a)
311 // MERGE (a)`) does nothing real -- not searching for or creating
312 // anything, just re-stating a var that already exists. A bound start
313 // node used as a relationship endpoint (`MATCH (a) MERGE (a)-[:T]->
314 // (b)`) stays legitimate -- only checked when there are no hops at
315 // all. Checked here (compile time, TCK's Merge1 [15]), not only at
316 // runtime -- a zero-row MATCH would otherwise skip this entirely
317 // even though real Cypher's `VariableAlreadyBound` is a
318 // structural/scope error, not a data-dependent one.
319 if pattern.hops.is_empty() {
320 if let Some(var) = &pattern.start.var {
321 if pattern.start.labels.is_empty()
322 && !pattern.start.has_explicit_props
323 && scope.contains_key(var)
324 {
325 return Err(semantic(format!(
326 "'{var}' is already bound — MERGE ({var}) with no relationship and no \
327 labels/properties doesn't search for or create anything"
328 )));
329 }
330 }
331 }
332 // Same reasoning as CREATE's own node check -- MERGE might need to
333 // *create* any node its pattern names, so an already-bound node can't
334 // also carry a new label/property predicate (TCK's Merge5 [22]). The
335 // hopless-and-predicate-free case just above has its own, more
336 // specific message; this covers every other node token, start and hop
337 // ends alike.
338 check_no_new_predicates_on_bound_node(&pattern.start, scope, "MERGE")?;
339 for (_, node) in &pattern.hops {
340 check_no_new_predicates_on_bound_node(node, scope, "MERGE")?;
341 }
342 // Unlike a node endpoint (which can legitimately reference an
343 // already-bound node to search/create from), MERGE never reuses an
344 // already-bound relationship as its own pattern token -- there's no
345 // "search using this specific existing edge" mode (TCK's Merge5
346 // [26]).
347 for (rel, _) in &pattern.hops {
348 if let Some(var) = &rel.var {
349 if scope.contains_key(var) {
350 return Err(semantic(format!(
351 "'{var}' is already bound — MERGE can't reuse an existing relationship \
352 variable as its own pattern token"
353 )));
354 }
355 }
356 // Same reasoning as CREATE's own check -- MERGE might need to
357 // *create* this relationship on no-match, and a brand new edge
358 // with no type (or more than one -- which one would it get?) is
359 // meaningless (TCK's Merge5 [24]).
360 if rel.rel_types.len() != 1 {
361 return Err(semantic(
362 "MERGE requires exactly one explicit relationship type (e.g. -[:KNOWS]->) -- an \
363 untyped or multi-typed relationship pattern can't be created if the MERGE \
364 doesn't find a match",
365 ));
366 }
367 }
368 bind_match_pattern(pattern, scope)?;
369 if let Some(path_var) = &clause.path_var {
370 bind_kind(scope, path_var, Kind::Path, "path variable")?;
371 }
372 for item in clause.on_create.iter().chain(&clause.on_match) {
373 validate_set_item(item, scope)?;
374 }
375 apply_with(&clause.with, scope)
376}
377
378fn bind_match_pattern(pattern: &Pattern, scope: &mut Scope) -> Result<(), QueryError> {
379 if let Some(var) = &pattern.start.var {
380 bind_kind(scope, var, Kind::Node, "node pattern")?;
381 }
382 for (rel, node) in &pattern.hops {
383 if let Some(var) = &rel.var {
384 // A variable-length hop's own `rel.var` binds a *list* of
385 // relationships (`[r:TYPE*1..3]`), not a single edge --
386 // TCK's Match4 `[1]`/`[6]`.
387 let kind = if rel.hop_range.is_some() {
388 Kind::List(Box::new(Kind::Edge))
389 } else {
390 Kind::Edge
391 };
392 bind_kind(scope, var, kind, "relationship pattern")?;
393 }
394 if let Some(var) = &node.var {
395 bind_kind(scope, var, Kind::Node, "node pattern")?;
396 }
397 }
398 Ok(())
399}
400
401fn bind_create_pattern(pattern: &Pattern, scope: &mut Scope) -> Result<(), QueryError> {
402 validate_props(&pattern.start.props, scope)?;
403 check_create_node_not_already_bound(&pattern.start, scope, pattern.hops.is_empty())?;
404 if let Some(var) = &pattern.start.var {
405 bind_kind(scope, var, Kind::Node, "CREATE node")?;
406 }
407 for (rel, node) in &pattern.hops {
408 validate_props(&node.props, scope)?;
409 check_create_node_not_already_bound(node, scope, false)?;
410 if let Some(var) = &node.var {
411 bind_kind(scope, var, Kind::Node, "CREATE node")?;
412 }
413 // Unlike MATCH (where an untyped/multi-typed hop just means "any
414 // of these"), CREATE always makes exactly one new relationship,
415 // and a brand new edge needs exactly one type -- real Cypher
416 // requires a single explicit `:TYPE` here, never inferred,
417 // defaulted, or a `|`-alternative list.
418 if rel.rel_types.len() != 1 {
419 return Err(semantic(
420 "CREATE requires exactly one explicit relationship type (e.g. -[:KNOWS]->) -- \
421 unlike MATCH, an untyped or multi-typed relationship pattern can't be created",
422 ));
423 }
424 validate_props(&rel.props, scope)?;
425 if let Some(var) = &rel.var {
426 bind_kind(scope, var, Kind::Edge, "CREATE relationship")?;
427 }
428 }
429 Ok(())
430}
431
432/// Mirrors `Executor::resolve_or_create_node`'s already-bound rejection
433/// at compile time -- a node token naming a variable already in `scope`
434/// either does nothing real (`is_bare`: no relationship, no new
435/// labels/props -- `MATCH (a) CREATE (a)`) or would silently drop
436/// user-written labels/props onto an existing node (any hop count --
437/// `MATCH (a) CREATE (a {x: 1})`). Checked here, not only at runtime --
438/// a zero-row MATCH would otherwise skip this entirely even though real
439/// Cypher's `VariableAlreadyBound` is a structural/scope error, not a
440/// data-dependent one (TCK's Create1 [13]/[14]).
441fn check_create_node_not_already_bound(
442 node: &NodePattern,
443 scope: &Scope,
444 is_bare: bool,
445) -> Result<(), QueryError> {
446 let Some(var) = &node.var else {
447 return Ok(());
448 };
449 if !scope.contains_key(var) {
450 return Ok(());
451 }
452 if is_bare && node.labels.is_empty() && !node.has_explicit_props {
453 return Err(semantic(format!(
454 "'{var}' is already bound — CREATE ({var}) with no relationship and no new \
455 labels/properties doesn't create or connect anything"
456 )));
457 }
458 check_no_new_predicates_on_bound_node(node, scope, "CREATE")
459}
460
461/// Shared by CREATE (via `check_create_node_not_already_bound` above) and
462/// MERGE (`bind_merge`, for each of its own node endpoints) -- both might
463/// need to *create* a node the pattern names, so a variable already bound
464/// to an *existing* node can't also carry a new label/property predicate
465/// (would silently drop it on match, or ambiguously decide whether it
466/// applies on create) (TCK's Create1 `[19]`/Merge5 `[22]`). Unlike
467/// `check_create_node_not_already_bound`, this alone doesn't also cover
468/// the "no relationship and no predicates at all" case -- CREATE and
469/// MERGE phrase that differently (MERGE's own bare-node check lives in
470/// `bind_merge`, keyed off `pattern.hops.is_empty()` the same way).
471fn check_no_new_predicates_on_bound_node(
472 node: &NodePattern,
473 scope: &Scope,
474 verb: &str,
475) -> Result<(), QueryError> {
476 let Some(var) = &node.var else {
477 return Ok(());
478 };
479 if !scope.contains_key(var) {
480 return Ok(());
481 }
482 if !node.labels.is_empty() || node.has_explicit_props {
483 return Err(semantic(format!(
484 "'{var}' is already bound — {verb} can't add labels/properties to an existing node"
485 )));
486 }
487 Ok(())
488}
489
490fn validate_props(props: &[(String, ReturnExpr)], scope: &Scope) -> Result<(), QueryError> {
491 for (_, expr) in props {
492 infer_expr(expr, scope)?;
493 }
494 Ok(())
495}
496
497fn apply_with(with: &Option<WithClause>, scope: &mut Scope) -> Result<(), QueryError> {
498 if let Some(with) = with {
499 *scope = project_with(with, scope)?;
500 }
501 Ok(())
502}
503
504fn project_with(with: &WithClause, input: &Scope) -> Result<Scope, QueryError> {
505 // `WITH *` -- `input` already reflects this same clause's own new
506 // bindings (`bind_match_pattern`/`bind_unwind`/`bind_merge` all
507 // mutate `scope` before calling `apply_with`), so no union with
508 // anything else is needed here, unlike `executor::
509 // apply_with_or_carry`'s own `carried_vars`/`new_vars` split.
510 let with_owned;
511 let with: &WithClause = if with.star {
512 let star_items = crate::executor::with_star_items(input.keys().cloned());
513 let mut owned = with.clone();
514 let mut items = star_items;
515 items.extend(owned.items);
516 owned.items = items;
517 with_owned = owned;
518 &with_owned
519 } else {
520 with
521 };
522 crate::executor::validate_return_items(&with.items)?;
523 let mut projected = Scope::new();
524 for (index, item) in with.items.iter().enumerate() {
525 // Unlike RETURN (where an unaliased expression just gets an
526 // auto-generated column name, e.g. `RETURN 1+1`), every WITH
527 // item that isn't a bare variable reference must have an
528 // explicit `AS alias` -- real Cypher's `NoExpressionAlias`
529 // error. A bare `Var` needs none since its own name already is
530 // the alias (`WITH a` carries `a` forward as itself).
531 if item.alias.is_none() && !matches!(item.expr, ReturnExpr::Var(_)) {
532 return Err(semantic(
533 "WITH requires an alias (AS ...) for every item except a bare variable reference",
534 ));
535 }
536 let kind = infer_expr(&item.expr, input)?;
537 let name = item_output_name(index, item);
538 if projected.insert(name.clone(), kind).is_some() {
539 return Err(semantic(format!(
540 "WITH projects duplicate variable '{name}'"
541 )));
542 }
543 }
544 if let Some(expr) = &with.where_clause {
545 // Real Cypher lets `WITH x AS y WHERE ...` see both the pre-WITH
546 // binding (`x`) and the new alias (`y`) -- matches the merged-row
547 // evaluation `executor::materialize_with` does at runtime for the
548 // same reason (see its docs). Only aggregation collapses rows
549 // ambiguously here, not `DISTINCT` -- `WHERE` runs *before*
550 // `DISTINCT`'s own dedup (`materialize_with` filters first, then
551 // dedups the survivors), so every row WHERE sees still has its
552 // own single, unambiguous pre-WITH binding (TCK's WithWhere1
553 // `[2]`: `WITH DISTINCT a.name2 AS name WHERE a.name2 = 'B'`).
554 if crate::executor::has_aggregate(&with.items) {
555 validate_with_expr(expr, &projected)?;
556 } else {
557 let mut merged = input.clone();
558 merged.extend(projected.iter().map(|(k, v)| (k.clone(), v.clone())));
559 validate_with_expr(expr, &merged)?;
560 }
561 }
562 if let Some(order_by) = &with.order_by {
563 // Same `InvalidAggregation` rule as RETURN's own ORDER BY (see the
564 // `Statement::Match` arm above) -- TCK's WithOrderBy2 [25].
565 let with_aggregates = crate::executor::has_aggregate(&with.items);
566 // Real Cypher lets a non-aggregating, non-`DISTINCT` `WITH`'s own
567 // `ORDER BY` see both the pre-WITH scope and the new aliases, not
568 // just the projected names (`WITH a.count AS count ORDER BY
569 // a.count` -- `a` isn't projected but is still a valid sort key,
570 // TCK's With4 [6]/WithSkipLimit3 [3]/Return4 [9,11]) -- same
571 // merged-scope reasoning `where_clause` above already has, and
572 // for the identical reason: aggregation/`DISTINCT` both collapse
573 // many pre-WITH rows into one output row, so there's no single
574 // pre-WITH scope left to fall back to there.
575 let order_scope = if with_aggregates || with.distinct {
576 projected.clone()
577 } else {
578 let mut merged = input.clone();
579 merged.extend(projected.iter().map(|(k, v)| (k.clone(), v.clone())));
580 merged
581 };
582 for (expr, _) in order_by {
583 // Repeating a WITH item's expression *or own alias* verbatim
584 // (see the matching comment on the RETURN side) -- WithOrderBy4
585 // [11]/WithSkipLimit1 [2]. Applies to `DISTINCT` too, not just
586 // aggregation: both collapse many pre-WITH rows into one
587 // output row (that's exactly why `order_scope` above is
588 // `projected`-only for either), so a `DISTINCT`-only `WITH`'s
589 // `ORDER BY` needs the same shortcut to see its own item's
590 // alias instead of failing to resolve a pre-WITH variable it
591 // doesn't have access to (TCK's WithOrderBy2 [24] -- previously
592 // this shortcut only fired `if with_aggregates`, a real gap: a
593 // non-aggregating `DISTINCT` WITH's `order_scope` was *also*
594 // narrowed to `projected`-only above, just without this
595 // matching escape hatch).
596 if (with_aggregates || with.distinct)
597 && with
598 .items
599 .iter()
600 .enumerate()
601 .any(|(i, item)| crate::executor::item_matches_leaf(expr, i, item))
602 {
603 continue;
604 }
605 if with_aggregates {
606 // Not a verbatim match -- may still be a *composed*
607 // expression `resolve_grouped_rows`/`rewrite_composed_
608 // item` (executor.rs) can evaluate the same way a
609 // composed WITH item would, same reasoning as the
610 // matching RETURN-side check above (TCK's WithOrderBy4
611 // [16]-[18]). A `DISTINCT`-only (non-aggregating) WITH has
612 // no such per-group evaluator to fall back to, so that
613 // case still just falls through to `infer_expr` below,
614 // which correctly fails on anything past its own
615 // `projected`-only scope.
616 crate::executor::validate_order_by_composed_expr(expr, &with.items)?;
617 continue;
618 }
619 if crate::executor::contains_aggregate(expr) {
620 return Err(semantic(
621 "ORDER BY cannot use an aggregate function unless WITH itself is \
622 aggregating",
623 ));
624 }
625 infer_expr(expr, &order_scope)?;
626 }
627 }
628 Ok(projected)
629}
630
631fn validate_tail(
632 tail: &Option<Tail>,
633 scope: &mut Scope,
634 allow_mutation: bool,
635) -> Result<Scope, QueryError> {
636 let Some(tail) = tail else {
637 return Ok(Scope::new());
638 };
639 let reject_mutation = |clause_name: &str| -> Result<(), QueryError> {
640 if allow_mutation {
641 Ok(())
642 } else {
643 Err(semantic(format!(
644 "exists {{}} can't contain an updating clause ({clause_name}) -- only reading \
645 clauses (MATCH/UNWIND/WITH) are allowed inside it"
646 )))
647 }
648 };
649 match tail {
650 Tail::Return(items, _) => project_return(items, scope),
651 Tail::ReturnStar(_) => {
652 let items = crate::executor::return_star_items(scope.keys().cloned())?;
653 project_return(&items, scope)
654 }
655 Tail::Delete(exprs, ret) | Tail::DetachDelete(exprs, ret) => {
656 reject_mutation("DELETE")?;
657 for expr in exprs {
658 validate_delete_target(expr, scope)?;
659 }
660 validate_return_tail(ret, scope)
661 }
662 Tail::Set(items, ret) => {
663 reject_mutation("SET")?;
664 for item in items {
665 validate_set_item(item, scope)?;
666 }
667 validate_return_tail(ret, scope)
668 }
669 Tail::Remove(items, ret) => {
670 reject_mutation("REMOVE")?;
671 for item in items {
672 validate_remove_item(item, scope)?;
673 }
674 validate_return_tail(ret, scope)
675 }
676 Tail::Create(patterns, ret) => {
677 reject_mutation("CREATE")?;
678 for pattern in patterns {
679 bind_create_pattern(pattern, scope)?;
680 }
681 validate_return_tail(ret, scope)
682 }
683 }
684}
685
686fn validate_return_tail(ret: &Option<ReturnTail>, scope: &Scope) -> Result<Scope, QueryError> {
687 match ret {
688 Some(ret) => project_return(&ret.items, scope),
689 None => Ok(Scope::new()),
690 }
691}
692
693fn project_return(items: &[ReturnItem], scope: &Scope) -> Result<Scope, QueryError> {
694 crate::executor::validate_return_items(items)?;
695 let mut projected = Scope::new();
696 for (index, item) in items.iter().enumerate() {
697 let name = item_output_name(index, item);
698 let kind = infer_expr(&item.expr, scope)?;
699 // Only a real name collision -- an explicit alias reused, or a
700 // bare variable/property-access name repeated -- is a genuine
701 // conflict. An *unaliased* function call/`count(*)` falls back
702 // to a generic placeholder name (`"date(...)"`,`"count(*)"`,
703 // not argument-aware -- see `default_output_name`), so two
704 // different unaliased calls to the same function legitimately
705 // collide there without being a real duplicate (real Cypher
706 // auto-names each by its full source text instead, which
707 // MarsDB's AST-only naming can't reproduce) -- skip the check
708 // for that specific case rather than reject valid queries.
709 let name_is_real =
710 item.alias.is_some() || matches!(item.expr, ReturnExpr::Var(_) | ReturnExpr::Prop(_));
711 let existing = projected.insert(name.clone(), kind);
712 if name_is_real && existing.is_some() {
713 return Err(semantic(format!(
714 "RETURN projects duplicate column name '{name}'"
715 )));
716 }
717 }
718 Ok(projected)
719}
720
721/// Shared by `Tail::Delete`/`Tail::DetachDelete` and `QueryClause::Delete`
722/// (the `DELETE ... WITH ...` mid-statement form) -- same target-kind rules
723/// either way.
724fn validate_delete_target(expr: &ReturnExpr, scope: &Scope) -> Result<(), QueryError> {
725 // Some shapes can *never* evaluate to a node/relationship/path, by
726 // construction, regardless of what any variable inside them turns out
727 // to hold at runtime -- rejected immediately here rather than only once
728 // a row actually reaches `delete_value` (which a `MATCH` matching zero
729 // rows would skip entirely, real Cypher's own `InvalidArgumentType` is
730 // independent of whether any data exists -- TCK's Delete5 `[9]`,
731 // `DELETE 1 + 1`). `null` is the one literal exempt, since deleting it
732 // is a documented no-op, not a type error.
733 if !matches!(expr, ReturnExpr::Lit(Literal::Null))
734 && matches!(
735 expr,
736 ReturnExpr::Lit(_)
737 | ReturnExpr::CountStar
738 | ReturnExpr::Arith(..)
739 | ReturnExpr::Neg(..)
740 | ReturnExpr::And(..)
741 | ReturnExpr::Or(..)
742 | ReturnExpr::Xor(..)
743 | ReturnExpr::Not(..)
744 | ReturnExpr::Compare(..)
745 | ReturnExpr::IsNull(..)
746 | ReturnExpr::In(..)
747 | ReturnExpr::MapLit(..)
748 | ReturnExpr::ListLit(..)
749 | ReturnExpr::HasLabel(..)
750 )
751 {
752 return Err(semantic(
753 "DELETE target must evaluate to a node, relationship, or path -- a \
754 literal/arithmetic/boolean/map/list expression never can",
755 ));
756 }
757 let kind = infer_expr(expr, scope)?;
758 // `Scalar` is deliberately not rejected here, same reasoning as
759 // `bind_unwind`'s: a map/list access (`nodes.key`, `friends[0]`) types
760 // as `Scalar` in this codebase's `Kind` system even when it legitimately
761 // holds a `Node`/`Edge`/`Path` at runtime (TCK's Delete5 `[3]`/`[5]`
762 // scenarios are exactly this shape) -- only a confidently-wrong kind
763 // (a real number/string/bool/map) is rejected here, everything else
764 // defers to the runtime `QueryError::Type` in `delete_value`.
765 if !matches!(
766 kind,
767 Kind::Node | Kind::Edge | Kind::Path | Kind::Unknown | Kind::Scalar
768 ) {
769 return Err(semantic(format!(
770 "DELETE target is {}, not a node, relationship, or path",
771 kind_name(&kind)
772 )));
773 }
774 Ok(())
775}
776
777fn validate_set_item(item: &SetItem, scope: &Scope) -> Result<(), QueryError> {
778 match item {
779 SetItem::Prop(access, value) => {
780 require_graph(scope, &access.var, "SET property target")?;
781 infer_expr(value, scope)?;
782 Ok(())
783 }
784 SetItem::Labels(var, _) => require_kind(scope, var, &Kind::Node, "SET label target"),
785 SetItem::MapAssign { var, value, .. } => {
786 require_graph(scope, var, "SET map-assignment target")?;
787 infer_expr(value, scope)?;
788 Ok(())
789 }
790 }
791}
792
793fn validate_remove_item(item: &RemoveItem, scope: &Scope) -> Result<(), QueryError> {
794 match item {
795 RemoveItem::Prop(access) => require_graph(scope, &access.var, "REMOVE property target"),
796 RemoveItem::Labels(var, _) => require_kind(scope, var, &Kind::Node, "REMOVE label target"),
797 }
798}
799
800/// An aggregate function (`count(a)`, etc) is never legal inside a
801/// pattern-level `WHERE` -- real Cypher's `InvalidAggregation` at compile
802/// time (TCK's MatchWhere1 `[15]`: `MATCH (a) WHERE count(a) > 10`), not
803/// something a zero-row `MATCH` could otherwise silently skip checking
804/// (aggregates only ever make sense as a `RETURN`/`WITH` item's own
805/// top-level expression, evaluated once *after* every row has already
806/// been matched-and-filtered -- a `WHERE` predicate runs per-row, before
807/// any such collapsing exists). `infer_expr` itself stays permissive
808/// (same "any recognized function call" treatment every other function
809/// gets) since it's shared with `RETURN`/`WITH` items, where an aggregate
810/// *is* legal -- this is the pattern-`WHERE`-specific half of that check.
811fn reject_aggregate_in_where(expr: &ReturnExpr) -> Result<(), QueryError> {
812 if crate::executor::contains_aggregate(expr) {
813 return Err(semantic(
814 "an aggregate function can't be used inside a WHERE clause",
815 ));
816 }
817 Ok(())
818}
819
820fn validate_pattern_expr(expr: &Expr, scope: &Scope) -> Result<(), QueryError> {
821 match expr {
822 Expr::And(left, right) | Expr::Or(left, right) => {
823 validate_pattern_expr(left, scope)?;
824 validate_pattern_expr(right, scope)
825 }
826 Expr::Not(inner) => validate_pattern_expr(inner, scope),
827 Expr::Compare(access, _, _) | Expr::IsNull(access) => {
828 require_property_owner(scope, &access.var)
829 }
830 Expr::PropCompare(left, _, right) => {
831 require_property_owner(scope, &left.var)?;
832 require_property_owner(scope, &right.var)
833 }
834 Expr::HasLabel(var, _) => require_kind(scope, var, &Kind::Node, "label predicate"),
835 Expr::VarEq(left, right) => {
836 require_graph(scope, left, "identity predicate")?;
837 require_graph(scope, right, "identity predicate")
838 }
839 Expr::GeneralCompare(left, _, right) => {
840 infer_expr(left, scope)?;
841 infer_expr(right, scope)?;
842 reject_aggregate_in_where(left)?;
843 reject_aggregate_in_where(right)
844 }
845 Expr::GeneralIsNull(e) => {
846 infer_expr(e, scope)?;
847 reject_aggregate_in_where(e)
848 }
849 Expr::GeneralBare(e) => {
850 let kind = infer_expr(e, scope)?;
851 require_boolean_predicate_kind(&kind, "WHERE predicate")?;
852 reject_aggregate_in_where(e)
853 }
854 Expr::Pattern(pattern) => validate_pattern_predicate(pattern, scope),
855 // Unlike `Pattern` above (existential-only, never introduces a
856 // variable), `exists {}`'s pattern *can* introduce brand-new
857 // node/relationship variables (TCK's ExistentialSubquery1 `[2]`'s
858 // `m`), so it reuses `bind_match_pattern` against a scoped copy --
859 // same reasoning as `PatternComprehension`'s own handling
860 // (`infer_expr`, below) -- these bindings are local to the
861 // `exists {}` block, they don't leak into the enclosing scope.
862 Expr::Exists {
863 pattern,
864 where_clause,
865 } => {
866 let mut inner_scope = scope.clone();
867 bind_match_pattern(pattern, &mut inner_scope)?;
868 if let Some(w) = where_clause.as_deref() {
869 validate_pattern_expr(w, &inner_scope)?;
870 }
871 Ok(())
872 }
873 // `exists { MATCH ... RETURN ... }` (TCK's ExistentialSubquery2/3)
874 // -- correlated against the enclosing scope (`scope.clone()`, same
875 // reasoning as `Exists` above), reusing `validate_match_clauses`
876 // with `allow_mutation: false`. Only `Statement::Match` is a valid
877 // shape here (real Cypher's `exists {}` body is always
878 // MATCH/UNWIND/WITH-only, never a bare CREATE or UNION) -- anything
879 // else the grammar happened to parse inside it is rejected with a
880 // clear error rather than silently mishandled.
881 Expr::ExistsSubquery(stmt) => {
882 let Statement::Match {
883 clauses,
884 tail,
885 order_by,
886 ..
887 } = stmt.as_ref()
888 else {
889 return Err(semantic(
890 "exists {} subquery must be a MATCH ... RETURN ... statement",
891 ));
892 };
893 validate_match_clauses(clauses, tail, order_by, scope.clone(), false)
894 }
895 // Never reaches here: synthesized by the planner (`build_match_
896 // plan`), well after this pass already validated the original
897 // parsed AST -- no surface syntax constructs this directly (see
898 // its own doc comment).
899 Expr::EdgeNotInSet { .. } => {
900 unreachable!("Expr::EdgeNotInSet is only ever synthesized by the planner")
901 }
902 }
903}
904
905/// `WHERE (n)-[r:REL]->(m)` etc (TCK's Pattern1) -- every named endpoint
906/// must already be bound; unlike `bind_match_pattern` (a real MATCH's own
907/// pattern, which introduces new variables), a pattern predicate never
908/// does -- real Cypher's `UndefinedVariable` for anything it doesn't
909/// recognize (TCK's Pattern1 [10] outline, `MATCH (n) WHERE (n)-[r]->(a)
910/// RETURN n` with `a` never bound elsewhere). `require_kind`'s own
911/// `lookup` already produces exactly that "references undefined
912/// variable" error for an unbound name, so no separate check is needed.
913/// An anonymous (var-less) token is always fine, same as any ordinary
914/// MATCH pattern.
915fn validate_pattern_predicate(pattern: &Pattern, scope: &Scope) -> Result<(), QueryError> {
916 if let Some(var) = &pattern.start.var {
917 require_kind(scope, var, &Kind::Node, "pattern predicate node")?;
918 }
919 validate_props(&pattern.start.props, scope)?;
920 for (rel, node) in &pattern.hops {
921 if let Some(var) = &rel.var {
922 require_kind(scope, var, &Kind::Edge, "pattern predicate relationship")?;
923 }
924 validate_props(&rel.props, scope)?;
925 if let Some(var) = &node.var {
926 require_kind(scope, var, &Kind::Node, "pattern predicate node")?;
927 }
928 validate_props(&node.props, scope)?;
929 }
930 Ok(())
931}
932
933fn validate_with_expr(expr: &WithExpr, scope: &Scope) -> Result<(), QueryError> {
934 match expr {
935 WithExpr::And(left, right) | WithExpr::Or(left, right) => {
936 validate_with_expr(left, scope)?;
937 validate_with_expr(right, scope)
938 }
939 WithExpr::Not(inner) => validate_with_expr(inner, scope),
940 WithExpr::Compare(left, _, right) => {
941 infer_expr(left, scope)?;
942 infer_expr(right, scope)?;
943 Ok(())
944 }
945 WithExpr::IsNull(e) => {
946 infer_expr(e, scope)?;
947 Ok(())
948 }
949 // Same reasoning as `executor::eval_with_expr`'s matching special
950 // case: `WithExpr` has no `Expr::Pattern`-equivalent folding, so a
951 // bare pattern predicate reaches here as `ReturnExpr::
952 // PatternPredicate` inside `Bare` -- validated the same way
953 // ordinary MATCH's own WHERE already validates one (TCK's
954 // WithWhere4 `[2]`), not `infer_expr`'s generic (and therefore
955 // rejecting) handling.
956 WithExpr::Bare(ReturnExpr::PatternPredicate(pattern)) => {
957 validate_pattern_predicate(pattern, scope)
958 }
959 WithExpr::Bare(e) => {
960 let kind = infer_expr(e, scope)?;
961 require_boolean_predicate_kind(&kind, "WHERE predicate")
962 }
963 }
964}
965
966/// `(min, max)` argument count for a built-in function name (aggregates
967/// included, case-insensitively matched same as everywhere else this
968/// codebase dispatches on a function name) -- `max: None` means unbounded
969/// (`coalesce` only). `None` for a name this doesn't recognize at all --
970/// the "unknown function" error further down in `infer_expr` still
971/// covers that case, this only ever narrows an already-known function.
972///
973/// Checked once, compile-time, before any per-argument work: real
974/// Cypher's `InvalidNumberOfArguments` is knowable from the call's AST
975/// shape alone, no data needed, so it belongs in the same "Semantic, not
976/// Type" bucket `CYPHER_COVERAGE.md`'s error taxonomy already documents
977/// -- not a runtime error some call sites already produced ad hoc
978/// (`range()`/`replace()`/`duration.between()`/`*.truncate()`), and
979/// others (`datetime.fromepoch()`, most everything else) never checked
980/// at all, silently reading a plain missing argument as `Type` error
981/// with the wrong type reported (`{:?}` of `None`, not "no such
982/// argument").
983fn function_arity(name: &str) -> Option<(usize, Option<usize>)> {
984 Some(match name.to_ascii_lowercase().as_str() {
985 "count" | "sum" | "avg" | "min" | "max" | "collect" => (1, Some(1)),
986 "percentilecont" | "percentiledisc" => (2, Some(2)),
987 "coalesce" => (1, None),
988 "tointeger" | "tostring" | "tofloat" | "toboolean" => (1, Some(1)),
989 "date" | "localtime" | "time" | "localdatetime" | "datetime" => (0, Some(1)),
990 // 0 args in the ordinary case, but real Cypher also accepts
991 // exactly 1 -- if it's `null`, the call propagates `null` rather
992 // than erroring (TCK's Temporal4 `[13]`, tests this uniformly
993 // across the whole family even though these functions have no
994 // real parameter otherwise; the runtime's own `now_or_null`
995 // already implements this). `rand()` has no such exception --
996 // real Cypher's `rand()` is always exactly 0 args.
997 "date.transaction"
998 | "date.statement"
999 | "date.realtime"
1000 | "localtime.transaction"
1001 | "localtime.statement"
1002 | "localtime.realtime"
1003 | "time.transaction"
1004 | "time.statement"
1005 | "time.realtime"
1006 | "localdatetime.transaction"
1007 | "localdatetime.statement"
1008 | "localdatetime.realtime"
1009 | "datetime.transaction"
1010 | "datetime.statement"
1011 | "datetime.realtime" => (0, Some(1)),
1012 "rand" => (0, Some(0)),
1013 "duration" => (1, Some(1)),
1014 "datetime.fromepoch" => (2, Some(2)),
1015 "datetime.fromepochmillis" => (1, Some(1)),
1016 "duration.between" | "duration.inmonths" | "duration.indays" | "duration.inseconds" => {
1017 (2, Some(2))
1018 }
1019 "date.truncate"
1020 | "localtime.truncate"
1021 | "time.truncate"
1022 | "localdatetime.truncate"
1023 | "datetime.truncate" => (2, Some(3)),
1024 "length" | "nodes" | "relationships" | "type" | "startnode" | "endnode" | "keys"
1025 | "labels" | "properties" | "id" | "size" | "exists" | "head" | "last" | "tail"
1026 | "toupper" | "upper" | "tolower" | "lower" | "trim" | "ltrim" | "rtrim" | "reverse"
1027 | "abs" | "ceil" | "floor" | "round" | "sqrt" | "sign" => (1, Some(1)),
1028 "range" => (2, Some(3)),
1029 "split" | "left" | "right" => (2, Some(2)),
1030 "substring" => (2, Some(3)),
1031 "replace" => (3, Some(3)),
1032 _ => return None,
1033 })
1034}
1035
1036fn check_arity(name: &str, arg_count: usize) -> Result<(), QueryError> {
1037 let Some((min, max)) = function_arity(name) else {
1038 return Ok(());
1039 };
1040 let ok = arg_count >= min && max.is_none_or(|max| arg_count <= max);
1041 if ok {
1042 return Ok(());
1043 }
1044 let arg_word = |n: usize| if n == 1 { "argument" } else { "arguments" };
1045 let expected = match max {
1046 Some(max) if max == min => format!("exactly {min} {}", arg_word(min)),
1047 Some(max) => format!("{min} to {max} arguments"),
1048 None => format!("at least {min} {}", arg_word(min)),
1049 };
1050 Err(semantic(format!(
1051 "{name}() expects {expected}, got {arg_count}"
1052 )))
1053}
1054
1055fn infer_expr(expr: &ReturnExpr, scope: &Scope) -> Result<Kind, QueryError> {
1056 Ok(match expr {
1057 ReturnExpr::Var(var) => lookup(scope, var, "expression")?.clone(),
1058 ReturnExpr::Prop(access) => {
1059 require_property_owner(scope, &access.var)?;
1060 Kind::Scalar
1061 }
1062 // `<expr>.prop` where `<expr>` isn't a bare variable -- same
1063 // permissive stance as `Prop` above (the real node/relationship/
1064 // map/temporal-value-or-error check is a runtime one, see
1065 // `executor::property_of_value`); only checks that the base
1066 // expression itself is well-formed (e.g. no unbound variable
1067 // inside it).
1068 ReturnExpr::PropOf(base, _) => {
1069 infer_expr(base, scope)?;
1070 Kind::Scalar
1071 }
1072 // `null` specifically types as `Unknown`, not `Scalar` -- real
1073 // Cypher's `null` is compatible with *any* type (it's not "some
1074 // scalar that happens to be null," it's the universal "unknown
1075 // value" every type check already treats `Unknown` as compatible
1076 // with). Using `Scalar` here used to force a pile of individual
1077 // "Scalar tolerated too, not just Unknown" call-site exceptions
1078 // (`Index`, `type()`, `nodes()`/`relationships()`/`length()`) just
1079 // to let `null` through checks that already handle `Unknown` for
1080 // free -- and still didn't cover every site (`bind_kind` reusing
1081 // an already-bound `null` variable as a node/relationship pattern
1082 // token, TCK's Path1 `[1]`/Path2 `[3]`: `WITH null AS a OPTIONAL
1083 // MATCH p = (a)-[r]->()`). A real, non-null scalar (`1`, `'x'`,
1084 // `true`) still types as `Scalar` -- only the literal `null`
1085 // keyword changes.
1086 ReturnExpr::Lit(Literal::Null) => Kind::Unknown,
1087 ReturnExpr::Lit(_) | ReturnExpr::CountStar => Kind::Scalar,
1088 ReturnExpr::Call { name, args, .. } => {
1089 check_arity(name, args.len())?;
1090 let arg_kinds = args
1091 .iter()
1092 .map(|arg| infer_expr(arg, scope))
1093 .collect::<Result<Vec<_>, _>>()?;
1094 if is_aggregate_name(name) {
1095 if name.eq_ignore_ascii_case("collect") {
1096 Kind::List(Box::new(
1097 arg_kinds.first().cloned().unwrap_or(Kind::Unknown),
1098 ))
1099 } else {
1100 Kind::Scalar
1101 }
1102 } else {
1103 match name.to_ascii_lowercase().as_str() {
1104 "coalesce" => unify_many(&arg_kinds),
1105 "tointeger"
1106 | "tostring"
1107 | "tofloat"
1108 | "toboolean"
1109 | "date"
1110 | "duration"
1111 | "localtime"
1112 | "time"
1113 | "localdatetime"
1114 | "datetime"
1115 | "duration.between"
1116 | "duration.inmonths"
1117 | "duration.indays"
1118 | "duration.inseconds"
1119 | "date.truncate"
1120 | "localtime.truncate"
1121 | "time.truncate"
1122 | "localdatetime.truncate"
1123 | "datetime.truncate"
1124 | "date.transaction"
1125 | "date.statement"
1126 | "date.realtime"
1127 | "localtime.transaction"
1128 | "localtime.statement"
1129 | "localtime.realtime"
1130 | "time.transaction"
1131 | "time.statement"
1132 | "time.realtime"
1133 | "localdatetime.transaction"
1134 | "localdatetime.statement"
1135 | "localdatetime.realtime"
1136 | "datetime.transaction"
1137 | "datetime.statement"
1138 | "datetime.realtime"
1139 | "datetime.fromepoch"
1140 | "datetime.fromepochmillis" => Kind::Scalar,
1141 "length" => {
1142 if let Some(kind) = arg_kinds.first() {
1143 require_path_or_null(kind, "length() argument")?;
1144 }
1145 Kind::Scalar
1146 }
1147 "nodes" => {
1148 if let Some(kind) = arg_kinds.first() {
1149 require_path_or_null(kind, "nodes() argument")?;
1150 }
1151 Kind::List(Box::new(Kind::Node))
1152 }
1153 "relationships" => {
1154 if let Some(kind) = arg_kinds.first() {
1155 require_path_or_null(kind, "relationships() argument")?;
1156 }
1157 Kind::List(Box::new(Kind::Edge))
1158 }
1159 // Unlike `keys`/`labels`/`id`/`size`/`exists` (each
1160 // polymorphic over several kinds, so left to the
1161 // runtime's own `QueryError::Type` below), `type()`
1162 // only ever accepts a relationship -- checked here so
1163 // `MATCH (r) RETURN type(r)` (`r` a *node*, from the
1164 // pattern itself) is a compile-time error even when
1165 // the `MATCH` matches zero rows, not only a runtime
1166 // one a zero-row match would silently skip (TCK's
1167 // Graph4 [7]).
1168 "type" => {
1169 // `Scalar` tolerated too, not just `Unknown` -- a
1170 // `null`-valued argument types as `Scalar` in this
1171 // imprecise `Kind` system, and `type(null)` is
1172 // `null` at runtime (`call_builtin`'s own early
1173 // null check), not an error (TCK's Graph4 `[3]`).
1174 if let Some(kind) = arg_kinds.first() {
1175 if !matches!(kind, Kind::Edge | Kind::Scalar | Kind::Unknown) {
1176 return Err(semantic(format!(
1177 "type() argument requires a relationship, but found {}",
1178 kind_name(kind)
1179 )));
1180 }
1181 }
1182 Kind::Scalar
1183 }
1184 // Same compile-time-checkable-input-kind reasoning as
1185 // `type()` just above -- both only ever accept a
1186 // relationship, and return the node at its
1187 // start/end.
1188 "startnode" | "endnode" => {
1189 if let Some(kind) = arg_kinds.first() {
1190 require_compatible_kind(
1191 kind,
1192 &Kind::Edge,
1193 "startNode()/endNode() argument",
1194 )?;
1195 }
1196 Kind::Node
1197 }
1198 // `keys`/`labels`/`properties`/`id`/`size`/`exists`
1199 // accept a node, relationship, or (for keys/
1200 // properties/size) a map/list/string too, depending on
1201 // the specific function -- narrower than what the
1202 // runtime (`executor::call_builtin`'s own arms) already
1203 // enforces with a clear `QueryError::Type`, so no
1204 // additional structural check is added here beyond
1205 // "the call itself is a recognized function."
1206 // `keys`/`labels` each return a *list* of strings, not
1207 // a scalar -- real Cypher needs this to be `Kind::
1208 // List` so `[x IN labels(n) | ...]`'s own source-kind
1209 // check (`list_element`) doesn't wrongly reject a
1210 // perfectly good list comprehension source (TCK's
1211 // List12 [6]).
1212 "keys" | "labels" => Kind::List(Box::new(Kind::Scalar)),
1213 // Unlike `id`/`exists` (genuinely polymorphic over
1214 // node/relationship, left to the runtime's own
1215 // `QueryError::Type`), `size()` never accepts a `Path`
1216 // -- `size_builtin` has no arm for one, and (unlike a
1217 // wrong `Scalar`) a `Path`-kinded argument is knowable
1218 // here without ever running a row, so real Cypher
1219 // makes this compile-time (TCK's List6 `[5]`) rather
1220 // than something a zero-row `MATCH` could silently
1221 // skip checking at all.
1222 "size" => {
1223 if let Some(Kind::Path) = arg_kinds.first() {
1224 return Err(semantic(
1225 "size() doesn't accept a path -- use length() instead",
1226 ));
1227 }
1228 Kind::Scalar
1229 }
1230 "id" | "exists" => Kind::Scalar,
1231 "properties" => Kind::Map,
1232 "head" | "last" => match arg_kinds.first() {
1233 Some(Kind::List(inner)) => (**inner).clone(),
1234 _ => Kind::Unknown,
1235 },
1236 "tail" => match arg_kinds.first() {
1237 Some(kind @ Kind::List(_)) => kind.clone(),
1238 _ => Kind::Unknown,
1239 },
1240 "range" | "split" => Kind::List(Box::new(Kind::Scalar)),
1241 "toupper" | "upper" | "tolower" | "lower" | "trim" | "ltrim" | "rtrim"
1242 | "replace" | "substring" | "left" | "right" | "abs" | "ceil" | "floor"
1243 | "round" | "sqrt" | "sign" | "rand" => Kind::Scalar,
1244 // Polymorphic over string/list -- the input's own kind
1245 // (if known) is the output's kind too.
1246 "reverse" => arg_kinds.first().cloned().unwrap_or(Kind::Unknown),
1247 other => return Err(semantic(format!("unknown function '{other}'"))),
1248 }
1249 }
1250 }
1251 ReturnExpr::Case { test, whens, else_ } => {
1252 if let Some(test) = test {
1253 infer_expr(test, scope)?;
1254 }
1255 let mut result_kinds = Vec::new();
1256 for (when, then) in whens {
1257 infer_expr(when, scope)?;
1258 result_kinds.push(infer_expr(then, scope)?);
1259 }
1260 if let Some(else_) = else_ {
1261 result_kinds.push(infer_expr(else_, scope)?);
1262 }
1263 unify_many(&result_kinds)
1264 }
1265 ReturnExpr::Arith(left, op, right) => {
1266 let lk = infer_expr(left, scope)?;
1267 let rk = infer_expr(right, scope)?;
1268 // `+` alone also means real Cypher's list concatenation/
1269 // append/prepend (`[1,2] + [3]`, `[1,2] + 3`, `3 + [1,2]`) --
1270 // `-`/`*`/`/`/`%` have no defined meaning for a list, so
1271 // those still reject one outright via `require_scalarish`.
1272 // The resulting element kind unifies whichever side(s) are
1273 // themselves a list with the other operand's own kind (an
1274 // append/prepend puts that whole value in as one more element)
1275 // -- not hardcoded to `Scalar`, which would wrongly forget a
1276 // concatenated node/relationship list's real element kind
1277 // (`[a] + collect(n) + [b]` must still type as `List(Node)`,
1278 // not `List(Scalar)`, or a later `CREATE` off one of its
1279 // elements gets rejected at compile time even though it's a
1280 // real node -- TCK's Match4 `[4]`). `unify_many` already
1281 // widens to `Unknown` on any real mismatch, same safe fallback
1282 // every other composed-kind check here uses.
1283 if *op == ArithOp::Add && (matches!(lk, Kind::List(_)) || matches!(rk, Kind::List(_))) {
1284 let elem = |k: Kind| match k {
1285 Kind::List(inner) => *inner,
1286 other => other,
1287 };
1288 Kind::List(Box::new(unify_many(&[elem(lk), elem(rk)])))
1289 } else {
1290 require_scalarish(&lk, "arithmetic operand")?;
1291 require_scalarish(&rk, "arithmetic operand")?;
1292 Kind::Scalar
1293 }
1294 }
1295 ReturnExpr::Neg(e) => {
1296 let k = infer_expr(e, scope)?;
1297 require_scalarish(&k, "unary minus operand")?;
1298 Kind::Scalar
1299 }
1300 ReturnExpr::ListLit(items) => {
1301 let kinds = items
1302 .iter()
1303 .map(|item| infer_expr(item, scope))
1304 .collect::<Result<Vec<_>, _>>()?;
1305 Kind::List(Box::new(unify_many(&kinds)))
1306 }
1307 ReturnExpr::Index(base, index) => {
1308 require_scalarish(&infer_expr(index, scope)?, "list index")?;
1309 match infer_expr(base, scope)? {
1310 Kind::List(element) => *element,
1311 // `map['key']` -- real Cypher's dynamic map-field access
1312 // (`apply_index`'s own runtime already fully supports
1313 // this, only this compile-time check was too narrow).
1314 // The result could be any value the map happens to hold
1315 // at that key -- `Kind::Scalar`, same imprecise fallback
1316 // `keys`/`labels`/etc already use elsewhere, not worth a
1317 // per-key type model.
1318 Kind::Map => Kind::Scalar,
1319 // `Scalar` is deliberately tolerated here too, not just
1320 // `Unknown` -- a `null`-valued base types as `Scalar` in
1321 // this imprecise `Kind` system (see `ReturnExpr::Lit`'s
1322 // own arm), and indexing into `null` is `null` at
1323 // runtime (`apply_index`'s own early check), not an
1324 // error. A genuinely wrong scalar (e.g. a bound integer)
1325 // still gets `apply_index`'s real `QueryError::Type` at
1326 // runtime -- same "defer to the runtime check" tolerance
1327 // every other `Kind::Scalar` case in this module already
1328 // gives.
1329 Kind::Unknown | Kind::Scalar => Kind::Unknown,
1330 // `n['name']` -- dynamic property access on a node/
1331 // relationship, same as `n.name`'s static form (TCK's
1332 // Graph7 `[1]`-`[3]`); `apply_index`'s own runtime already
1333 // supports this via `property_of_value`.
1334 Kind::Node | Kind::Edge => Kind::Scalar,
1335 other => {
1336 return Err(semantic(format!(
1337 "index base is {}, not a list or map",
1338 kind_name(&other)
1339 )))
1340 }
1341 }
1342 }
1343 ReturnExpr::Slice(base, start, end) => {
1344 if let Some(start) = start {
1345 require_scalarish(&infer_expr(start, scope)?, "slice bound")?;
1346 }
1347 if let Some(end) = end {
1348 require_scalarish(&infer_expr(end, scope)?, "slice bound")?;
1349 }
1350 match infer_expr(base, scope)? {
1351 list @ Kind::List(_) => list,
1352 Kind::Unknown => Kind::List(Box::new(Kind::Unknown)),
1353 other => {
1354 return Err(semantic(format!(
1355 "slice base is {}, not a list",
1356 kind_name(&other)
1357 )))
1358 }
1359 }
1360 }
1361 ReturnExpr::ListComp {
1362 var,
1363 source,
1364 where_clause,
1365 project,
1366 } => {
1367 let element = list_element(infer_expr(source, scope)?, "list comprehension source")?;
1368 let mut local = scope.clone();
1369 local.insert(var.clone(), element.clone());
1370 if let Some(where_clause) = where_clause {
1371 require_scalarish(&infer_expr(where_clause, &local)?, "list filter")?;
1372 }
1373 let projected = match project {
1374 Some(project) => infer_expr(project, &local)?,
1375 None => element,
1376 };
1377 Kind::List(Box::new(projected))
1378 }
1379 ReturnExpr::Quantifier {
1380 var,
1381 source,
1382 where_clause,
1383 ..
1384 } => {
1385 let element = list_element(infer_expr(source, scope)?, "quantifier source")?;
1386 let mut local = scope.clone();
1387 local.insert(var.clone(), element);
1388 if let Some(where_clause) = where_clause {
1389 require_scalarish(&infer_expr(where_clause, &local)?, "quantifier predicate")?;
1390 }
1391 Kind::Scalar
1392 }
1393 ReturnExpr::MapLit(entries) => {
1394 for (_, value) in entries {
1395 infer_expr(value, scope)?;
1396 }
1397 Kind::Map
1398 }
1399 ReturnExpr::And(left, right)
1400 | ReturnExpr::Or(left, right)
1401 | ReturnExpr::Xor(left, right) => {
1402 require_scalarish(&infer_expr(left, scope)?, "boolean operand")?;
1403 require_scalarish(&infer_expr(right, scope)?, "boolean operand")?;
1404 Kind::Scalar
1405 }
1406 ReturnExpr::Not(inner) => {
1407 require_scalarish(&infer_expr(inner, scope)?, "boolean operand")?;
1408 Kind::Scalar
1409 }
1410 ReturnExpr::Compare(left, _, right) => {
1411 infer_expr(left, scope)?;
1412 infer_expr(right, scope)?;
1413 Kind::Scalar
1414 }
1415 ReturnExpr::IsNull(inner) => {
1416 infer_expr(inner, scope)?;
1417 Kind::Scalar
1418 }
1419 ReturnExpr::In(needle, haystack) => {
1420 infer_expr(needle, scope)?;
1421 infer_expr(haystack, scope)?;
1422 Kind::Scalar
1423 }
1424 ReturnExpr::HasLabel(var, _) => {
1425 require_graph(scope, var, "(n:Label) target")?;
1426 Kind::Scalar
1427 }
1428 // Real validation (undefined-variable checks etc) happens via
1429 // `validate_pattern_predicate` once `return_expr_to_expr` folds
1430 // this into `Expr::Pattern` -- reaching `infer_expr` at all means
1431 // it's in a position `Expr`-folding never runs (RETURN/WITH item,
1432 // function arg, ...), a real compile-time error (TCK's List6 [6]
1433 // "Fail for size() on pattern predicates" expects a SyntaxError
1434 // regardless of whether any row ever reaches evaluation -- found
1435 // via the TCK: the executor's own runtime rejection only fires
1436 // per-row, silently never triggering on an empty result set).
1437 ReturnExpr::PatternPredicate(_) => {
1438 return Err(QueryError::Semantic(
1439 "a pattern predicate (`(n)-->()` etc) can only be used inside WHERE".into(),
1440 ))
1441 }
1442 // Unlike `PatternPredicate` (existential-only, never introduces a
1443 // variable -- `validate_pattern_predicate`'s `require_kind`
1444 // checks, not `bind_kind`), a pattern comprehension is allowed to
1445 // introduce brand-new node/relationship variables (TCK's
1446 // Pattern2 `[4]`/`[5]`), so it reuses `bind_match_pattern` (same
1447 // "new var -> fresh binding, already-bound var -> compatibility
1448 // check" logic a real `MATCH` pattern gets) against a scoped
1449 // copy -- these bindings are local to the projection, they don't
1450 // leak into the enclosing RETURN/WITH scope.
1451 ReturnExpr::PatternComprehension {
1452 path_var,
1453 pattern,
1454 where_clause,
1455 projection,
1456 } => {
1457 if path_var.is_some() {
1458 crate::parse_helpers::validate_named_path_pattern(pattern)?;
1459 }
1460 let mut inner_scope = scope.clone();
1461 bind_match_pattern(pattern, &mut inner_scope)?;
1462 if let Some(path_var) = path_var {
1463 bind_kind(&mut inner_scope, path_var, Kind::Path, "path variable")?;
1464 }
1465 if let Some(where_expr) = where_clause {
1466 validate_pattern_expr(where_expr, &inner_scope)?;
1467 }
1468 Kind::List(Box::new(infer_expr(projection, &inner_scope)?))
1469 }
1470 ReturnExpr::ExistsPattern { .. } | ReturnExpr::ExistsSubquery(_) => {
1471 return Err(QueryError::Semantic(
1472 "an exists {} subquery can only be used inside WHERE".into(),
1473 ))
1474 }
1475 })
1476}
1477
1478fn list_element(kind: Kind, context: &str) -> Result<Kind, QueryError> {
1479 match kind {
1480 Kind::List(element) => Ok(*element),
1481 // `Scalar` is deliberately not rejected here, same reasoning as
1482 // `bind_unwind`'s own matching widening: a property access
1483 // (`n.numbers`) always types as `Kind::Scalar` in this codebase's
1484 // `Kind` system, even when it legitimately holds a `List` at
1485 // runtime now that list-valued properties are supported (TCK's
1486 // Set1 [5], `[i IN n.numbers | i / 2.0]`) -- only a confidently-
1487 // wrong kind (a real node/edge/map/path) is rejected here,
1488 // everything else defers to the real runtime `Value::List` check
1489 // in `eval_return_expr`'s own `ListComp`/`Quantifier` arms.
1490 Kind::Unknown | Kind::Scalar => Ok(Kind::Unknown),
1491 other => Err(semantic(format!(
1492 "{context} is {}, not a list",
1493 kind_name(&other)
1494 ))),
1495 }
1496}
1497
1498fn bind_kind(
1499 scope: &mut Scope,
1500 var: &str,
1501 expected: Kind,
1502 context: &str,
1503) -> Result<(), QueryError> {
1504 match scope.get(var) {
1505 Some(actual) => require_compatible_kind(actual, &expected, context),
1506 None => {
1507 scope.insert(var.to_string(), expected);
1508 Ok(())
1509 }
1510 }
1511}
1512
1513fn require_kind(
1514 scope: &Scope,
1515 var: &str,
1516 expected: &Kind,
1517 context: &str,
1518) -> Result<(), QueryError> {
1519 let actual = lookup(scope, var, context)?;
1520 require_compatible_kind(actual, expected, context)
1521}
1522
1523fn require_compatible_kind(
1524 actual: &Kind,
1525 expected: &Kind,
1526 context: &str,
1527) -> Result<(), QueryError> {
1528 if actual == expected || matches!(actual, Kind::Unknown) {
1529 return Ok(());
1530 }
1531 Err(semantic(format!(
1532 "{context} requires {}, but found {}",
1533 kind_name(expected),
1534 kind_name(actual)
1535 )))
1536}
1537
1538/// `length()`/`nodes()`/`relationships()`'s shared argument check --
1539/// `Kind::Path`, or `Scalar` (a `null`-valued argument types as `Scalar`
1540/// in this imprecise `Kind` system, and all three are `null` at runtime
1541/// for a `null` argument -- `call_builtin`'s own early null check, not an
1542/// error, TCK's Path1 `[1]`/Path2 `[3]`), or `Unknown`.
1543fn require_path_or_null(actual: &Kind, context: &str) -> Result<(), QueryError> {
1544 if matches!(actual, Kind::Path | Kind::Scalar | Kind::Unknown) {
1545 return Ok(());
1546 }
1547 Err(semantic(format!(
1548 "{context} requires {}, but found {}",
1549 kind_name(&Kind::Path),
1550 kind_name(actual)
1551 )))
1552}
1553
1554fn require_graph(scope: &Scope, var: &str, context: &str) -> Result<(), QueryError> {
1555 let actual = lookup(scope, var, context)?;
1556 if matches!(actual, Kind::Node | Kind::Edge | Kind::Unknown) {
1557 Ok(())
1558 } else {
1559 Err(semantic(format!(
1560 "{context} '{var}' is {}, not a node or relationship",
1561 kind_name(actual)
1562 )))
1563 }
1564}
1565
1566fn require_property_owner(scope: &Scope, var: &str) -> Result<(), QueryError> {
1567 // Scalars deliberately remain valid: Date/Duration expose component
1568 // fields, and null/other scalars yield null for a missing component in
1569 // the current runtime semantics. The binder resolves the name here;
1570 // the exact property/component remains data-dependent.
1571 let kind = lookup(scope, var, "property access")?;
1572 // `Path` is the one kind that's *never* valid here, knowable without
1573 // ever running a row -- real Cypher's `InvalidArgumentType` at
1574 // compile time (TCK's MatchWhere1 `[14]`: `MATCH r = (n)-[*]->()
1575 // WHERE r.name = 'apa'`), not something a zero-row `MATCH` (unbounded
1576 // `[*]` against an empty graph, here) could silently skip checking by
1577 // never actually evaluating the predicate.
1578 if matches!(kind, Kind::Path) {
1579 return Err(semantic(format!(
1580 "'{var}' is a path — property access requires a node, relationship, or map"
1581 )));
1582 }
1583 Ok(())
1584}
1585
1586fn require_scalarish(kind: &Kind, context: &str) -> Result<(), QueryError> {
1587 if matches!(kind, Kind::Scalar | Kind::Unknown) {
1588 Ok(())
1589 } else {
1590 Err(semantic(format!(
1591 "{context} cannot use {}",
1592 kind_name(kind)
1593 )))
1594 }
1595}
1596
1597fn lookup<'a>(scope: &'a Scope, var: &str, context: &str) -> Result<&'a Kind, QueryError> {
1598 scope
1599 .get(var)
1600 .ok_or_else(|| semantic(format!("{context} references undefined variable '{var}'")))
1601}
1602
1603fn unify_many(kinds: &[Kind]) -> Kind {
1604 let Some(first) = kinds.first() else {
1605 return Kind::Unknown;
1606 };
1607 if kinds.iter().all(|kind| kind == first) {
1608 first.clone()
1609 } else {
1610 Kind::Unknown
1611 }
1612}
1613
1614fn item_output_name(index: usize, item: &ReturnItem) -> String {
1615 item.alias
1616 .clone()
1617 .unwrap_or_else(|| default_output_name(&item.expr, index))
1618}
1619
1620fn default_output_name(expr: &ReturnExpr, index: usize) -> String {
1621 match expr {
1622 ReturnExpr::Var(var) => var.clone(),
1623 ReturnExpr::Prop(access) => format!("{}.{}", access.var, access.prop),
1624 ReturnExpr::Call { name, .. } => format!("{name}(...)"),
1625 ReturnExpr::CountStar => "count(*)".to_string(),
1626 ReturnExpr::Case { .. } => format!("case{index}"),
1627 _ => format!("col{index}"),
1628 }
1629}
1630
1631fn kind_name(kind: &Kind) -> &'static str {
1632 match kind {
1633 Kind::Node => "a node",
1634 Kind::Edge => "a relationship",
1635 Kind::Scalar => "a scalar",
1636 Kind::List(_) => "a list",
1637 Kind::Map => "a map",
1638 Kind::Path => "a path",
1639 Kind::Unknown => "a dynamically typed value",
1640 }
1641}
1642
1643fn semantic(message: impl Into<String>) -> QueryError {
1644 QueryError::Semantic(message.into())
1645}
1646
1647/// `WHERE (n)` / `WHERE (n)-->()`-shaped bare-expression predicates
1648/// (`Expr::GeneralBare`/`WithExpr::Bare`) -- a node/relationship/list/map/
1649/// path can *never* be a valid boolean predicate regardless of what data
1650/// the query runs against (`MATCH (n) WHERE (n) RETURN n`'s `(n)` is a
1651/// bare node reference, not a pattern predicate), so this is checked here
1652/// rather than left to `value_to_bool3`'s runtime error -- a zero-row
1653/// `MATCH` would otherwise never evaluate the predicate at all and the
1654/// query would wrongly "succeed" (TCK's Pattern1 `[11]`, `InvalidArgumentType`
1655/// expected "at compile time"). `Scalar`/`Unknown` both pass -- a `Scalar`
1656/// could still turn out to be a non-boolean scalar (a string/int
1657/// variable), which stays a real runtime `value_to_bool3` error, same
1658/// tolerance every other `Kind::Scalar` check in this module already
1659/// gives.
1660fn require_boolean_predicate_kind(kind: &Kind, context: &str) -> Result<(), QueryError> {
1661 match kind {
1662 Kind::Scalar | Kind::Unknown => Ok(()),
1663 other => Err(semantic(format!(
1664 "{context} requires a boolean, but found {}",
1665 kind_name(other)
1666 ))),
1667 }
1668}