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