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