Skip to main content

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}