use std::collections::HashMap;
use crate::ast::{
is_aggregate_name, ArithOp, CallClause, CallYield, Expr, Literal, MergeClause, NodePattern,
Pattern, QueryClause, RemoveItem, ReturnExpr, ReturnItem, ReturnTail, SetItem, Statement, Tail,
UnwindClause, WithClause, WithExpr,
};
use crate::QueryError;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Kind {
Node,
Edge,
Scalar,
List(Box<Kind>),
Map,
Path,
Unknown,
}
type Scope = HashMap<String, Kind>;
pub fn validate_statement(statement: &Statement) -> Result<(), QueryError> {
match statement {
Statement::Create(patterns) => {
let mut scope = Scope::new();
for pattern in patterns {
bind_create_pattern(pattern, &mut scope)?;
}
Ok(())
}
Statement::CreateIndex { .. } => Ok(()),
Statement::Explain(inner) => validate_statement(inner),
Statement::Union { parts, .. } => {
for part in parts {
validate_statement(part)?;
}
Ok(())
}
Statement::Match {
clauses,
tail,
order_by,
..
} => validate_match_clauses(clauses, tail, order_by, Scope::new(), true),
Statement::StandaloneCall(call) => validate_call_clause(call, &mut Scope::new()),
}
}
fn validate_call_clause(call: &CallClause, scope: &mut Scope) -> Result<(), QueryError> {
if let Some(args) = &call.args {
for arg in args {
infer_expr(arg, scope)?;
if crate::executor::contains_aggregate(arg) {
return Err(semantic(
"an aggregate function can't be used as a CALL argument",
));
}
}
}
if let Some(CallYield::Items(items, where_expr)) = &call.yield_items {
for (name, alias) in items {
let out_name = alias.clone().unwrap_or_else(|| name.clone());
if scope.contains_key(&out_name) {
return Err(semantic(format!(
"'{out_name}' is already bound -- CALL's YIELD can't reuse an already-bound \
name, whether from an outer scope or another output in the same YIELD"
)));
}
scope.insert(out_name, Kind::Unknown);
}
if let Some(w) = where_expr.as_deref() {
validate_pattern_expr(w, scope)?;
}
}
Ok(())
}
fn validate_match_clauses(
clauses: &[QueryClause],
tail: &Option<Tail>,
order_by: &Option<Vec<(ReturnExpr, crate::ast::SortDir)>>,
mut scope: Scope,
allow_mutation: bool,
) -> Result<(), QueryError> {
let reject_mutation = |clause_name: &str| -> Result<(), QueryError> {
if allow_mutation {
Ok(())
} else {
Err(semantic(format!(
"exists {{}} can't contain an updating clause ({clause_name}) -- only reading \
clauses (MATCH/UNWIND/WITH) are allowed inside it"
)))
}
};
for clause in clauses {
match clause {
QueryClause::Match(part) => {
let prior_scope = scope.clone();
bind_match_pattern(&part.pattern, &mut scope)?;
if part.shortest_path {
let start = part.pattern.start.var.as_deref().ok_or_else(|| {
semantic("shortestPath() start node must have a variable")
})?;
let end = part
.pattern
.hops
.first()
.and_then(|(_, node)| node.var.as_deref())
.ok_or_else(|| semantic("shortestPath() end node must have a variable"))?;
require_kind(&prior_scope, start, &Kind::Node, "shortestPath endpoint")?;
require_kind(&prior_scope, end, &Kind::Node, "shortestPath endpoint")?;
}
if let Some(path_var) = &part.path_var {
bind_kind(&mut scope, path_var, Kind::Path, "path variable")?;
}
if let Some(expr) = &part.where_clause {
validate_pattern_expr(expr, &scope)?;
}
apply_with(&part.with, &mut scope)?;
}
QueryClause::Unwind(clause) => bind_unwind(clause, &mut scope)?,
QueryClause::Merge(clause) => {
reject_mutation("MERGE")?;
bind_merge(clause, &mut scope)?
}
QueryClause::With(with) => scope = project_with(with, &scope)?,
QueryClause::Set(items) => {
reject_mutation("SET")?;
for item in items {
validate_set_item(item, &scope)?;
}
}
QueryClause::Delete { items, detach: _ } => {
reject_mutation("DELETE")?;
for expr in items {
validate_delete_target(expr, &scope)?;
}
}
QueryClause::Remove(items) => {
reject_mutation("REMOVE")?;
for item in items {
validate_remove_item(item, &scope)?;
}
}
QueryClause::Create(patterns) => {
reject_mutation("CREATE")?;
for pattern in patterns {
bind_create_pattern(pattern, &mut scope)?;
}
}
QueryClause::Call(call) => {
reject_mutation("CALL")?;
validate_call_clause(call, &mut scope)?;
apply_with(&call.with, &mut scope)?;
}
}
}
let input_scope = scope.clone();
let output_scope = validate_tail(tail, &mut scope, allow_mutation)?;
if let Some(order_by) = order_by {
let mut order_scope = input_scope;
order_scope.extend(output_scope);
let tail_items: Option<&[ReturnItem]> = match tail {
Some(Tail::Return(items, _)) => Some(items),
_ => None,
};
let tail_aggregates = tail_items.is_some_and(crate::executor::has_aggregate);
for (expr, _) in order_by {
if tail_aggregates {
if tail_items
.unwrap()
.iter()
.enumerate()
.any(|(i, item)| crate::executor::item_matches_leaf(expr, i, item))
{
continue;
}
crate::executor::validate_order_by_composed_expr(expr, tail_items.unwrap())?;
continue;
}
if crate::executor::contains_aggregate(expr) {
return Err(semantic(
"ORDER BY cannot use an aggregate function unless RETURN itself \
is aggregating",
));
}
infer_expr(expr, &order_scope)?;
}
}
Ok(())
}
fn bind_unwind(clause: &UnwindClause, scope: &mut Scope) -> Result<(), QueryError> {
let source_kind = infer_expr(&clause.source.0, scope)?;
let element_kind = match source_kind {
Kind::List(element) => *element,
Kind::Unknown | Kind::Scalar => Kind::Unknown,
other => {
return Err(semantic(format!(
"UNWIND source is {}, not a list",
kind_name(&other)
)))
}
};
scope.insert(clause.var.clone(), element_kind);
if let Some(expr) = &clause.where_clause {
validate_with_expr(expr, scope)?;
}
apply_with(&clause.with, scope)
}
fn bind_merge(clause: &MergeClause, scope: &mut Scope) -> Result<(), QueryError> {
let pattern = &clause.pattern;
if pattern.hops.is_empty() {
if let Some(var) = &pattern.start.var {
if pattern.start.labels.is_empty()
&& !pattern.start.has_explicit_props
&& scope.contains_key(var)
{
return Err(semantic(format!(
"'{var}' is already bound — MERGE ({var}) with no relationship and no \
labels/properties doesn't search for or create anything"
)));
}
}
}
check_no_new_predicates_on_bound_node(&pattern.start, scope, "MERGE")?;
for (_, node) in &pattern.hops {
check_no_new_predicates_on_bound_node(node, scope, "MERGE")?;
}
for (rel, _) in &pattern.hops {
if let Some(var) = &rel.var {
if scope.contains_key(var) {
return Err(semantic(format!(
"'{var}' is already bound — MERGE can't reuse an existing relationship \
variable as its own pattern token"
)));
}
}
if rel.rel_types.len() != 1 {
return Err(semantic(
"MERGE requires exactly one explicit relationship type (e.g. -[:KNOWS]->) -- an \
untyped or multi-typed relationship pattern can't be created if the MERGE \
doesn't find a match",
));
}
}
bind_match_pattern(pattern, scope)?;
if let Some(path_var) = &clause.path_var {
bind_kind(scope, path_var, Kind::Path, "path variable")?;
}
for item in clause.on_create.iter().chain(&clause.on_match) {
validate_set_item(item, scope)?;
}
apply_with(&clause.with, scope)
}
fn bind_match_pattern(pattern: &Pattern, scope: &mut Scope) -> Result<(), QueryError> {
if let Some(var) = &pattern.start.var {
bind_kind(scope, var, Kind::Node, "node pattern")?;
}
for (rel, node) in &pattern.hops {
if let Some(var) = &rel.var {
let kind = if rel.hop_range.is_some() {
Kind::List(Box::new(Kind::Edge))
} else {
Kind::Edge
};
bind_kind(scope, var, kind, "relationship pattern")?;
}
if let Some(var) = &node.var {
bind_kind(scope, var, Kind::Node, "node pattern")?;
}
}
Ok(())
}
fn bind_create_pattern(pattern: &Pattern, scope: &mut Scope) -> Result<(), QueryError> {
validate_props(&pattern.start.props, scope)?;
check_create_node_not_already_bound(&pattern.start, scope, pattern.hops.is_empty())?;
if let Some(var) = &pattern.start.var {
bind_kind(scope, var, Kind::Node, "CREATE node")?;
}
for (rel, node) in &pattern.hops {
validate_props(&node.props, scope)?;
check_create_node_not_already_bound(node, scope, false)?;
if let Some(var) = &node.var {
bind_kind(scope, var, Kind::Node, "CREATE node")?;
}
if rel.rel_types.len() != 1 {
return Err(semantic(
"CREATE requires exactly one explicit relationship type (e.g. -[:KNOWS]->) -- \
unlike MATCH, an untyped or multi-typed relationship pattern can't be created",
));
}
validate_props(&rel.props, scope)?;
if let Some(var) = &rel.var {
bind_kind(scope, var, Kind::Edge, "CREATE relationship")?;
}
}
Ok(())
}
fn check_create_node_not_already_bound(
node: &NodePattern,
scope: &Scope,
is_bare: bool,
) -> Result<(), QueryError> {
let Some(var) = &node.var else {
return Ok(());
};
if !scope.contains_key(var) {
return Ok(());
}
if is_bare && node.labels.is_empty() && !node.has_explicit_props {
return Err(semantic(format!(
"'{var}' is already bound — CREATE ({var}) with no relationship and no new \
labels/properties doesn't create or connect anything"
)));
}
check_no_new_predicates_on_bound_node(node, scope, "CREATE")
}
fn check_no_new_predicates_on_bound_node(
node: &NodePattern,
scope: &Scope,
verb: &str,
) -> Result<(), QueryError> {
let Some(var) = &node.var else {
return Ok(());
};
if !scope.contains_key(var) {
return Ok(());
}
if !node.labels.is_empty() || node.has_explicit_props {
return Err(semantic(format!(
"'{var}' is already bound — {verb} can't add labels/properties to an existing node"
)));
}
Ok(())
}
fn validate_props(props: &[(String, ReturnExpr)], scope: &Scope) -> Result<(), QueryError> {
for (_, expr) in props {
infer_expr(expr, scope)?;
}
Ok(())
}
fn apply_with(with: &Option<WithClause>, scope: &mut Scope) -> Result<(), QueryError> {
if let Some(with) = with {
*scope = project_with(with, scope)?;
}
Ok(())
}
fn project_with(with: &WithClause, input: &Scope) -> Result<Scope, QueryError> {
let with_owned;
let with: &WithClause = if with.star {
let star_items = crate::executor::with_star_items(input.keys().cloned());
let mut owned = with.clone();
let mut items = star_items;
items.extend(owned.items);
owned.items = items;
with_owned = owned;
&with_owned
} else {
with
};
crate::executor::validate_return_items(&with.items)?;
let mut projected = Scope::new();
for (index, item) in with.items.iter().enumerate() {
if item.alias.is_none() && !matches!(item.expr, ReturnExpr::Var(_)) {
return Err(semantic(
"WITH requires an alias (AS ...) for every item except a bare variable reference",
));
}
let kind = infer_expr(&item.expr, input)?;
let name = item_output_name(index, item);
if projected.insert(name.clone(), kind).is_some() {
return Err(semantic(format!(
"WITH projects duplicate variable '{name}'"
)));
}
}
if let Some(expr) = &with.where_clause {
if crate::executor::has_aggregate(&with.items) {
validate_with_expr(expr, &projected)?;
} else {
let mut merged = input.clone();
merged.extend(projected.iter().map(|(k, v)| (k.clone(), v.clone())));
validate_with_expr(expr, &merged)?;
}
}
if let Some(order_by) = &with.order_by {
let with_aggregates = crate::executor::has_aggregate(&with.items);
let order_scope = if with_aggregates || with.distinct {
projected.clone()
} else {
let mut merged = input.clone();
merged.extend(projected.iter().map(|(k, v)| (k.clone(), v.clone())));
merged
};
for (expr, _) in order_by {
if (with_aggregates || with.distinct)
&& with
.items
.iter()
.enumerate()
.any(|(i, item)| crate::executor::item_matches_leaf(expr, i, item))
{
continue;
}
if with_aggregates {
crate::executor::validate_order_by_composed_expr(expr, &with.items)?;
continue;
}
if crate::executor::contains_aggregate(expr) {
return Err(semantic(
"ORDER BY cannot use an aggregate function unless WITH itself is \
aggregating",
));
}
infer_expr(expr, &order_scope)?;
}
}
Ok(projected)
}
fn validate_tail(
tail: &Option<Tail>,
scope: &mut Scope,
allow_mutation: bool,
) -> Result<Scope, QueryError> {
let Some(tail) = tail else {
return Ok(Scope::new());
};
let reject_mutation = |clause_name: &str| -> Result<(), QueryError> {
if allow_mutation {
Ok(())
} else {
Err(semantic(format!(
"exists {{}} can't contain an updating clause ({clause_name}) -- only reading \
clauses (MATCH/UNWIND/WITH) are allowed inside it"
)))
}
};
match tail {
Tail::Return(items, _) => project_return(items, scope),
Tail::ReturnStar(_) => {
let items = crate::executor::return_star_items(scope.keys().cloned())?;
project_return(&items, scope)
}
Tail::Delete(exprs, ret) | Tail::DetachDelete(exprs, ret) => {
reject_mutation("DELETE")?;
for expr in exprs {
validate_delete_target(expr, scope)?;
}
validate_return_tail(ret, scope)
}
Tail::Set(items, ret) => {
reject_mutation("SET")?;
for item in items {
validate_set_item(item, scope)?;
}
validate_return_tail(ret, scope)
}
Tail::Remove(items, ret) => {
reject_mutation("REMOVE")?;
for item in items {
validate_remove_item(item, scope)?;
}
validate_return_tail(ret, scope)
}
Tail::Create(patterns, ret) => {
reject_mutation("CREATE")?;
for pattern in patterns {
bind_create_pattern(pattern, scope)?;
}
validate_return_tail(ret, scope)
}
}
}
fn validate_return_tail(ret: &Option<ReturnTail>, scope: &Scope) -> Result<Scope, QueryError> {
match ret {
Some(ret) => project_return(&ret.items, scope),
None => Ok(Scope::new()),
}
}
fn project_return(items: &[ReturnItem], scope: &Scope) -> Result<Scope, QueryError> {
crate::executor::validate_return_items(items)?;
let mut projected = Scope::new();
for (index, item) in items.iter().enumerate() {
let name = item_output_name(index, item);
let kind = infer_expr(&item.expr, scope)?;
let name_is_real =
item.alias.is_some() || matches!(item.expr, ReturnExpr::Var(_) | ReturnExpr::Prop(_));
let existing = projected.insert(name.clone(), kind);
if name_is_real && existing.is_some() {
return Err(semantic(format!(
"RETURN projects duplicate column name '{name}'"
)));
}
}
Ok(projected)
}
fn validate_delete_target(expr: &ReturnExpr, scope: &Scope) -> Result<(), QueryError> {
if !matches!(expr, ReturnExpr::Lit(Literal::Null))
&& matches!(
expr,
ReturnExpr::Lit(_)
| ReturnExpr::CountStar
| ReturnExpr::Arith(..)
| ReturnExpr::Neg(..)
| ReturnExpr::And(..)
| ReturnExpr::Or(..)
| ReturnExpr::Xor(..)
| ReturnExpr::Not(..)
| ReturnExpr::Compare(..)
| ReturnExpr::IsNull(..)
| ReturnExpr::In(..)
| ReturnExpr::MapLit(..)
| ReturnExpr::ListLit(..)
| ReturnExpr::HasLabel(..)
)
{
return Err(semantic(
"DELETE target must evaluate to a node, relationship, or path -- a \
literal/arithmetic/boolean/map/list expression never can",
));
}
let kind = infer_expr(expr, scope)?;
if !matches!(
kind,
Kind::Node | Kind::Edge | Kind::Path | Kind::Unknown | Kind::Scalar
) {
return Err(semantic(format!(
"DELETE target is {}, not a node, relationship, or path",
kind_name(&kind)
)));
}
Ok(())
}
fn validate_set_item(item: &SetItem, scope: &Scope) -> Result<(), QueryError> {
match item {
SetItem::Prop(access, value) => {
require_graph(scope, &access.var, "SET property target")?;
infer_expr(value, scope)?;
Ok(())
}
SetItem::Labels(var, _) => require_kind(scope, var, &Kind::Node, "SET label target"),
SetItem::MapAssign { var, value, .. } => {
require_graph(scope, var, "SET map-assignment target")?;
infer_expr(value, scope)?;
Ok(())
}
}
}
fn validate_remove_item(item: &RemoveItem, scope: &Scope) -> Result<(), QueryError> {
match item {
RemoveItem::Prop(access) => require_graph(scope, &access.var, "REMOVE property target"),
RemoveItem::Labels(var, _) => require_kind(scope, var, &Kind::Node, "REMOVE label target"),
}
}
fn reject_aggregate_in_where(expr: &ReturnExpr) -> Result<(), QueryError> {
if crate::executor::contains_aggregate(expr) {
return Err(semantic(
"an aggregate function can't be used inside a WHERE clause",
));
}
Ok(())
}
fn validate_pattern_expr(expr: &Expr, scope: &Scope) -> Result<(), QueryError> {
match expr {
Expr::And(left, right) | Expr::Or(left, right) => {
validate_pattern_expr(left, scope)?;
validate_pattern_expr(right, scope)
}
Expr::Not(inner) => validate_pattern_expr(inner, scope),
Expr::Compare(access, _, _) | Expr::IsNull(access) => {
require_property_owner(scope, &access.var)
}
Expr::PropCompare(left, _, right) => {
require_property_owner(scope, &left.var)?;
require_property_owner(scope, &right.var)
}
Expr::HasLabel(var, _) => require_kind(scope, var, &Kind::Node, "label predicate"),
Expr::VarEq(left, right) => {
require_graph(scope, left, "identity predicate")?;
require_graph(scope, right, "identity predicate")
}
Expr::GeneralCompare(left, _, right) => {
infer_expr(left, scope)?;
infer_expr(right, scope)?;
reject_aggregate_in_where(left)?;
reject_aggregate_in_where(right)
}
Expr::GeneralIsNull(e) => {
infer_expr(e, scope)?;
reject_aggregate_in_where(e)
}
Expr::GeneralBare(e) => {
let kind = infer_expr(e, scope)?;
require_boolean_predicate_kind(&kind, "WHERE predicate")?;
reject_aggregate_in_where(e)
}
Expr::Pattern(pattern) => validate_pattern_predicate(pattern, scope),
Expr::Exists {
pattern,
where_clause,
} => {
let mut inner_scope = scope.clone();
bind_match_pattern(pattern, &mut inner_scope)?;
if let Some(w) = where_clause.as_deref() {
validate_pattern_expr(w, &inner_scope)?;
}
Ok(())
}
Expr::ExistsSubquery(stmt) => {
let Statement::Match {
clauses,
tail,
order_by,
..
} = stmt.as_ref()
else {
return Err(semantic(
"exists {} subquery must be a MATCH ... RETURN ... statement",
));
};
validate_match_clauses(clauses, tail, order_by, scope.clone(), false)
}
Expr::EdgeNotInSet { .. } => {
unreachable!("Expr::EdgeNotInSet is only ever synthesized by the planner")
}
}
}
fn validate_pattern_predicate(pattern: &Pattern, scope: &Scope) -> Result<(), QueryError> {
if let Some(var) = &pattern.start.var {
require_kind(scope, var, &Kind::Node, "pattern predicate node")?;
}
validate_props(&pattern.start.props, scope)?;
for (rel, node) in &pattern.hops {
if let Some(var) = &rel.var {
require_kind(scope, var, &Kind::Edge, "pattern predicate relationship")?;
}
validate_props(&rel.props, scope)?;
if let Some(var) = &node.var {
require_kind(scope, var, &Kind::Node, "pattern predicate node")?;
}
validate_props(&node.props, scope)?;
}
Ok(())
}
fn validate_with_expr(expr: &WithExpr, scope: &Scope) -> Result<(), QueryError> {
match expr {
WithExpr::And(left, right) | WithExpr::Or(left, right) => {
validate_with_expr(left, scope)?;
validate_with_expr(right, scope)
}
WithExpr::Not(inner) => validate_with_expr(inner, scope),
WithExpr::Compare(left, _, right) => {
infer_expr(left, scope)?;
infer_expr(right, scope)?;
Ok(())
}
WithExpr::IsNull(e) => {
infer_expr(e, scope)?;
Ok(())
}
WithExpr::Bare(ReturnExpr::PatternPredicate(pattern)) => {
validate_pattern_predicate(pattern, scope)
}
WithExpr::Bare(e) => {
let kind = infer_expr(e, scope)?;
require_boolean_predicate_kind(&kind, "WHERE predicate")
}
}
}
fn function_arity(name: &str) -> Option<(usize, Option<usize>)> {
Some(match name.to_ascii_lowercase().as_str() {
"count" | "sum" | "avg" | "min" | "max" | "collect" => (1, Some(1)),
"percentilecont" | "percentiledisc" => (2, Some(2)),
"coalesce" => (1, None),
"tointeger" | "tostring" | "tofloat" | "toboolean" => (1, Some(1)),
"date" | "localtime" | "time" | "localdatetime" | "datetime" => (0, Some(1)),
"date.transaction"
| "date.statement"
| "date.realtime"
| "localtime.transaction"
| "localtime.statement"
| "localtime.realtime"
| "time.transaction"
| "time.statement"
| "time.realtime"
| "localdatetime.transaction"
| "localdatetime.statement"
| "localdatetime.realtime"
| "datetime.transaction"
| "datetime.statement"
| "datetime.realtime" => (0, Some(1)),
"rand" => (0, Some(0)),
"duration" => (1, Some(1)),
"datetime.fromepoch" => (2, Some(2)),
"datetime.fromepochmillis" => (1, Some(1)),
"duration.between" | "duration.inmonths" | "duration.indays" | "duration.inseconds" => {
(2, Some(2))
}
"date.truncate"
| "localtime.truncate"
| "time.truncate"
| "localdatetime.truncate"
| "datetime.truncate" => (2, Some(3)),
"length" | "nodes" | "relationships" | "type" | "startnode" | "endnode" | "keys"
| "labels" | "properties" | "id" | "size" | "exists" | "head" | "last" | "tail"
| "toupper" | "upper" | "tolower" | "lower" | "trim" | "ltrim" | "rtrim" | "reverse"
| "abs" | "ceil" | "floor" | "round" | "sqrt" | "sign" => (1, Some(1)),
"range" => (2, Some(3)),
"split" | "left" | "right" => (2, Some(2)),
"substring" => (2, Some(3)),
"replace" => (3, Some(3)),
_ => return None,
})
}
fn check_arity(name: &str, arg_count: usize) -> Result<(), QueryError> {
let Some((min, max)) = function_arity(name) else {
return Ok(());
};
let ok = arg_count >= min && max.is_none_or(|max| arg_count <= max);
if ok {
return Ok(());
}
let arg_word = |n: usize| if n == 1 { "argument" } else { "arguments" };
let expected = match max {
Some(max) if max == min => format!("exactly {min} {}", arg_word(min)),
Some(max) => format!("{min} to {max} arguments"),
None => format!("at least {min} {}", arg_word(min)),
};
Err(semantic(format!(
"{name}() expects {expected}, got {arg_count}"
)))
}
fn infer_expr(expr: &ReturnExpr, scope: &Scope) -> Result<Kind, QueryError> {
Ok(match expr {
ReturnExpr::Var(var) => lookup(scope, var, "expression")?.clone(),
ReturnExpr::Prop(access) => {
require_property_owner(scope, &access.var)?;
Kind::Scalar
}
ReturnExpr::PropOf(base, _) => {
infer_expr(base, scope)?;
Kind::Scalar
}
ReturnExpr::Lit(Literal::Null) => Kind::Unknown,
ReturnExpr::Lit(_) | ReturnExpr::CountStar => Kind::Scalar,
ReturnExpr::Call { name, args, .. } => {
check_arity(name, args.len())?;
let arg_kinds = args
.iter()
.map(|arg| infer_expr(arg, scope))
.collect::<Result<Vec<_>, _>>()?;
if is_aggregate_name(name) {
if name.eq_ignore_ascii_case("collect") {
Kind::List(Box::new(
arg_kinds.first().cloned().unwrap_or(Kind::Unknown),
))
} else {
Kind::Scalar
}
} else {
match name.to_ascii_lowercase().as_str() {
"coalesce" => unify_many(&arg_kinds),
"tointeger"
| "tostring"
| "tofloat"
| "toboolean"
| "date"
| "duration"
| "localtime"
| "time"
| "localdatetime"
| "datetime"
| "duration.between"
| "duration.inmonths"
| "duration.indays"
| "duration.inseconds"
| "date.truncate"
| "localtime.truncate"
| "time.truncate"
| "localdatetime.truncate"
| "datetime.truncate"
| "date.transaction"
| "date.statement"
| "date.realtime"
| "localtime.transaction"
| "localtime.statement"
| "localtime.realtime"
| "time.transaction"
| "time.statement"
| "time.realtime"
| "localdatetime.transaction"
| "localdatetime.statement"
| "localdatetime.realtime"
| "datetime.transaction"
| "datetime.statement"
| "datetime.realtime"
| "datetime.fromepoch"
| "datetime.fromepochmillis" => Kind::Scalar,
"length" => {
if let Some(kind) = arg_kinds.first() {
require_path_or_null(kind, "length() argument")?;
}
Kind::Scalar
}
"nodes" => {
if let Some(kind) = arg_kinds.first() {
require_path_or_null(kind, "nodes() argument")?;
}
Kind::List(Box::new(Kind::Node))
}
"relationships" => {
if let Some(kind) = arg_kinds.first() {
require_path_or_null(kind, "relationships() argument")?;
}
Kind::List(Box::new(Kind::Edge))
}
"type" => {
if let Some(kind) = arg_kinds.first() {
if !matches!(kind, Kind::Edge | Kind::Scalar | Kind::Unknown) {
return Err(semantic(format!(
"type() argument requires a relationship, but found {}",
kind_name(kind)
)));
}
}
Kind::Scalar
}
"startnode" | "endnode" => {
if let Some(kind) = arg_kinds.first() {
require_compatible_kind(
kind,
&Kind::Edge,
"startNode()/endNode() argument",
)?;
}
Kind::Node
}
"keys" | "labels" => Kind::List(Box::new(Kind::Scalar)),
"size" => {
if let Some(Kind::Path) = arg_kinds.first() {
return Err(semantic(
"size() doesn't accept a path -- use length() instead",
));
}
Kind::Scalar
}
"id" | "exists" => Kind::Scalar,
"properties" => Kind::Map,
"head" | "last" => match arg_kinds.first() {
Some(Kind::List(inner)) => (**inner).clone(),
_ => Kind::Unknown,
},
"tail" => match arg_kinds.first() {
Some(kind @ Kind::List(_)) => kind.clone(),
_ => Kind::Unknown,
},
"range" | "split" => Kind::List(Box::new(Kind::Scalar)),
"toupper" | "upper" | "tolower" | "lower" | "trim" | "ltrim" | "rtrim"
| "replace" | "substring" | "left" | "right" | "abs" | "ceil" | "floor"
| "round" | "sqrt" | "sign" | "rand" => Kind::Scalar,
"reverse" => arg_kinds.first().cloned().unwrap_or(Kind::Unknown),
other => return Err(semantic(format!("unknown function '{other}'"))),
}
}
}
ReturnExpr::Case { test, whens, else_ } => {
if let Some(test) = test {
infer_expr(test, scope)?;
}
let mut result_kinds = Vec::new();
for (when, then) in whens {
infer_expr(when, scope)?;
result_kinds.push(infer_expr(then, scope)?);
}
if let Some(else_) = else_ {
result_kinds.push(infer_expr(else_, scope)?);
}
unify_many(&result_kinds)
}
ReturnExpr::Arith(left, op, right) => {
let lk = infer_expr(left, scope)?;
let rk = infer_expr(right, scope)?;
if *op == ArithOp::Add && (matches!(lk, Kind::List(_)) || matches!(rk, Kind::List(_))) {
let elem = |k: Kind| match k {
Kind::List(inner) => *inner,
other => other,
};
Kind::List(Box::new(unify_many(&[elem(lk), elem(rk)])))
} else {
require_scalarish(&lk, "arithmetic operand")?;
require_scalarish(&rk, "arithmetic operand")?;
Kind::Scalar
}
}
ReturnExpr::Neg(e) => {
let k = infer_expr(e, scope)?;
require_scalarish(&k, "unary minus operand")?;
Kind::Scalar
}
ReturnExpr::ListLit(items) => {
let kinds = items
.iter()
.map(|item| infer_expr(item, scope))
.collect::<Result<Vec<_>, _>>()?;
Kind::List(Box::new(unify_many(&kinds)))
}
ReturnExpr::Index(base, index) => {
require_scalarish(&infer_expr(index, scope)?, "list index")?;
match infer_expr(base, scope)? {
Kind::List(element) => *element,
Kind::Map => Kind::Scalar,
Kind::Unknown | Kind::Scalar => Kind::Unknown,
Kind::Node | Kind::Edge => Kind::Scalar,
other => {
return Err(semantic(format!(
"index base is {}, not a list or map",
kind_name(&other)
)))
}
}
}
ReturnExpr::Slice(base, start, end) => {
if let Some(start) = start {
require_scalarish(&infer_expr(start, scope)?, "slice bound")?;
}
if let Some(end) = end {
require_scalarish(&infer_expr(end, scope)?, "slice bound")?;
}
match infer_expr(base, scope)? {
list @ Kind::List(_) => list,
Kind::Unknown => Kind::List(Box::new(Kind::Unknown)),
other => {
return Err(semantic(format!(
"slice base is {}, not a list",
kind_name(&other)
)))
}
}
}
ReturnExpr::ListComp {
var,
source,
where_clause,
project,
} => {
let element = list_element(infer_expr(source, scope)?, "list comprehension source")?;
let mut local = scope.clone();
local.insert(var.clone(), element.clone());
if let Some(where_clause) = where_clause {
require_scalarish(&infer_expr(where_clause, &local)?, "list filter")?;
}
let projected = match project {
Some(project) => infer_expr(project, &local)?,
None => element,
};
Kind::List(Box::new(projected))
}
ReturnExpr::Quantifier {
var,
source,
where_clause,
..
} => {
let element = list_element(infer_expr(source, scope)?, "quantifier source")?;
let mut local = scope.clone();
local.insert(var.clone(), element);
if let Some(where_clause) = where_clause {
require_scalarish(&infer_expr(where_clause, &local)?, "quantifier predicate")?;
}
Kind::Scalar
}
ReturnExpr::MapLit(entries) => {
for (_, value) in entries {
infer_expr(value, scope)?;
}
Kind::Map
}
ReturnExpr::And(left, right)
| ReturnExpr::Or(left, right)
| ReturnExpr::Xor(left, right) => {
require_scalarish(&infer_expr(left, scope)?, "boolean operand")?;
require_scalarish(&infer_expr(right, scope)?, "boolean operand")?;
Kind::Scalar
}
ReturnExpr::Not(inner) => {
require_scalarish(&infer_expr(inner, scope)?, "boolean operand")?;
Kind::Scalar
}
ReturnExpr::Compare(left, _, right) => {
infer_expr(left, scope)?;
infer_expr(right, scope)?;
Kind::Scalar
}
ReturnExpr::IsNull(inner) => {
infer_expr(inner, scope)?;
Kind::Scalar
}
ReturnExpr::In(needle, haystack) => {
infer_expr(needle, scope)?;
infer_expr(haystack, scope)?;
Kind::Scalar
}
ReturnExpr::HasLabel(var, _) => {
require_graph(scope, var, "(n:Label) target")?;
Kind::Scalar
}
ReturnExpr::PatternPredicate(_) => {
return Err(QueryError::Semantic(
"a pattern predicate (`(n)-->()` etc) can only be used inside WHERE".into(),
))
}
ReturnExpr::PatternComprehension {
path_var,
pattern,
where_clause,
projection,
} => {
if path_var.is_some() {
crate::parse_helpers::validate_named_path_pattern(pattern)?;
}
let mut inner_scope = scope.clone();
bind_match_pattern(pattern, &mut inner_scope)?;
if let Some(path_var) = path_var {
bind_kind(&mut inner_scope, path_var, Kind::Path, "path variable")?;
}
if let Some(where_expr) = where_clause {
validate_pattern_expr(where_expr, &inner_scope)?;
}
Kind::List(Box::new(infer_expr(projection, &inner_scope)?))
}
ReturnExpr::ExistsPattern { .. } | ReturnExpr::ExistsSubquery(_) => {
return Err(QueryError::Semantic(
"an exists {} subquery can only be used inside WHERE".into(),
))
}
})
}
fn list_element(kind: Kind, context: &str) -> Result<Kind, QueryError> {
match kind {
Kind::List(element) => Ok(*element),
Kind::Unknown | Kind::Scalar => Ok(Kind::Unknown),
other => Err(semantic(format!(
"{context} is {}, not a list",
kind_name(&other)
))),
}
}
fn bind_kind(
scope: &mut Scope,
var: &str,
expected: Kind,
context: &str,
) -> Result<(), QueryError> {
match scope.get(var) {
Some(actual) => require_compatible_kind(actual, &expected, context),
None => {
scope.insert(var.to_string(), expected);
Ok(())
}
}
}
fn require_kind(
scope: &Scope,
var: &str,
expected: &Kind,
context: &str,
) -> Result<(), QueryError> {
let actual = lookup(scope, var, context)?;
require_compatible_kind(actual, expected, context)
}
fn require_compatible_kind(
actual: &Kind,
expected: &Kind,
context: &str,
) -> Result<(), QueryError> {
if actual == expected || matches!(actual, Kind::Unknown) {
return Ok(());
}
Err(semantic(format!(
"{context} requires {}, but found {}",
kind_name(expected),
kind_name(actual)
)))
}
fn require_path_or_null(actual: &Kind, context: &str) -> Result<(), QueryError> {
if matches!(actual, Kind::Path | Kind::Scalar | Kind::Unknown) {
return Ok(());
}
Err(semantic(format!(
"{context} requires {}, but found {}",
kind_name(&Kind::Path),
kind_name(actual)
)))
}
fn require_graph(scope: &Scope, var: &str, context: &str) -> Result<(), QueryError> {
let actual = lookup(scope, var, context)?;
if matches!(actual, Kind::Node | Kind::Edge | Kind::Unknown) {
Ok(())
} else {
Err(semantic(format!(
"{context} '{var}' is {}, not a node or relationship",
kind_name(actual)
)))
}
}
fn require_property_owner(scope: &Scope, var: &str) -> Result<(), QueryError> {
let kind = lookup(scope, var, "property access")?;
if matches!(kind, Kind::Path) {
return Err(semantic(format!(
"'{var}' is a path — property access requires a node, relationship, or map"
)));
}
Ok(())
}
fn require_scalarish(kind: &Kind, context: &str) -> Result<(), QueryError> {
if matches!(kind, Kind::Scalar | Kind::Unknown) {
Ok(())
} else {
Err(semantic(format!(
"{context} cannot use {}",
kind_name(kind)
)))
}
}
fn lookup<'a>(scope: &'a Scope, var: &str, context: &str) -> Result<&'a Kind, QueryError> {
scope
.get(var)
.ok_or_else(|| semantic(format!("{context} references undefined variable '{var}'")))
}
fn unify_many(kinds: &[Kind]) -> Kind {
let Some(first) = kinds.first() else {
return Kind::Unknown;
};
if kinds.iter().all(|kind| kind == first) {
first.clone()
} else {
Kind::Unknown
}
}
fn item_output_name(index: usize, item: &ReturnItem) -> String {
item.alias
.clone()
.unwrap_or_else(|| default_output_name(&item.expr, index))
}
fn default_output_name(expr: &ReturnExpr, index: usize) -> String {
match expr {
ReturnExpr::Var(var) => var.clone(),
ReturnExpr::Prop(access) => format!("{}.{}", access.var, access.prop),
ReturnExpr::Call { name, .. } => format!("{name}(...)"),
ReturnExpr::CountStar => "count(*)".to_string(),
ReturnExpr::Case { .. } => format!("case{index}"),
_ => format!("col{index}"),
}
}
fn kind_name(kind: &Kind) -> &'static str {
match kind {
Kind::Node => "a node",
Kind::Edge => "a relationship",
Kind::Scalar => "a scalar",
Kind::List(_) => "a list",
Kind::Map => "a map",
Kind::Path => "a path",
Kind::Unknown => "a dynamically typed value",
}
}
fn semantic(message: impl Into<String>) -> QueryError {
QueryError::Semantic(message.into())
}
fn require_boolean_predicate_kind(kind: &Kind, context: &str) -> Result<(), QueryError> {
match kind {
Kind::Scalar | Kind::Unknown => Ok(()),
other => Err(semantic(format!(
"{context} requires a boolean, but found {}",
kind_name(other)
))),
}
}