Skip to main content

graphforge_ir/
binder.rs

1//! Binder — lowers an [`AstQuery`] to a [`GraphPlan`].
2//!
3//! The binder is the stage between the parser and the execution engine.  It:
4//! - Resolves label/relation-type/property strings to integer IDs
5//! - Tracks variable scope and allocates [`VarId`]s
6//! - Emits the correct operators (e.g. relationship patterns become `Expand`,
7//!   node patterns become `NodeScan`)
8//! - Populates the [`ExprArena`] while lowering WHERE / RETURN expressions
9//! - Supports three ontology modes (exploratory / advisory / strict)
10
11use std::collections::{HashMap, HashSet};
12use std::sync::{Arc, Mutex};
13
14use graphforge_ast::{
15    AstClause, AstQuery, BinaryOpKind as AstBinOp, CallClause, CaseExpr, CreateClause,
16    DialectVersion, ExistentialSubqueryBody, Expr, FunctionCall, LabelPredicate, Literal,
17    MapLiteral, PathElement, PathPattern, PatternPredicate, PropertyAccess, RemoveClause,
18    RemoveItem, ReturnItem, SetClause, SetItem, SortItem, SortOrder as AstSortOrder, StringOpKind,
19    UnaryOpKind as AstUnOp, VarRef, WhereClause, WithClause,
20};
21use graphforge_core::{PropId, Span, TypeId};
22use graphforge_ontology::OntologyHandle;
23
24use crate::catalog::RuntimeCatalog;
25use crate::expr::{BinaryOpKind, CaseArm, IrExpr, IrLiteral, UnaryOpKind};
26use crate::plan::{
27    GraphOp, GraphPlan, GraphPlanBuilder, OntologyMode, PATTERN_COMPREHENSION_VALUE_ALIAS, SortKey,
28};
29use crate::{
30    AggExpr, AggFunc, CreateEdgeSpec, CreateNodeSpec, CreatePattern, Direction, ExprId,
31    MergeSetItem, OntologyVersion, ProcedureRegistry, ProcedureYield, ProjectItem, RemovePropItem,
32    SetMapItem, SetPropItem, SortOrder, VarId,
33};
34
35// ---------------------------------------------------------------------------
36// BindError
37// ---------------------------------------------------------------------------
38
39/// The kind of a binder error.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum BindErrorKind {
42    /// A label name was not found in the ontology (strict mode).
43    UnknownLabel,
44    /// A relation type name was not found in the ontology (strict mode).
45    UnknownRelationType,
46    /// A property name was not found in the ontology (strict mode).
47    UnknownProperty,
48    /// A variable was referenced before it was introduced by a MATCH pattern.
49    UndeclaredVariable,
50    /// The same variable was introduced more than once in a conflicting way.
51    DuplicateVariable,
52    /// A property name is ambiguous across multiple owner labels.
53    AmbiguousProperty,
54    /// A clause is recognized by the parser but not yet implemented by the
55    /// binder/executor (SET/REMOVE/CALL). Surfaced as an error instead
56    /// of a silent no-op (#724).
57    UnsupportedClause,
58    /// A `DELETE` target was not a plain bound variable (e.g. `DELETE n.prop`
59    /// or `DELETE n + 1`). Only `DELETE <var>` is supported (#740).
60    InvalidDeleteTarget,
61    /// A variable is used with two incompatible kinds — e.g. a relationship
62    /// variable reused as a node pattern (`MATCH ()-[r]-() MATCH (r)`).
63    /// openCypher rejects this as a compile-time `VariableTypeConflict` (#956).
64    VariableKindConflict,
65    /// An already-bound variable is re-declared by a CREATE/MERGE pattern
66    /// (`MATCH (a) CREATE (a)`, a reused relationship variable, a bound node
67    /// given new labels/properties). openCypher `VariableAlreadyBound` (#956).
68    VariableAlreadyBound,
69    /// A clause or function argument is semantically invalid — a non-integer
70    /// `range()`/`SKIP`/`LIMIT` argument, an aggregate in `WHERE`, a CREATE
71    /// relationship with no/var-length/undirected type, a UNION column
72    /// mismatch, etc. Covers openCypher's `ArgumentError` / `InvalidAggregation`
73    /// / `NoSingleRelationshipType` / … — the harness checks the phase, not the
74    /// sub-code, so one kind with a descriptive message suffices (#956).
75    InvalidArgument,
76}
77
78/// The semantic kind a pattern variable is bound to, tracked so a later use
79/// with an incompatible kind is rejected (openCypher `VariableTypeConflict`,
80/// #956). Value-typed WITH/RETURN aliases are deliberately NOT tracked — only
81/// pattern bindings (node / relationship / path) participate in conflict
82/// detection. Value aliases are detected when a later pattern tries to reuse
83/// their already-bound name.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub(crate) enum VarKind {
86    /// Runtime-polymorphic value, such as an UNWIND element or literal null.
87    Unknown,
88    /// Bound by a node pattern element `(n)`.
89    Node,
90    /// Bound by a relationship pattern element `-[r]-` (fixed or variable-length).
91    Relationship,
92}
93
94impl std::fmt::Display for VarKind {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.write_str(match self {
97            VarKind::Unknown => "a runtime value",
98            VarKind::Node => "a node",
99            VarKind::Relationship => "a relationship",
100        })
101    }
102}
103
104/// A semantic error or warning produced by the binder.
105#[derive(Debug, Clone)]
106pub struct BindError {
107    /// The kind of error.
108    pub kind: BindErrorKind,
109    /// Source location of the offending token.
110    pub span: Span,
111    /// Human-readable message.
112    pub message: String,
113}
114
115impl BindError {
116    fn new(kind: BindErrorKind, span: Span, message: impl Into<String>) -> Self {
117        Self {
118            kind,
119            span,
120            message: message.into(),
121        }
122    }
123}
124
125// ---------------------------------------------------------------------------
126// Binder
127// ---------------------------------------------------------------------------
128
129/// Lowers an [`AstQuery`] to a typed [`GraphPlan`].
130///
131/// # Construction
132///
133/// ```
134/// use std::sync::{Arc, Mutex};
135/// use graphforge_ir::{Binder, OntologyMode, RuntimeCatalog};
136///
137/// let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
138/// let binder = Binder::new(None, catalog, OntologyMode::Exploratory);
139/// ```
140pub struct Binder {
141    ontology: Option<OntologyHandle>,
142    catalog: Arc<Mutex<RuntimeCatalog>>,
143    mode: OntologyMode,
144    procedures: Arc<ProcedureRegistry>,
145    typed_uuid_params: HashMap<String, UuidParamClass>,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149enum UuidParamClass {
150    ExactUuid,
151    ContainsUuid,
152}
153
154impl Binder {
155    /// Creates a new binder.
156    ///
157    /// - `ontology`: the compiled ontology handle, or `None` in exploratory mode.
158    /// - `catalog`: shared runtime catalog for auto-assigning IDs to unknown types.
159    /// - `mode`: controls how unknown labels/types are handled.
160    #[must_use]
161    pub fn new(
162        ontology: Option<OntologyHandle>,
163        catalog: Arc<Mutex<RuntimeCatalog>>,
164        mode: OntologyMode,
165    ) -> Self {
166        Self {
167            ontology,
168            catalog,
169            mode,
170            procedures: Arc::new(ProcedureRegistry::new()),
171            typed_uuid_params: HashMap::new(),
172        }
173    }
174
175    /// Supplies the procedures available while binding `CALL` clauses.
176    #[must_use]
177    pub fn with_procedures(mut self, procedures: Arc<ProcedureRegistry>) -> Self {
178        self.procedures = procedures;
179        self
180    }
181
182    /// Supplies query literals for bind-time validation of typed UUID predicates.
183    #[must_use]
184    pub fn with_parameter_literals(mut self, params: &HashMap<String, IrLiteral>) -> Self {
185        self.typed_uuid_params = params
186            .iter()
187            .filter_map(|(name, value)| {
188                classify_uuid_parameter(value).map(|class| (name.clone(), class))
189            })
190            .collect();
191        self
192    }
193
194    /// Bind an `AstQuery` and produce a [`GraphPlan`].
195    ///
196    /// In strict mode, all errors are collected before returning so that a
197    /// single call surfaces every problem in the query.
198    ///
199    /// # Errors
200    ///
201    /// Returns a non-empty `Vec<BindError>` when the query has semantic errors
202    /// (strict-mode type violations, undeclared variables, etc.).
203    pub fn bind(&self, ast: &AstQuery) -> Result<GraphPlan, Vec<BindError>> {
204        // Bind against an isolated catalog snapshot while holding the shared
205        // lock. A successful bind publishes exactly the snapshot whose IDs
206        // appear in the plan; a failed bind drops every staged observation.
207        let mut shared_catalog = self.catalog.lock().expect("runtime catalog poisoned");
208        let staged_catalog = Arc::new(Mutex::new(shared_catalog.clone()));
209        let staged_binder = Self {
210            ontology: self.ontology.clone(),
211            catalog: Arc::clone(&staged_catalog),
212            mode: self.mode,
213            procedures: Arc::clone(&self.procedures),
214            typed_uuid_params: self.typed_uuid_params.clone(),
215        };
216        let result = staged_binder.bind_staged(ast);
217        if result.is_ok() {
218            *shared_catalog = staged_catalog
219                .lock()
220                .expect("staged runtime catalog poisoned")
221                .clone();
222        }
223        result
224    }
225
226    fn bind_staged(&self, ast: &AstQuery) -> Result<GraphPlan, Vec<BindError>> {
227        let dialect = match ast.dialect {
228            DialectVersion::OpenCypher9 => "openCypher",
229        };
230
231        let ontology_version: Option<OntologyVersion> = self
232            .ontology
233            .as_ref()
234            .map(|h| OntologyVersion::from(format!("{}:{}", h.version(), h.checksum())));
235
236        if ast
237            .clauses
238            .iter()
239            .any(|clause| matches!(clause, AstClause::Union(_)))
240        {
241            return self.bind_union_query(ast, dialect, ontology_version);
242        }
243
244        let mut builder = GraphPlan::builder(dialect).ontology_mode(self.mode);
245        if let Some(v) = ontology_version {
246            builder = builder.ontology_version(v);
247        }
248
249        let mut state = BinderState {
250            vars: HashMap::new(),
251            path_vars: HashMap::new(),
252            node_vars: HashMap::new(),
253            edge_vars: HashMap::new(),
254            edge_rel_names: HashMap::new(),
255            scalar_list_edges: HashSet::new(),
256            var_kinds: HashMap::new(),
257            next_var: 0,
258            builder,
259            errors: Vec::new(),
260            warnings: Vec::new(),
261            captured_pattern_comprehensions: None,
262            existential_depth: 0,
263            standalone_call: ast.clauses.len() == 1 && matches!(ast.clauses[0], AstClause::Call(_)),
264        };
265
266        for clause in &ast.clauses {
267            self.lower_clause(clause, &mut state);
268        }
269
270        if !state.errors.is_empty() {
271            return Err(state.errors);
272        }
273        Ok(state.builder.build())
274    }
275
276    fn bind_union_query(
277        &self,
278        ast: &AstQuery,
279        dialect: &str,
280        ontology_version: Option<OntologyVersion>,
281    ) -> Result<GraphPlan, Vec<BindError>> {
282        let markers: Vec<(usize, &graphforge_ast::UnionClause)> = ast
283            .clauses
284            .iter()
285            .enumerate()
286            .filter_map(|(index, clause)| match clause {
287                AstClause::Union(union) => Some((index, union)),
288                _ => None,
289            })
290            .collect();
291        let all = markers[0].1.all;
292        if markers.iter().any(|(_, marker)| marker.all != all) {
293            return Err(vec![BindError::new(
294                BindErrorKind::InvalidArgument,
295                markers[0].1.span,
296                "InvalidCombinationOfUnion: UNION and UNION ALL cannot be mixed",
297            )]);
298        }
299
300        let mut starts = vec![0];
301        starts.extend(markers.iter().map(|(index, _)| index + 1));
302        let mut ends: Vec<usize> = markers.iter().map(|(index, _)| *index).collect();
303        ends.push(ast.clauses.len());
304        let mut inputs = Vec::with_capacity(starts.len());
305        let mut errors = Vec::new();
306        for (start, end) in starts.into_iter().zip(ends) {
307            if start == end {
308                errors.push(BindError::new(
309                    BindErrorKind::InvalidArgument,
310                    ast.span,
311                    "UNION requires a query on both sides",
312                ));
313                continue;
314            }
315            let branch = AstQuery {
316                dialect: ast.dialect,
317                clauses: ast.clauses[start..end].to_vec(),
318                span: ast.span,
319            };
320            match self.bind_staged(&branch) {
321                Ok(plan) => inputs.push(plan),
322                Err(mut branch_errors) => errors.append(&mut branch_errors),
323            }
324        }
325        if !errors.is_empty() {
326            return Err(errors);
327        }
328
329        let expected = union_output_names(&inputs[0]);
330        if expected.is_none()
331            || inputs
332                .iter()
333                .skip(1)
334                .any(|branch| union_output_names(branch) != expected)
335        {
336            return Err(vec![BindError::new(
337                BindErrorKind::InvalidArgument,
338                ast.span,
339                "DifferentColumnsInUnion: all UNION branches must return the same columns",
340            )]);
341        }
342
343        let mut builder = GraphPlan::builder(dialect).ontology_mode(self.mode);
344        if let Some(version) = ontology_version {
345            builder = builder.ontology_version(version);
346        }
347        Ok(builder.push_op(GraphOp::Union { all, inputs }).build())
348    }
349
350    // -----------------------------------------------------------------------
351    // Clause lowering
352    // -----------------------------------------------------------------------
353
354    fn lower_clause(&self, clause: &AstClause, s: &mut BinderState) {
355        match clause {
356            AstClause::Match(m) => self.lower_match(m, false, s),
357            AstClause::OptionalMatch(m) => self.lower_match(m, true, s),
358            AstClause::Where(w) => self.lower_where(w, s),
359            AstClause::With(w) => self.lower_with(w, s),
360            AstClause::Return(r) => self.lower_return(r, s),
361            AstClause::Unwind(u) => self.lower_unwind(u, s),
362            AstClause::Create(c) => self.lower_create(c, s),
363            AstClause::Merge(m) => self.lower_merge(m, s),
364            AstClause::Union(u) => {
365                s.builder.push_op_mut(GraphOp::Union {
366                    all: u.all,
367                    inputs: vec![],
368                });
369            }
370            AstClause::Delete(d) => self.lower_delete(d, s),
371            AstClause::Set(st) => self.lower_set(st, s),
372            AstClause::Remove(r) => self.lower_remove(r, s),
373            AstClause::Call(c) => self.lower_call(c, s),
374            // `AstClause` is `#[non_exhaustive]`, so a catch-all is required.
375            // Keep it an explicit *error* (never a silent no-op) so any clause
376            // added upstream surfaces loudly until it is wired in here.
377            _ => s.errors.push(BindError::new(
378                BindErrorKind::UnsupportedClause,
379                Span::default(),
380                "clause is not yet implemented",
381            )),
382        }
383    }
384
385    fn lower_match(&self, m: &graphforge_ast::MatchClause, optional: bool, s: &mut BinderState) {
386        if optional {
387            // Collect ops into a sub-builder, then wrap in Optional.
388            let mut sub_state = BinderState {
389                vars: s.vars.clone(),
390                path_vars: s.path_vars.clone(),
391                node_vars: s.node_vars.clone(),
392                edge_vars: s.edge_vars.clone(),
393                edge_rel_names: s.edge_rel_names.clone(),
394                scalar_list_edges: s.scalar_list_edges.clone(),
395                var_kinds: s.var_kinds.clone(),
396                next_var: s.next_var,
397                builder: GraphPlan::builder("openCypher").ontology_mode(self.mode),
398                errors: Vec::new(),
399                warnings: Vec::new(),
400                captured_pattern_comprehensions: None,
401                existential_depth: s.existential_depth,
402                standalone_call: false,
403            };
404            for pat in &m.patterns {
405                self.lower_path_pattern(pat, &mut sub_state);
406            }
407            if let Some(w) = &m.where_clause {
408                self.lower_where(w, &mut sub_state);
409            }
410            // Propagate state back up
411            s.next_var = sub_state.next_var;
412            // Merge any newly introduced vars (do not overwrite existing)
413            for (name, id) in sub_state.vars {
414                s.vars.entry(name).or_insert(id);
415            }
416            for (name, binding) in sub_state.path_vars {
417                s.path_vars.entry(name).or_insert(binding);
418            }
419            for (v, label) in sub_state.node_vars {
420                s.node_vars.entry(v).or_insert(label);
421            }
422            for (v, rel_name) in sub_state.edge_rel_names {
423                s.edge_rel_names.entry(v).or_insert(rel_name);
424            }
425            for (v, kind) in sub_state.var_kinds {
426                s.var_kinds.entry(v).or_insert(kind);
427            }
428            // Deliberately do NOT propagate `edge_vars` out of an OPTIONAL MATCH.
429            // After an unmatched optional row `r` is null, and Cypher requires
430            // `startNode(r)`/`endNode(r)` to be null — but the rewrite resolves
431            // to the endpoint node var, which on such a row is the *outer*,
432            // non-null var (e.g. `a` in `MATCH (a) OPTIONAL MATCH (a)-[r]->(b)`).
433            // Without an edge-uuid null gate on endpoint materialization that
434            // would return a wrong non-null value, so optional edges stay
435            // unresolved here and `startNode`/`endNode` fall through to
436            // UnknownFunction. (Edges still resolve inside the optional's own
437            // WHERE, where matched rows have a non-null `r`.) Null-gated endpoint
438            // values are a node-value-completeness follow-up (#889).
439            s.errors.extend(sub_state.errors);
440            s.warnings.extend(sub_state.warnings);
441            let mut child = sub_state.builder.build();
442            let referenced_vars = (0..child.exprs.len())
443                .filter_map(|index| {
444                    let index = u32::try_from(index).ok()?;
445                    match child.exprs.get(ExprId(index)) {
446                        IrExpr::VarRef(var) => Some(*var),
447                        _ => None,
448                    }
449                })
450                .collect::<HashSet<_>>();
451            let bound_vars = child
452                .ops
453                .iter()
454                .flat_map(graph_op_bound_vars)
455                .collect::<HashSet<_>>();
456            let mut correlated_scans = referenced_vars
457                .difference(&bound_vars)
458                .filter(|var| s.node_vars.contains_key(var))
459                .copied()
460                .collect::<Vec<_>>();
461            correlated_scans.sort_by_key(|var| var.0);
462            for var in correlated_scans.into_iter().rev() {
463                child.ops.insert(0, GraphOp::NodeScan { var, ty: None });
464            }
465            let mut correlated_edges = referenced_vars
466                .difference(&bound_vars)
467                .filter(|var| s.edge_rel_names.contains_key(var))
468                .copied()
469                .collect::<Vec<_>>();
470            correlated_edges.sort_by_key(|var| var.0);
471            for var in correlated_edges.into_iter().rev() {
472                child.ops.insert(0, GraphOp::EdgeScan { var, ty: None });
473            }
474            s.builder.push_op_mut(GraphOp::Optional {
475                child: Box::new(child),
476            });
477        } else {
478            for pat in &m.patterns {
479                self.lower_path_pattern(pat, s);
480            }
481            if let Some(w) = &m.where_clause {
482                self.lower_where(w, s);
483            }
484        }
485    }
486
487    #[allow(clippy::too_many_lines)]
488    fn lower_path_pattern(&self, pat: &PathPattern, s: &mut BinderState) {
489        let mut prev_node_var: Option<VarId> = None;
490        let mut iter = pat.elements.iter().peekable();
491        // A node that follows a relationship IS that relationship's destination:
492        // reuse the `dst` var the Rel arm already computed (carried in
493        // `pending_dst`) so the trailing `NodeScan` binds to the SAME var as the
494        // `Expand`'s `dst`. Otherwise an anonymous destination would mint a fresh
495        // var here (`ensure_var(None)`), leaving the scan disconnected from the
496        // expansion — the lowerer would then drop or cross-join it (#718/#598).
497        let mut pending_dst: Option<VarId> = None;
498        let mut path_nodes: Vec<VarId> = Vec::new();
499        let mut path_segments: Vec<PathSegment> = Vec::new();
500        let mut path_edges: Vec<VarId> = Vec::new();
501
502        while let Some(elem) = iter.next() {
503            match elem {
504                PathElement::Node(node) => {
505                    let var = pending_dst.take().unwrap_or_else(|| {
506                        ensure_pattern_var(node.var.as_deref(), VarKind::Node, node.span, s)
507                    });
508                    let ty = node
509                        .labels
510                        .first()
511                        .map(|label| self.resolve_label(label, node.span, s));
512                    s.builder.push_op_mut(GraphOp::NodeScan { var, ty });
513                    s.node_vars.insert(var, node.labels.first().cloned());
514                    if node.labels.len() > 1 {
515                        let predicate =
516                            self.lower_node_label_predicate(var, &node.labels[1..], node.span, s);
517                        s.builder.push_op_mut(GraphOp::Filter { predicate });
518                    }
519                    // An inline property map `(a:Person {name:'x'})` is an
520                    // exact-equality constraint — lower it to a Filter over the
521                    // just-scanned rows (#748).
522                    self.lower_inline_property_filter(var, node.properties.as_ref(), node.span, s);
523                    prev_node_var = Some(var);
524                }
525                PathElement::Rel(rel) => {
526                    let edge_var =
527                        ensure_pattern_var(rel.var.as_deref(), VarKind::Relationship, rel.span, s);
528
529                    // Peek ahead for the destination node. When it exists, hand
530                    // its var to the next loop iteration via `pending_dst` so the
531                    // trailing NodeScan binds to this same dst (see top of fn).
532                    let dst_var = if let Some(PathElement::Node(dst)) = iter.peek() {
533                        let v = ensure_pattern_var(dst.var.as_deref(), VarKind::Node, dst.span, s);
534                        pending_dst = Some(v);
535                        v
536                    } else {
537                        alloc_anon_var(s)
538                    };
539
540                    let src_var = prev_node_var.unwrap_or_else(|| alloc_anon_var(s));
541                    let dir = lower_direction(rel.direction);
542                    let rel_name = (rel.types.len() == 1)
543                        .then(|| rel.types.first().cloned())
544                        .flatten();
545                    let rel_ty = rel_name
546                        .as_ref()
547                        .map(|t| self.resolve_relation_type(t, rel.span, s));
548                    let is_var_hop = rel.min_hops.is_some() || rel.max_hops.is_some();
549
550                    // Both fixed (`-[:R]->`) and variable-length (`-[:R*1..3]->`)
551                    // hops lower to a single `Expand`: it is the only op that
552                    // carries the src/dst node vars, so the relational layer can
553                    // connect `(a)` and `(b)` with a join. A bare `TypedEdgeScan`
554                    // / `EdgeScan` would drop those vars and leave the pattern's
555                    // node scans disconnected (#718). A fixed hop is encoded as
556                    // `min_hops == 1 && max_hops == Some(1)`, which the lowerer
557                    // routes to a relational join; any other bound goes to the
558                    // BFS Extension.
559                    let (min_hops, max_hops) = if is_var_hop {
560                        let min = u16::try_from(rel.min_hops.unwrap_or(1)).unwrap_or(u16::MAX);
561                        let max = rel.max_hops.map(|h| u16::try_from(h).unwrap_or(u16::MAX));
562                        (min, max)
563                    } else {
564                        (1, Some(1))
565                    };
566                    let is_scalar_hop = min_hops == 1 && max_hops == Some(1);
567                    if is_var_hop && is_scalar_hop {
568                        s.scalar_list_edges.insert(edge_var);
569                    }
570                    let bound_rel_type_conflict =
571                        bound_rel_type_conflict(edge_var, rel_name.as_deref(), is_scalar_hop, s);
572                    s.builder.push_op_mut(GraphOp::Expand {
573                        src: src_var,
574                        edge: edge_var,
575                        dst: dst_var,
576                        rel_ty,
577                        dir,
578                        min_hops,
579                        max_hops,
580                    });
581                    if rel.types.len() > 1 {
582                        if is_scalar_hop {
583                            s.edge_rel_names.insert(edge_var, None);
584                        }
585                        let predicate = self
586                            .lower_relationship_type_predicate(edge_var, &rel.types, rel.span, s);
587                        s.builder.push_op_mut(GraphOp::Filter { predicate });
588                    }
589                    let prior_edges = path_edges.clone();
590                    if path_edges.contains(&edge_var) {
591                        s.errors.push(BindError::new(
592                            BindErrorKind::InvalidArgument,
593                            rel.span,
594                            "RelationshipUniquenessViolation: a relationship variable may not be reused within one pattern",
595                        ));
596                    }
597                    if !prior_edges.is_empty() {
598                        s.builder.push_op_mut(GraphOp::RelationshipUnique {
599                            edge: edge_var,
600                            prior_edges,
601                        });
602                    }
603                    path_edges.push(edge_var);
604                    if bound_rel_type_conflict {
605                        push_false_filter(s);
606                    }
607
608                    // Record the relationship's endpoints so `startNode(r)` /
609                    // `endNode(r)` can return the start/end node value (#753).
610                    // `src_var`/`dst_var` are the pattern's traversal-left/right
611                    // vars; the relationship's true start/end follow its
612                    // *direction*: an outgoing `(a)-[r]->(b)` starts at `a`, an
613                    // incoming `(a)<-[r]-(b)` starts at `b`. An undirected edge's
614                    // orientation is per matched row, so a static left/right
615                    // rewrite could pick the wrong endpoint — skip it (startNode/
616                    // endNode then fall through to UnknownFunction). Only scalar
617                    // single hops qualify: a true variable-length edge var binds
618                    // to a *list*, not one relationship.
619                    if is_scalar_hop {
620                        s.edge_rel_names.insert(edge_var, rel_name.clone());
621                        let endpoints = match dir {
622                            Direction::Out => Some((src_var, dst_var)),
623                            Direction::In => Some((dst_var, src_var)),
624                            Direction::Undirected => None,
625                        };
626                        if let Some(endpoints) = endpoints {
627                            s.edge_vars.insert(edge_var, endpoints);
628                        }
629                    }
630
631                    if path_nodes.is_empty() {
632                        path_nodes.push(src_var);
633                    }
634                    path_nodes.push(dst_var);
635                    path_segments.push(PathSegment {
636                        edge: edge_var,
637                        // Mirror the lowerer's routing, not the syntax: an
638                        // explicit `*1..1` goes to the relational join like a
639                        // fixed hop, so its edge var binds to scalar edge
640                        // columns — only true BFS bounds get the list column.
641                        var_len: !(min_hops == 1 && max_hops == Some(1)),
642                        rel_name: rel_name.clone(),
643                    });
644
645                    // An inline relationship-property map `-[r:KNOWS {since:2020}]->`
646                    // is an exact-equality constraint on the edge — lower it to a
647                    // Filter over the just-expanded rows, mirroring the node arm
648                    // (#748/#750). The read side already materialises
649                    // `var_<edge>.<prop>` for a scalar hop (`join_edge_properties`,
650                    // #784). A true variable-length edge var binds to a *list*
651                    // column, not scalar props, so an inline filter there has
652                    // nothing to resolve against (that's #755).
653                    if is_scalar_hop {
654                        self.lower_inline_property_filter(
655                            edge_var,
656                            rel.properties.as_ref(),
657                            rel.span,
658                            s,
659                        );
660                    } else {
661                        self.lower_varlen_inline_property_filter(
662                            edge_var,
663                            rel_name.as_deref(),
664                            rel.properties.as_ref(),
665                            rel.span,
666                            s,
667                        );
668                    }
669
670                    prev_node_var = Some(dst_var);
671                }
672            }
673        }
674
675        if let Some(name) = &pat.var {
676            if path_nodes.is_empty()
677                && let Some(node) = prev_node_var
678            {
679                path_nodes.push(node);
680            }
681            Self::bind_path_var(name, pat.span, path_nodes, path_segments, s);
682        }
683    }
684
685    /// Register a named path variable (`MATCH p = (a)-[*]->(b)`, #754).
686    ///
687    /// The binding retains every node and segment in traversal order so path
688    /// functions can compose fixed and variable-length segments later.
689    fn bind_path_var(
690        name: &str,
691        span: Span,
692        nodes: Vec<VarId>,
693        segments: Vec<PathSegment>,
694        s: &mut BinderState,
695    ) {
696        if s.vars.contains_key(name) || s.path_vars.contains_key(name) {
697            s.errors.push(BindError::new(
698                BindErrorKind::DuplicateVariable,
699                span,
700                format!("path variable `{name}` conflicts with an existing variable"),
701            ));
702            return;
703        }
704        s.path_vars
705            .insert(name.to_owned(), PathBinding { nodes, segments });
706    }
707
708    /// Lower an inline node-property map (`(a {name:'x', age:30})`) into a
709    /// [`GraphOp::Filter`] of AND-ed equality predicates over `var` (#748).
710    ///
711    /// openCypher treats inline node properties as exact-equality conjunction,
712    /// so `{k1:v1, k2:v2}` becomes `var.k1 = v1 AND var.k2 = v2`. A no-property
713    /// node (or an empty `{}`) adds no filter.
714    fn lower_inline_property_filter(
715        &self,
716        var: VarId,
717        properties: Option<&Expr>,
718        span: Span,
719        s: &mut BinderState,
720    ) {
721        let Some(Expr::Map(map)) = properties else {
722            return;
723        };
724        // `HashMap` iteration order is non-deterministic; sort by key so a
725        // multi-property filter produces a stable predicate order (reproducible
726        // plans).
727        let mut keys: Vec<&String> = map.entries.keys().collect();
728        keys.sort();
729        let mut combined: Option<ExprId> = None;
730        for key in keys {
731            let value = self.lower_expr(&map.entries[key], span, s);
732            let owner = property_owner_for_var(var, s);
733            let prop_span = map.key_spans.get(key).copied().unwrap_or(span);
734            let prop = self.resolve_property(key, prop_span, owner, s);
735            let base = s.builder.push_expr(IrExpr::VarRef(var));
736            let access = s.builder.push_expr(IrExpr::PropertyAccess { base, prop });
737            let eq = s.builder.push_expr(IrExpr::BinaryOp {
738                op: BinaryOpKind::Eq,
739                left: access,
740                right: value,
741            });
742            combined = Some(match combined {
743                None => eq,
744                Some(acc) => s.builder.push_expr(IrExpr::BinaryOp {
745                    op: BinaryOpKind::And,
746                    left: acc,
747                    right: eq,
748                }),
749            });
750        }
751        if let Some(predicate) = combined {
752            s.builder.push_op_mut(GraphOp::Filter { predicate });
753        }
754    }
755
756    fn lower_varlen_inline_property_filter(
757        &self,
758        edge_var: VarId,
759        rel_name: Option<&str>,
760        properties: Option<&Expr>,
761        span: Span,
762        s: &mut BinderState,
763    ) {
764        let Some(Expr::Map(map)) = properties else {
765            return;
766        };
767        let mut keys: Vec<&String> = map.entries.keys().collect();
768        keys.sort();
769        let mut combined = None;
770        for key in keys {
771            let value = self.lower_expr(&map.entries[key], span, s);
772            let owner = BoundPropertyOwner::Relationship(rel_name.map(str::to_owned));
773            let prop_span = map.key_spans.get(key).copied().unwrap_or(span);
774            let prop = self.resolve_property(key, prop_span, owner, s);
775            let loop_var = alloc_anon_var(s);
776            let element = s.builder.push_expr(IrExpr::VarRef(loop_var));
777            let access = s.builder.push_expr(IrExpr::PropertyAccess {
778                base: element,
779                prop,
780            });
781            let predicate = s.builder.push_expr(IrExpr::BinaryOp {
782                op: BinaryOpKind::Eq,
783                left: access,
784                right: value,
785            });
786            let list = s.builder.push_expr(IrExpr::VarRef(edge_var));
787            let all = s.builder.push_expr(IrExpr::Quantifier {
788                kind: graphforge_ast::QuantifierKind::All,
789                loop_var,
790                list,
791                predicate,
792            });
793            combined = Some(match combined {
794                None => all,
795                Some(acc) => s.builder.push_expr(IrExpr::BinaryOp {
796                    op: BinaryOpKind::And,
797                    left: acc,
798                    right: all,
799                }),
800            });
801        }
802        if let Some(predicate) = combined {
803            s.builder.push_op_mut(GraphOp::Filter { predicate });
804        }
805    }
806
807    /// Lower a `CREATE` clause into a single [`GraphOp::Create`].
808    ///
809    /// Walks each path pattern's node/relationship elements (mirroring
810    /// [`lower_path_pattern`](Self::lower_path_pattern)) and accumulates the
811    /// node and edge specs.  Variables are shared across the clause's patterns
812    /// via the binder scope, so an edge in one pattern may reference a node
813    /// bound in another.  Property maps are lowered into the [`ExprArena`] and
814    /// referenced by [`ExprId`]; the relational lowering layer resolves them to
815    /// literals at write time.
816    fn lower_create(&self, c: &CreateClause, s: &mut BinderState) {
817        let pattern = self.bind_create_patterns(&c.patterns, false, s);
818        s.builder.push_op_mut(GraphOp::Create { pattern });
819    }
820
821    fn lower_merge(&self, m: &graphforge_ast::MergeClause, s: &mut BinderState) {
822        let pattern = self.bind_create_patterns(std::slice::from_ref(&m.pattern), true, s);
823        let on_create = self.lower_merge_actions(&m.on_create, s);
824        let on_match = self.lower_merge_actions(&m.on_match, s);
825        s.builder.push_op_mut(GraphOp::Merge {
826            pattern,
827            on_create,
828            on_match,
829        });
830    }
831
832    #[allow(
833        clippy::too_many_lines,
834        reason = "one stateful walk keeps node, relationship, and named-path bindings aligned"
835    )]
836    fn bind_create_patterns(
837        &self,
838        patterns: &[graphforge_ast::PathPattern],
839        allow_undirected_relationship: bool,
840        s: &mut BinderState,
841    ) -> CreatePattern {
842        let mut pattern = CreatePattern::default();
843        let mut created_property_bindings: Vec<ReturnItem> = Vec::new();
844
845        // Variables bound BEFORE this CREATE (by a preceding MATCH/WITH/…). A
846        // node spec for such a var is a *reference* (resolve the matched node per
847        // row), not a mint (#703). Snapshot before `ensure_var` introduces any
848        // of this clause's new vars. `ensure_var_name` reuses an existing VarId
849        // for a re-named var, so membership here is exactly "came from earlier".
850        let bound_before: std::collections::HashSet<VarId> = s.vars.values().copied().collect();
851
852        for pat in patterns {
853            let mut path_nodes = Vec::new();
854            let mut path_segments = Vec::new();
855            // A standalone single-node pattern whose variable is already bound
856            // (`MATCH (a) CREATE (a)`) re-declares it — invalid even without a
857            // new shape (`VariableAlreadyBound`, #956). A bound var used as an
858            // edge endpoint (a multi-element pattern) is a valid reference.
859            if let [PathElement::Node(node)] = pat.elements.as_slice()
860                && let Some(name) = node.var.as_deref()
861                && s.vars.get(name).is_some_and(|v| bound_before.contains(v))
862            {
863                s.errors.push(BindError::new(
864                    BindErrorKind::VariableAlreadyBound,
865                    node.span,
866                    format!("variable `{name}` is already bound and cannot be re-created"),
867                ));
868            }
869            let mut prev_node_var: Option<VarId> = None;
870            // A node following a relationship is that relationship's destination:
871            // reuse the `dst` var the Rel arm computed (mirrors lower_path_pattern,
872            // #895) so an anonymous dst node's create-spec matches the edge spec's
873            // `dst`. Otherwise `ensure_var(None)` mints a fresh var and the edge
874            // references an unbound dst (`CREATE (:A)-[:R]->(:B)`).
875            let mut pending_dst: Option<VarId> = None;
876            let mut iter = pat.elements.iter().peekable();
877
878            while let Some(elem) = iter.next() {
879                match elem {
880                    PathElement::Node(node) => {
881                        let var = pending_dst
882                            .take()
883                            .unwrap_or_else(|| ensure_var(node.var.as_ref(), s));
884                        if let Some(name) = node.var.as_deref() {
885                            bind_var_kind(var, VarKind::Node, name, node.span, s);
886                        }
887                        // Re-declaring an already-bound node (from an earlier
888                        // clause or earlier in this CREATE) with NEW labels or
889                        // properties is invalid — `MATCH (a) CREATE (a {…})`,
890                        // `CREATE (n:Foo) CREATE (n:Bar)…` (`VariableAlreadyBound`,
891                        // #956). A bare reference (endpoint, no shape) is fine.
892                        let already_specced = pattern.nodes.iter().any(|n| n.var == var);
893                        let has_new_shape = !node.labels.is_empty() || node.properties.is_some();
894                        if (bound_before.contains(&var) || already_specced) && has_new_shape {
895                            s.errors.push(BindError::new(
896                                BindErrorKind::VariableAlreadyBound,
897                                node.span,
898                                format!(
899                                    "variable `{}` is already bound and cannot be re-declared \
900                                     with new labels or properties",
901                                    node.var.as_deref().unwrap_or("?")
902                                ),
903                            ));
904                        }
905                        // Only emit a create-spec the first time a variable
906                        // appears in this CREATE; a repeated var (e.g.
907                        // `CREATE (a), (a)-[:R]->(b)`) references the same node
908                        // rather than creating a duplicate.
909                        if !pattern.nodes.iter().any(|n| n.var == var) {
910                            let labels = node
911                                .labels
912                                .iter()
913                                .map(|label| self.resolve_label(label, node.span, s))
914                                .collect();
915                            let resolved_properties = node.properties.as_ref().map(|expr| {
916                                rewrite_projection_alias_refs(
917                                    expr.clone(),
918                                    &created_property_bindings,
919                                )
920                            });
921                            let properties = resolved_properties
922                                .as_ref()
923                                .map(|expr| self.lower_expr(expr, node.span, s));
924                            let is_reference = bound_before.contains(&var);
925                            pattern.nodes.push(CreateNodeSpec {
926                                var,
927                                labels,
928                                properties,
929                                is_reference,
930                            });
931                            // A freshly-created node var is node-valued, so a
932                            // trailing `RETURN n` / `n.prop` treats it as a node
933                            // (write-result RETURN, #814). A reference var is
934                            // already registered by the preceding MATCH — don't
935                            // clobber its label.
936                            if !is_reference {
937                                s.node_vars
938                                    .entry(var)
939                                    .or_insert_with(|| node.labels.first().cloned());
940                                if let Some(name) = node.var.as_deref() {
941                                    created_property_bindings.push(ReturnItem {
942                                        expr: resolved_properties.unwrap_or_else(|| {
943                                            Expr::Map(MapLiteral {
944                                                entries: HashMap::new(),
945                                                key_spans: HashMap::new(),
946                                                span: node.span,
947                                            })
948                                        }),
949                                        alias: Some(name.to_owned()),
950                                        display: Some(name.to_owned()),
951                                        span: node.span,
952                                    });
953                                }
954                            }
955                        }
956                        prev_node_var = Some(var);
957                        if path_nodes.is_empty() {
958                            path_nodes.push(var);
959                        }
960                    }
961                    PathElement::Rel(rel) => {
962                        validate_created_rel(rel, &bound_before, allow_undirected_relationship, s);
963                        let edge_var = ensure_var(rel.var.as_ref(), s);
964                        if let Some(name) = rel.var.as_deref() {
965                            bind_var_kind(edge_var, VarKind::Relationship, name, rel.span, s);
966                        }
967                        let dst_var = if let Some(PathElement::Node(dst)) = iter.peek() {
968                            let v = ensure_var(dst.var.as_ref(), s);
969                            pending_dst = Some(v);
970                            v
971                        } else {
972                            alloc_anon_var(s)
973                        };
974                        let src_var = prev_node_var.unwrap_or_else(|| alloc_anon_var(s));
975                        let rel_type = rel
976                            .types
977                            .first()
978                            .map(|t| self.resolve_relation_type(t, rel.span, s));
979                        let properties = rel
980                            .properties
981                            .as_ref()
982                            .map(|expr| self.lower_expr(expr, rel.span, s));
983                        pattern.edges.push(CreateEdgeSpec {
984                            var: edge_var,
985                            src: src_var,
986                            dst: dst_var,
987                            rel_type,
988                            direction: lower_direction(rel.direction),
989                            properties,
990                        });
991                        s.edge_rel_names
992                            .insert(edge_var, rel.types.first().cloned());
993                        let endpoints = match lower_direction(rel.direction) {
994                            Direction::In => (dst_var, src_var),
995                            Direction::Out | Direction::Undirected => (src_var, dst_var),
996                        };
997                        s.edge_vars.insert(edge_var, endpoints);
998                        path_nodes.push(dst_var);
999                        path_segments.push(PathSegment {
1000                            edge: edge_var,
1001                            var_len: false,
1002                            rel_name: rel.types.first().cloned(),
1003                        });
1004                        prev_node_var = Some(dst_var);
1005                    }
1006                }
1007            }
1008            if let Some(name) = &pat.var {
1009                Self::bind_path_var(name, pat.span, path_nodes, path_segments, s);
1010            }
1011        }
1012
1013        pattern
1014    }
1015
1016    fn lower_merge_actions(&self, actions: &[SetItem], s: &mut BinderState) -> Vec<MergeSetItem> {
1017        let mut lowered = Vec::with_capacity(actions.len());
1018        for action in actions {
1019            match action {
1020                SetItem::Property {
1021                    target,
1022                    value,
1023                    span,
1024                } => {
1025                    if let Some((var, prop, prop_name)) = self.resolve_write_target(target, s) {
1026                        lowered.push(MergeSetItem::Property(SetPropItem {
1027                            target: var,
1028                            prop,
1029                            prop_name,
1030                            value: self.lower_expr(value, *span, s),
1031                        }));
1032                    }
1033                }
1034                SetItem::PropertyMerge { var, map, span }
1035                | SetItem::PropertyReplace { var, map, span } => {
1036                    if let Some(&target) = s.vars.get(var) {
1037                        lowered.push(MergeSetItem::Map(SetMapItem {
1038                            target,
1039                            map: self.lower_set_map_source(map, *span, s),
1040                            replace: matches!(action, SetItem::PropertyReplace { .. }),
1041                        }));
1042                    } else {
1043                        s.errors.push(BindError::new(
1044                            BindErrorKind::UndeclaredVariable,
1045                            *span,
1046                            format!("undefined variable `{var}`"),
1047                        ));
1048                    }
1049                }
1050                SetItem::Label { var, labels, span } => {
1051                    if let Some(&target) = s.vars.get(var) {
1052                        lowered.push(MergeSetItem::AddLabels {
1053                            target,
1054                            labels: labels
1055                                .iter()
1056                                .map(|label| self.resolve_label(label, *span, s))
1057                                .collect(),
1058                        });
1059                    } else {
1060                        s.errors.push(BindError::new(
1061                            BindErrorKind::UndeclaredVariable,
1062                            *span,
1063                            format!("undefined variable `{var}`"),
1064                        ));
1065                    }
1066                }
1067                _ => unreachable!("future SET forms are rejected by the parser contract"),
1068            }
1069        }
1070        lowered
1071    }
1072
1073    /// Lower a `DELETE` / `DETACH DELETE` clause into a [`GraphOp::Delete`].
1074    ///
1075    /// Direct entity variables retain their identity-column fast path. Named
1076    /// paths expand to their constituent variables, while list/map access and
1077    /// other runtime value expressions are evaluated by the statement driver.
1078    fn lower_delete(&self, d: &graphforge_ast::DeleteClause, s: &mut BinderState) {
1079        let mut vars: Vec<VarId> = Vec::with_capacity(d.exprs.len());
1080        let mut exprs = Vec::new();
1081        for expr in &d.exprs {
1082            match expr {
1083                Expr::Var(VarRef { name, span }) => match s.vars.get(name) {
1084                    Some(&var_id) => vars.push(var_id),
1085                    None => {
1086                        if let Some(path) = s.path_vars.get(name) {
1087                            vars.extend(path.nodes.iter().copied());
1088                            vars.extend(path.segments.iter().map(|segment| segment.edge));
1089                        } else {
1090                            s.errors.push(BindError::new(
1091                                BindErrorKind::UndeclaredVariable,
1092                                *span,
1093                                format!("DELETE target `{name}` is not a bound variable"),
1094                            ));
1095                        }
1096                    }
1097                },
1098                Expr::Property(_) => exprs.push(self.lower_expr(expr, expr.span(), s)),
1099                Expr::FunctionCall(call) if call.name.as_slice() == ["_subscript"] => {
1100                    exprs.push(self.lower_expr(expr, expr.span(), s));
1101                }
1102                Expr::Parenthesized { inner, .. } => {
1103                    if matches!(inner.as_ref(), Expr::Property(_))
1104                        || matches!(inner.as_ref(), Expr::FunctionCall(call) if call.name.as_slice() == ["_subscript"])
1105                    {
1106                        exprs.push(self.lower_expr(inner, inner.span(), s));
1107                    } else {
1108                        if self.typed_uuid_param_in(inner).is_some() {
1109                            self.lower_expr(inner, inner.span(), s);
1110                        }
1111                        s.errors.push(BindError::new(
1112                            BindErrorKind::InvalidDeleteTarget,
1113                            expr.span(),
1114                            "DELETE target must be a node, relationship, or path value",
1115                        ));
1116                    }
1117                }
1118                other => {
1119                    if self.typed_uuid_param_in(other).is_some() {
1120                        self.lower_expr(other, other.span(), s);
1121                    }
1122                    s.errors.push(BindError::new(
1123                        BindErrorKind::InvalidDeleteTarget,
1124                        other.span(),
1125                        "DELETE target must be a node, relationship, or path value",
1126                    ));
1127                }
1128            }
1129        }
1130        vars.sort_unstable_by_key(|var| var.0);
1131        vars.dedup();
1132        s.builder.push_op_mut(GraphOp::Delete {
1133            vars,
1134            exprs,
1135            detach: d.detach,
1136        });
1137    }
1138
1139    /// Lower a `SET` clause into a [`GraphOp::Set`] (#791).
1140    ///
1141    /// Property assignments and bulk map assignments carry full runtime
1142    /// expressions through to lowering; label additions carry resolved type ids.
1143    fn lower_set(&self, st: &SetClause, s: &mut BinderState) {
1144        let mut items: Vec<SetPropItem> = Vec::with_capacity(st.items.len());
1145        let mut map_items = Vec::new();
1146        let mut label_items = Vec::new();
1147        for item in &st.items {
1148            match item {
1149                SetItem::Property {
1150                    target,
1151                    value,
1152                    span,
1153                } => {
1154                    let Some((var, prop, prop_name)) = self.resolve_write_target(target, s) else {
1155                        continue;
1156                    };
1157                    let value = self.lower_expr(value, *span, s);
1158                    items.push(SetPropItem {
1159                        target: var,
1160                        prop,
1161                        prop_name,
1162                        value,
1163                    });
1164                }
1165                SetItem::PropertyMerge { var, map, span }
1166                | SetItem::PropertyReplace { var, map, span } => {
1167                    let Some(&target) = s.vars.get(var) else {
1168                        s.errors.push(BindError::new(
1169                            BindErrorKind::UndeclaredVariable,
1170                            *span,
1171                            format!("undefined variable `{var}`"),
1172                        ));
1173                        continue;
1174                    };
1175                    map_items.push(SetMapItem {
1176                        target,
1177                        map: self.lower_set_map_source(map, *span, s),
1178                        replace: matches!(item, SetItem::PropertyReplace { .. }),
1179                    });
1180                }
1181                SetItem::Label { var, labels, span } => match s.vars.get(var).copied() {
1182                    Some(target) => label_items.push(crate::LabelItem {
1183                        target,
1184                        labels: labels
1185                            .iter()
1186                            .map(|label| self.resolve_label(label, *span, s))
1187                            .collect(),
1188                    }),
1189                    None => s.errors.push(BindError::new(
1190                        BindErrorKind::UndeclaredVariable,
1191                        *span,
1192                        format!("undefined variable `{var}`"),
1193                    )),
1194                },
1195                _ => unreachable!("future SET forms rejected above"),
1196            }
1197        }
1198        s.builder.push_op_mut(GraphOp::Set {
1199            items,
1200            map_items,
1201            label_items,
1202        });
1203    }
1204
1205    fn lower_set_map_source(&self, map: &Expr, span: Span, s: &mut BinderState) -> ExprId {
1206        if let Expr::Var(VarRef { name, .. }) = map
1207            && let Some(&var) = s.vars.get(name)
1208            && (s.node_vars.contains_key(&var) || s.edge_rel_names.contains_key(&var))
1209        {
1210            let value = s.builder.push_expr(IrExpr::VarRef(var));
1211            return s.builder.push_expr(IrExpr::FunctionCall {
1212                name: "properties".into(),
1213                args: vec![value],
1214            });
1215        }
1216        self.lower_expr(map, span, s)
1217    }
1218
1219    /// Lower a `REMOVE` clause into a [`GraphOp::Remove`] (#791).
1220    ///
1221    /// Property removals carry resolved property ids; label removals carry
1222    /// resolved type ids for statement-driver execution.
1223    fn lower_remove(&self, r: &RemoveClause, s: &mut BinderState) {
1224        let mut items: Vec<RemovePropItem> = Vec::with_capacity(r.items.len());
1225        let mut label_items = Vec::new();
1226        for item in &r.items {
1227            match item {
1228                RemoveItem::Property(target, _span) => {
1229                    let Some((var, prop, prop_name)) = self.resolve_write_target(target, s) else {
1230                        continue;
1231                    };
1232                    items.push(RemovePropItem {
1233                        target: var,
1234                        prop,
1235                        prop_name,
1236                    });
1237                }
1238                RemoveItem::Label { var, labels, span } => match s.vars.get(var).copied() {
1239                    Some(target) => label_items.push(crate::LabelItem {
1240                        target,
1241                        labels: labels
1242                            .iter()
1243                            .map(|label| self.resolve_label(label, *span, s))
1244                            .collect(),
1245                    }),
1246                    None => s.errors.push(BindError::new(
1247                        BindErrorKind::UndeclaredVariable,
1248                        *span,
1249                        format!("undefined variable `{var}`"),
1250                    )),
1251                },
1252                _ => s.errors.push(BindError::new(
1253                    BindErrorKind::UnsupportedClause,
1254                    r.span,
1255                    "this form of REMOVE is not yet supported",
1256                )),
1257            }
1258        }
1259        s.builder
1260            .push_op_mut(GraphOp::Remove { items, label_items });
1261    }
1262
1263    /// Resolve a `SET`/`REMOVE` property target (`n.prop`) into its bound
1264    /// variable, [`PropId`], and property name.
1265    ///
1266    /// The target's object must be a bound variable (`Expr::Var`); a property
1267    /// access on anything else — a literal, an expression, a nested property —
1268    /// is an [`InvalidDeleteTarget`](BindErrorKind::InvalidDeleteTarget) (the
1269    /// kind means "write target must be a bound variable"). Returns `None` (and
1270    /// records an error) when the target is malformed or the variable unbound,
1271    /// so the caller skips that item.
1272    fn resolve_write_target(
1273        &self,
1274        target: &PropertyAccess,
1275        s: &mut BinderState,
1276    ) -> Option<(VarId, PropId, String)> {
1277        if matches!(target.key.as_str(), "node_uuid" | "edge_uuid") {
1278            s.errors.push(BindError::new(
1279                BindErrorKind::InvalidArgument,
1280                target.span,
1281                format!("structural identity field `{}` is read-only", target.key),
1282            ));
1283            return None;
1284        }
1285        let Expr::Var(VarRef { name, span }) = target.object.as_ref() else {
1286            s.errors.push(BindError::new(
1287                BindErrorKind::InvalidDeleteTarget,
1288                target.span,
1289                "write target must be a bound variable's property \
1290                 (e.g. `SET n.prop = …`)",
1291            ));
1292            return None;
1293        };
1294        let Some(&var) = s.vars.get(name) else {
1295            s.errors.push(BindError::new(
1296                BindErrorKind::UndeclaredVariable,
1297                *span,
1298                format!("write target `{name}` is not a bound variable"),
1299            ));
1300            return None;
1301        };
1302        let owner = property_owner_for_var(var, s);
1303        let prop = self.resolve_property(&target.key, target.span, owner, s);
1304        Some((var, prop, target.key.clone()))
1305    }
1306
1307    fn lower_where(&self, w: &WhereClause, s: &mut BinderState) {
1308        // An aggregate in WHERE is invalid — aggregation happens in WITH/RETURN,
1309        // and a predicate filtering on `count(...)` must use a WITH first
1310        // (openCypher `InvalidAggregation`, #956).
1311        if expr_contains_aggregate(&w.predicate) {
1312            s.errors.push(BindError::new(
1313                BindErrorKind::InvalidArgument,
1314                w.span,
1315                "an aggregate function may not be used in WHERE".to_string(),
1316            ));
1317        }
1318        self.lower_where_predicate(&w.predicate, w.span, s);
1319    }
1320
1321    fn lower_where_predicate(&self, expr: &Expr, parent_span: Span, s: &mut BinderState) {
1322        match expr {
1323            Expr::Parenthesized { inner, .. } => self.lower_where_predicate(inner, parent_span, s),
1324            Expr::PatternPredicate(pp) => self.lower_pattern_predicate(pp, false, s),
1325            Expr::ExistentialSubquery(es) => self.lower_existential_subquery(es, s),
1326            Expr::UnaryOp(graphforge_ast::UnaryOp {
1327                op: AstUnOp::Not,
1328                expr: inner,
1329                ..
1330            }) if matches_pattern_predicate(inner) => {
1331                let Expr::PatternPredicate(pp) = strip_parens(inner) else {
1332                    unreachable!("matches_pattern_predicate ensured the inner shape");
1333                };
1334                self.lower_pattern_predicate(pp, true, s);
1335            }
1336            Expr::BinaryOp(graphforge_ast::BinaryOp {
1337                op: AstBinOp::And,
1338                left,
1339                right,
1340                ..
1341            }) => {
1342                self.lower_where_predicate(left, parent_span, s);
1343                self.lower_where_predicate(right, parent_span, s);
1344            }
1345            Expr::BinaryOp(graphforge_ast::BinaryOp {
1346                op: AstBinOp::Or, ..
1347            }) => {
1348                let mut alternatives = Vec::new();
1349                if collect_pattern_disjunction(expr, &mut alternatives) {
1350                    self.lower_pattern_predicate_alternatives(&alternatives, false, s);
1351                } else if expr_contains_pattern_predicate(expr) {
1352                    let mut branches = Vec::new();
1353                    if collect_mixed_pattern_disjunction(expr, &mut branches) {
1354                        self.lower_mixed_pattern_predicate_alternatives(&branches, s);
1355                    } else {
1356                        s.errors.push(BindError::new(
1357                            BindErrorKind::InvalidArgument,
1358                            expr.span(),
1359                            "each OR branch containing a pattern predicate must contain exactly one pattern alternative",
1360                        ));
1361                    }
1362                } else {
1363                    Self::reject_bare_graph_value_predicate(expr, parent_span, s);
1364                    let pred = self.lower_expr(expr, parent_span, s);
1365                    s.builder.push_op_mut(GraphOp::Filter { predicate: pred });
1366                }
1367            }
1368            other if expr_contains_pattern_predicate(other) => {
1369                s.errors.push(BindError::new(
1370                    BindErrorKind::InvalidArgument,
1371                    other.span(),
1372                    "pattern predicates are currently supported only as single-relationship \
1373                     WHERE predicates, with optional NOT and AND",
1374                ));
1375            }
1376            other => {
1377                Self::reject_bare_graph_value_predicate(other, parent_span, s);
1378                let pred = self.lower_expr(other, parent_span, s);
1379                s.builder.push_op_mut(GraphOp::Filter { predicate: pred });
1380            }
1381        }
1382    }
1383
1384    fn lower_pattern_predicate(&self, pp: &PatternPredicate, negated: bool, s: &mut BinderState) {
1385        self.lower_pattern_predicate_alternatives(&[pp], negated, s);
1386    }
1387
1388    fn lower_existential_subquery(
1389        &self,
1390        es: &graphforge_ast::ExistentialSubquery,
1391        s: &mut BinderState,
1392    ) {
1393        let prior_error_count = s.errors.len();
1394        let outer_vars = s.vars.values().copied().collect::<HashSet<_>>();
1395
1396        let mut sub_state = BinderState {
1397            vars: s.vars.clone(),
1398            path_vars: s.path_vars.clone(),
1399            node_vars: s.node_vars.clone(),
1400            edge_vars: s.edge_vars.clone(),
1401            edge_rel_names: s.edge_rel_names.clone(),
1402            scalar_list_edges: s.scalar_list_edges.clone(),
1403            var_kinds: s.var_kinds.clone(),
1404            next_var: s.next_var,
1405            builder: GraphPlan::builder("openCypher").ontology_mode(self.mode),
1406            errors: Vec::new(),
1407            warnings: Vec::new(),
1408            captured_pattern_comprehensions: None,
1409            existential_depth: s.existential_depth + 1,
1410            standalone_call: false,
1411        };
1412
1413        match &es.body {
1414            ExistentialSubqueryBody::Simple { pattern, filter } => {
1415                self.lower_path_pattern(pattern, &mut sub_state);
1416                if let Some(filter) = filter {
1417                    if expr_contains_aggregate(filter) {
1418                        sub_state.errors.push(BindError::new(
1419                            BindErrorKind::InvalidArgument,
1420                            es.span,
1421                            "an aggregate function may not be used in a simple existential subquery",
1422                        ));
1423                    }
1424                    self.lower_where_predicate(filter, es.span, &mut sub_state);
1425                }
1426            }
1427            ExistentialSubqueryBody::Full(query) => {
1428                let last = query.clauses.len().saturating_sub(1);
1429                for (index, clause) in query.clauses.iter().enumerate() {
1430                    let allowed = matches!(
1431                        clause,
1432                        AstClause::Match(_)
1433                            | AstClause::OptionalMatch(_)
1434                            | AstClause::With(_)
1435                            | AstClause::Unwind(_)
1436                    ) || matches!(clause, AstClause::Return(_)) && index == last;
1437                    if !allowed {
1438                        sub_state.errors.push(BindError::new(
1439                            BindErrorKind::InvalidArgument,
1440                            clause.span(),
1441                            "a full existential subquery must contain only read clauses and end in RETURN",
1442                        ));
1443                        continue;
1444                    }
1445                    self.lower_clause(clause, &mut sub_state);
1446                }
1447                if !matches!(query.clauses.last(), Some(AstClause::Return(_))) {
1448                    sub_state.errors.push(BindError::new(
1449                        BindErrorKind::InvalidArgument,
1450                        es.span,
1451                        "a full existential subquery must end in RETURN",
1452                    ));
1453                }
1454            }
1455        }
1456
1457        let child = sub_state.builder.build();
1458        let references_outer = plan_references_any_var(&child, &outer_vars);
1459        s.next_var = s.next_var.max(sub_state.next_var);
1460        s.errors.extend(sub_state.errors);
1461        s.warnings.extend(sub_state.warnings);
1462        if !references_outer {
1463            s.errors.push(BindError::new(
1464                BindErrorKind::UndeclaredVariable,
1465                es.span,
1466                "existential subquery must reference at least one outer variable",
1467            ));
1468        }
1469        if s.errors.len() == prior_error_count {
1470            s.builder.push_op_mut(GraphOp::Exists {
1471                child: Box::new(child),
1472                negated: false,
1473            });
1474        }
1475    }
1476
1477    fn lower_pattern_predicate_alternatives(
1478        &self,
1479        alternatives: &[&PatternPredicate],
1480        negated: bool,
1481        s: &mut BinderState,
1482    ) {
1483        let prior_error_count = s.errors.len();
1484        let mut children = Vec::new();
1485        for pp in alternatives {
1486            if let Some(name) = pp.pattern.var.as_deref() {
1487                s.errors.push(BindError::new(
1488                    BindErrorKind::UndeclaredVariable,
1489                    pp.span,
1490                    format!("path variable `{name}` is not bound in this pattern predicate scope"),
1491                ));
1492                continue;
1493            }
1494            if !is_single_relationship_pattern(&pp.pattern) && s.existential_depth < 2 {
1495                s.errors.push(BindError::new(
1496                    BindErrorKind::InvalidArgument,
1497                    pp.span,
1498                    "multi-relationship pattern predicates are supported only in nested existential subqueries",
1499                ));
1500                continue;
1501            }
1502            if pattern_has_var_length_relationship_properties(&pp.pattern) {
1503                s.errors.push(BindError::new(
1504                    BindErrorKind::InvalidArgument,
1505                    pp.span,
1506                    "variable-length relationships in pattern predicates cannot have property maps",
1507                ));
1508                continue;
1509            }
1510            for pattern in relationship_type_alternatives(&pp.pattern) {
1511                if let Some(child) = self.bind_pattern_predicate_child(&pattern, pp.span, s) {
1512                    children.push(child);
1513                }
1514            }
1515        }
1516
1517        if children.is_empty() || s.errors.len() > prior_error_count {
1518            return;
1519        }
1520        let child = if children.len() == 1 {
1521            children.pop().expect("one child remains")
1522        } else {
1523            GraphPlan::builder("openCypher")
1524                .push_op(GraphOp::Union {
1525                    all: true,
1526                    inputs: children,
1527                })
1528                .build()
1529        };
1530        s.builder.push_op_mut(GraphOp::Exists {
1531            child: Box::new(child),
1532            negated,
1533        });
1534    }
1535
1536    fn lower_mixed_pattern_predicate_alternatives(
1537        &self,
1538        branches: &[MixedPatternBranch<'_>],
1539        s: &mut BinderState,
1540    ) {
1541        let prior_error_count = s.errors.len();
1542        let mut children = Vec::new();
1543        for branch in branches {
1544            for pattern in relationship_type_alternatives(&branch.pattern.pattern) {
1545                if let Some(child) = self.bind_pattern_predicate_child_with_filters(
1546                    &pattern,
1547                    branch.pattern.span,
1548                    &branch.scalar_filters,
1549                    s,
1550                ) {
1551                    children.push(child);
1552                }
1553            }
1554        }
1555        if children.is_empty() || s.errors.len() > prior_error_count {
1556            return;
1557        }
1558        let child = if children.len() == 1 {
1559            children.pop().expect("one child remains")
1560        } else {
1561            GraphPlan::builder("openCypher")
1562                .push_op(GraphOp::Union {
1563                    all: true,
1564                    inputs: children,
1565                })
1566                .build()
1567        };
1568        s.builder.push_op_mut(GraphOp::Exists {
1569            child: Box::new(child),
1570            negated: false,
1571        });
1572    }
1573
1574    fn bind_pattern_predicate_child(
1575        &self,
1576        pattern: &PathPattern,
1577        span: Span,
1578        s: &mut BinderState,
1579    ) -> Option<GraphPlan> {
1580        self.bind_pattern_predicate_child_with_filters(pattern, span, &[], s)
1581    }
1582
1583    fn bind_pattern_predicate_child_with_filters(
1584        &self,
1585        pattern: &PathPattern,
1586        span: Span,
1587        scalar_filters: &[&Expr],
1588        s: &mut BinderState,
1589    ) -> Option<GraphPlan> {
1590        let prior_error_count = s.errors.len();
1591        let mut sub_state = BinderState {
1592            vars: s.vars.clone(),
1593            path_vars: s.path_vars.clone(),
1594            node_vars: s.node_vars.clone(),
1595            edge_vars: s.edge_vars.clone(),
1596            edge_rel_names: s.edge_rel_names.clone(),
1597            scalar_list_edges: s.scalar_list_edges.clone(),
1598            var_kinds: s.var_kinds.clone(),
1599            next_var: s.next_var,
1600            builder: GraphPlan::builder("openCypher").ontology_mode(self.mode),
1601            errors: Vec::new(),
1602            warnings: Vec::new(),
1603            captured_pattern_comprehensions: None,
1604            existential_depth: s.existential_depth,
1605            standalone_call: false,
1606        };
1607        self.lower_path_pattern(pattern, &mut sub_state);
1608        for filter in scalar_filters {
1609            self.lower_where_predicate(filter, filter.span(), &mut sub_state);
1610        }
1611
1612        for name in sub_state.vars.keys() {
1613            if !s.vars.contains_key(name) {
1614                sub_state.errors.push(BindError::new(
1615                    BindErrorKind::UndeclaredVariable,
1616                    span,
1617                    format!("variable `{name}` is not bound in this pattern predicate scope"),
1618                ));
1619            }
1620        }
1621
1622        if !pattern_references_bound_var(pattern, s) {
1623            sub_state.errors.push(BindError::new(
1624                BindErrorKind::UndeclaredVariable,
1625                span,
1626                "pattern predicate must reference at least one bound variable",
1627            ));
1628        }
1629
1630        s.errors.extend(sub_state.errors);
1631        s.warnings.extend(sub_state.warnings);
1632        if s.errors.len() > prior_error_count {
1633            return None;
1634        }
1635
1636        Some(sub_state.builder.build())
1637    }
1638
1639    fn lower_pattern_comprehension(
1640        &self,
1641        pc: &graphforge_ast::PatternComprehension,
1642        s: &mut BinderState,
1643    ) -> ExprId {
1644        let prior_error_count = s.errors.len();
1645        let output = VarId(s.next_var);
1646        s.next_var += 1;
1647
1648        let mut pattern = pc.pattern.clone();
1649        pattern.var.clone_from(&pc.var);
1650        let alternatives = relationship_type_alternatives(&pattern);
1651        let mut children = Vec::with_capacity(alternatives.len());
1652        for pattern in alternatives {
1653            let mut sub_state = BinderState {
1654                vars: s.vars.clone(),
1655                path_vars: s.path_vars.clone(),
1656                node_vars: s.node_vars.clone(),
1657                edge_vars: s.edge_vars.clone(),
1658                edge_rel_names: s.edge_rel_names.clone(),
1659                scalar_list_edges: s.scalar_list_edges.clone(),
1660                var_kinds: s.var_kinds.clone(),
1661                next_var: s.next_var,
1662                builder: GraphPlan::builder("openCypher").ontology_mode(self.mode),
1663                errors: Vec::new(),
1664                warnings: Vec::new(),
1665                captured_pattern_comprehensions: None,
1666                existential_depth: s.existential_depth,
1667                standalone_call: false,
1668            };
1669            self.lower_path_pattern(&pattern, &mut sub_state);
1670
1671            if let Some(filter) = &pc.filter {
1672                if expr_contains_aggregate(filter) {
1673                    sub_state.errors.push(BindError::new(
1674                        BindErrorKind::InvalidArgument,
1675                        pc.span,
1676                        "an aggregate function may not be used in a pattern comprehension filter",
1677                    ));
1678                }
1679                self.lower_where_predicate(filter, pc.span, &mut sub_state);
1680            }
1681            if expr_contains_aggregate(&pc.projection) {
1682                sub_state.errors.push(BindError::new(
1683                    BindErrorKind::InvalidArgument,
1684                    pc.span,
1685                    "an aggregate function may not be used in a pattern comprehension projection",
1686                ));
1687            }
1688
1689            let projection = self
1690                .lower_projection_value_expr(&pc.projection, pc.span, &mut sub_state, true)
1691                .unwrap_or_else(|| self.lower_expr(&pc.projection, pc.span, &mut sub_state));
1692            sub_state.builder.push_op_mut(GraphOp::Project {
1693                items: vec![ProjectItem {
1694                    expr: projection,
1695                    alias: Some(PATTERN_COMPREHENSION_VALUE_ALIAS.into()),
1696                    out_var: None,
1697                }],
1698                distinct: false,
1699            });
1700
1701            s.next_var = s.next_var.max(sub_state.next_var);
1702            s.errors.extend(sub_state.errors);
1703            s.warnings.extend(sub_state.warnings);
1704            children.push(sub_state.builder.build());
1705        }
1706        if s.errors.len() > prior_error_count {
1707            return s.builder.push_expr(IrExpr::Literal(IrLiteral::Null));
1708        }
1709
1710        let mut outputs = Vec::with_capacity(children.len());
1711        for (index, child) in children.into_iter().enumerate() {
1712            let child_output = if index == 0 {
1713                output
1714            } else {
1715                let next = VarId(s.next_var);
1716                s.next_var += 1;
1717                next
1718            };
1719            let child = Box::new(child);
1720            if let Some(captured) = s.captured_pattern_comprehensions.as_mut() {
1721                captured.push((child, child_output));
1722            } else {
1723                s.builder.push_op_mut(GraphOp::PatternComprehension {
1724                    child,
1725                    output: child_output,
1726                });
1727            }
1728            outputs.push(s.builder.push_expr(IrExpr::VarRef(child_output)));
1729        }
1730        let mut outputs = outputs.into_iter();
1731        let Some(mut combined) = outputs.next() else {
1732            return s.builder.push_expr(IrExpr::ListLiteral(Vec::new()));
1733        };
1734        for next in outputs {
1735            combined = s.builder.push_expr(IrExpr::BinaryOp {
1736                op: BinaryOpKind::Add,
1737                left: combined,
1738                right: next,
1739            });
1740        }
1741        combined
1742    }
1743
1744    fn reject_bare_graph_value_predicate(expr: &Expr, span: Span, s: &mut BinderState) {
1745        let Expr::Var(VarRef { name, .. }) = strip_parens(expr) else {
1746            return;
1747        };
1748        if s.path_vars.contains_key(name) {
1749            s.errors.push(BindError::new(
1750                BindErrorKind::InvalidArgument,
1751                span,
1752                format!("WHERE predicate `{name}` must be a boolean expression"),
1753            ));
1754            return;
1755        }
1756        let Some(var_id) = s.vars.get(name).copied() else {
1757            return;
1758        };
1759        if s.node_vars.contains_key(&var_id) || s.edge_rel_names.contains_key(&var_id) {
1760            s.errors.push(BindError::new(
1761                BindErrorKind::InvalidArgument,
1762                span,
1763                format!("WHERE predicate `{name}` must be a boolean expression"),
1764            ));
1765        }
1766    }
1767
1768    // Item classification (aggregate/node-forward/path/scalar) + scope reset +
1769    // ORDER BY/SKIP/LIMIT make this long but linear, like the RETURN lowering.
1770    #[allow(clippy::too_many_lines)]
1771    fn lower_with(&self, w: &WithClause, s: &mut BinderState) {
1772        type ForwardedEdge = (VarId, Option<String>, Option<(VarId, VarId)>);
1773
1774        let items_ast = expand_projection_wildcard(&w.items, s);
1775        check_duplicate_aliases(&items_ast, s);
1776        if w.order_by
1777            .as_ref()
1778            .is_some_and(|order_by| has_unprojected_order_aggregate(&items_ast, &order_by.items))
1779        {
1780            s.errors.push(BindError::new(
1781                BindErrorKind::InvalidArgument,
1782                w.order_by.as_ref().expect("checked above").span,
1783                "an aggregate function in ORDER BY must also appear in the WITH projection",
1784            ));
1785            return;
1786        }
1787        // WITH projects/renames the pipeline and then *resets the scope*: only
1788        // the projected aliases are visible to subsequent clauses (#814).
1789        //
1790        // Each item's expression is lowered in the CURRENT scope (it references
1791        // upstream vars), then a fresh `out_var` is minted for its alias and
1792        // becomes the downstream scope. The lowerer maps `out_var` to the
1793        // projected column.
1794        //
1795        // Aggregation in WITH (#958): a WITH with a top-level aggregate
1796        // (`WITH a.name AS name, count(*) AS c`) lowers to a `GraphOp::Aggregate`
1797        // (mirroring RETURN's implicit grouping) instead of a `Project`. A NESTED
1798        // aggregate inside a larger expression (`count(n) + 1 AS c`) uses an
1799        // aggregate→project decomposition with a re-bound scope. An aggregate
1800        // inside another aggregate's argument (`sum(count(*))`) is invalid.
1801        let has_nested_agg = items_ast.iter().any(|i| match agg_func_of(&i.expr) {
1802            // A top-level aggregate is fine only if its arguments hold no further
1803            // aggregate (Cypher forbids nesting, so this is a malformed shape).
1804            Some(_) => match &i.expr {
1805                Expr::FunctionCall(call) => call.args.iter().any(expr_contains_aggregate),
1806                _ => false,
1807            },
1808            // A non-aggregate item must contain no aggregate at all.
1809            None => expr_contains_aggregate(&i.expr),
1810        });
1811        let has_aggregate_inside_aggregate = items_ast
1812            .iter()
1813            .any(|item| expr_contains_aggregate_inside_aggregate(&item.expr, false));
1814        if has_aggregate_inside_aggregate {
1815            s.errors.push(BindError::new(
1816                BindErrorKind::InvalidArgument,
1817                w.span,
1818                "an aggregate function may not contain another aggregate function",
1819            ));
1820            return;
1821        }
1822        if has_nested_agg && items_ast.iter().any(|i| expr_contains_aggregate(&i.expr)) {
1823            self.lower_with_aggregate_arith(w, &items_ast, s);
1824            return;
1825        }
1826        if !has_nested_agg && items_ast.iter().any(|i| agg_func_of(&i.expr).is_some()) {
1827            self.lower_with_aggregate(w, &items_ast, s);
1828            return;
1829        }
1830        let mut items: Vec<ProjectItem> = Vec::with_capacity(items_ast.len());
1831        let mut new_scope: Vec<(String, VarId)> = Vec::new();
1832        // Entity variables forwarded WHOLE (`WITH n`, `WITH r`): restore the
1833        // binder-side metadata after the scope reset, so a later `RETURN n` /
1834        // `n.x` or `RETURN r` / `r.x` still treats them as entities.
1835        let mut forwarded_nodes: Vec<(VarId, Option<String>)> = Vec::new();
1836        let mut forwarded_edges: Vec<ForwardedEdge> = Vec::new();
1837        let mut forwarded_paths: Vec<(String, PathBinding)> = Vec::new();
1838        let mut forwarded_path_vars = HashSet::new();
1839        for item in &items_ast {
1840            // Reject (rather than silently mishandle) the parts not yet
1841            // supported in WITH — these would otherwise produce wrong results.
1842            // The aggregate check is recursive: `WITH count(n) + 1 AS c` must be
1843            // caught too, not only a top-level `WITH count(n) AS c`.
1844            if expr_contains_aggregate(&item.expr) {
1845                s.errors.push(BindError::new(
1846                    BindErrorKind::UnsupportedClause,
1847                    item.span,
1848                    "aggregation in WITH is not yet supported (#814 follow-up)".to_string(),
1849                ));
1850            }
1851
1852            // A bare entity variable (`WITH n` or `WITH n AS m`) is carried
1853            // through whole. Each output alias gets a fresh var while the
1854            // source expression retains the incoming var, allowing the lowerer
1855            // to re-qualify every entity column independently. Forwarding a
1856            // path is still deferred.
1857            let node_forward = match &item.expr {
1858                Expr::Var(VarRef { name, .. }) => s
1859                    .vars
1860                    .get(name)
1861                    .copied()
1862                    .filter(|v| s.node_vars.contains_key(v)),
1863                _ => None,
1864            };
1865            let edge_forward = match &item.expr {
1866                Expr::Var(VarRef { name, .. }) => s
1867                    .vars
1868                    .get(name)
1869                    .copied()
1870                    .filter(|v| s.edge_rel_names.contains_key(v)),
1871                _ => None,
1872            };
1873            let computed_node_label = match &item.expr {
1874                Expr::FunctionCall(call)
1875                    if is_function_named(call, "coalesce") && !call.args.is_empty() =>
1876                {
1877                    call.args
1878                        .iter()
1879                        .map(|arg| match arg {
1880                            Expr::Var(VarRef { name, .. }) => s
1881                                .vars
1882                                .get(name)
1883                                .and_then(|var| s.node_vars.get(var))
1884                                .cloned(),
1885                            _ => None,
1886                        })
1887                        .collect::<Option<Vec<_>>>()
1888                        .map(|labels| labels.into_iter().flatten().next())
1889                }
1890                _ => None,
1891            };
1892            let path_forward = match &item.expr {
1893                Expr::Var(VarRef { name, .. }) => s.path_vars.get(name).cloned(),
1894                _ => None,
1895            };
1896            let expr_id = if computed_node_label.is_some() || path_forward.is_some() {
1897                self.lower_return_item_expr(&item.expr, item.span, s, true)
1898            } else {
1899                self.lower_expr(&item.expr, item.span, s)
1900            };
1901
1902            if let Some(v) = node_forward {
1903                // Each alias gets its own output var. Reusing `v` for both
1904                // `WITH n AS a, n AS b` would project the same qualified entity
1905                // columns twice and make the downstream schema ambiguous.
1906                let label = s.node_vars.get(&v).cloned().flatten();
1907                let expr_name = match &item.expr {
1908                    Expr::Var(VarRef { name, .. }) => name.clone(),
1909                    _ => unreachable!("node_forward is Some only for a Var item"),
1910                };
1911                let name = item.alias.clone().unwrap_or(expr_name);
1912                let out_var = alloc_anon_var(s);
1913                forwarded_nodes.push((out_var, label));
1914                new_scope.push((name.clone(), out_var));
1915                items.push(ProjectItem {
1916                    expr: expr_id,
1917                    alias: Some(name),
1918                    out_var: Some(out_var),
1919                });
1920                continue;
1921            }
1922            if let Some(v) = edge_forward {
1923                let rel_name = s.edge_rel_names.get(&v).cloned().flatten();
1924                let endpoints = s.edge_vars.get(&v).copied();
1925                let expr_name = match &item.expr {
1926                    Expr::Var(VarRef { name, .. }) => name.clone(),
1927                    _ => unreachable!("edge_forward is Some only for a Var item"),
1928                };
1929                let name = item.alias.clone().unwrap_or(expr_name);
1930                let out_var = alloc_anon_var(s);
1931                forwarded_edges.push((out_var, rel_name, endpoints));
1932                new_scope.push((name.clone(), out_var));
1933                items.push(ProjectItem {
1934                    expr: expr_id,
1935                    alias: Some(name),
1936                    out_var: Some(out_var),
1937                });
1938                continue;
1939            }
1940
1941            // Alias: explicit (`expr AS name`), or the variable's own name for a
1942            // bare scalar-variable pass-through (`WITH x`).
1943            let alias = item.alias.clone().or_else(|| match &item.expr {
1944                Expr::Var(VarRef { name, .. }) => Some(name.clone()),
1945                _ => None,
1946            });
1947            let Some(alias) = alias else {
1948                s.errors.push(BindError::new(
1949                    BindErrorKind::UnsupportedClause,
1950                    item.span,
1951                    "a non-variable WITH item must be aliased (`expr AS name`)".to_string(),
1952                ));
1953                continue;
1954            };
1955            let out_var = alloc_anon_var(s);
1956            if matches!(item.expr, Expr::Literal(Literal::Null(_))) {
1957                s.var_kinds.insert(out_var, VarKind::Unknown);
1958            }
1959            if let Some(label) = computed_node_label {
1960                forwarded_nodes.push((out_var, label));
1961            }
1962            if let Some(binding) = path_forward {
1963                s.var_kinds.insert(out_var, VarKind::Unknown);
1964                forwarded_path_vars.extend(binding.nodes.iter().copied());
1965                forwarded_path_vars.extend(binding.segments.iter().map(|segment| segment.edge));
1966                forwarded_paths.push((alias.clone(), binding));
1967            }
1968            if let Expr::List(graphforge_ast::ListLiteral { elements, .. }) = &item.expr
1969                && !elements.is_empty()
1970                && elements.iter().all(|element| {
1971                    matches!(element, Expr::Var(VarRef { name, .. })
1972                        if s.vars.get(name).is_some_and(|var| s.edge_rel_names.contains_key(var)))
1973                })
1974            {
1975                forwarded_edges.push((out_var, None, None));
1976            }
1977            new_scope.push((alias.clone(), out_var));
1978            items.push(ProjectItem {
1979                expr: expr_id,
1980                alias: Some(alias),
1981                out_var: Some(out_var),
1982            });
1983        }
1984
1985        let mut forwarded_path_vars = forwarded_path_vars.into_iter().collect::<Vec<_>>();
1986        forwarded_path_vars.sort_by_key(|var| var.0);
1987        for var in forwarded_path_vars {
1988            let expr = s.builder.push_expr(IrExpr::VarRef(var));
1989            items.push(ProjectItem {
1990                expr,
1991                alias: Some(format!("__gf_path_component_{}", var.0)),
1992                out_var: Some(var),
1993            });
1994            if let Some(label) = s.node_vars.get(&var).cloned() {
1995                forwarded_nodes.push((var, label));
1996            }
1997            if let Some(rel_name) = s.edge_rel_names.get(&var).cloned() {
1998                forwarded_edges.push((var, rel_name, s.edge_vars.get(&var).copied()));
1999            }
2000        }
2001
2002        // A scalar WITH WHERE can be evaluated against the incoming row after
2003        // substituting projected aliases back to their source expressions. This
2004        // keeps hidden incoming columns available for both WHERE and ORDER BY,
2005        // then lets the WITH operator perform only the final scope reset.
2006        if let Some(wc) = w
2007            .where_clause
2008            .as_ref()
2009            .filter(|wc| !expr_contains_pattern_predicate(&wc.predicate))
2010        {
2011            let predicate = rewrite_projection_alias_refs(wc.predicate.clone(), &items_ast);
2012            let predicate = self.lower_expr(&predicate, wc.span, s);
2013            s.builder.push_op_mut(GraphOp::Filter { predicate });
2014        }
2015        let where_pattern_predicate = w
2016            .where_clause
2017            .as_ref()
2018            .filter(|wc| expr_contains_pattern_predicate(&wc.predicate))
2019            .cloned();
2020
2021        let projection_bindings: Vec<(Expr, String, VarId)> = items_ast
2022            .iter()
2023            .filter_map(|item| {
2024                let alias = item.alias.clone().or_else(|| match &item.expr {
2025                    Expr::Var(VarRef { name, .. }) => Some(name.clone()),
2026                    _ => None,
2027                })?;
2028                let var = new_scope
2029                    .iter()
2030                    .find_map(|(name, var)| (name == &alias).then_some(*var))?;
2031                Some((item.expr.clone(), alias, var))
2032            })
2033            .collect();
2034
2035        // A non-DISTINCT ORDER BY may use incoming expressions that are not
2036        // projected. Bind it while that scope still exists and execute it before
2037        // WITH drops the hidden columns. Projected aliases are substituted back
2038        // to their source expressions for this pre-projection sort.
2039        let projected_names = projection_bindings
2040            .iter()
2041            .map(|(_, alias, _)| alias.as_str())
2042            .collect::<std::collections::HashSet<_>>();
2043        let sort_fits_projection = w.order_by.as_ref().is_none_or(|order_by| {
2044            order_by.items.iter().all(|item| {
2045                let rewritten = rewrite_grouping_refs(item.expr.clone(), &projection_bindings);
2046                let mut refs = Vec::new();
2047                collect_grouping_refs(&rewritten, &mut refs);
2048                refs.iter().all(|reference| {
2049                    grouping_ref_root_name(reference)
2050                        .is_some_and(|name| projected_names.contains(name))
2051                })
2052            })
2053        });
2054        let sort_before_projection = w.order_by.is_some() && !w.distinct && !sort_fits_projection;
2055        if sort_before_projection {
2056            self.push_sort_before_projection(
2057                &w.order_by.as_ref().expect("checked above").items,
2058                &items_ast,
2059                s,
2060            );
2061        }
2062
2063        // Scope reset: drop everything, then introduce only the WITH aliases
2064        // (and re-register forwarded whole-node vars as nodes).
2065        s.vars.clear();
2066        s.node_vars.clear();
2067        s.edge_vars.clear();
2068        s.edge_rel_names.clear();
2069        s.path_vars.clear();
2070        for (name, v) in new_scope {
2071            s.vars.insert(name, v);
2072        }
2073        for (v, label) in forwarded_nodes {
2074            s.node_vars.insert(v, label);
2075        }
2076        for (v, rel_name, endpoints) in forwarded_edges {
2077            s.edge_rel_names.insert(v, rel_name);
2078            if let Some(endpoints) = endpoints {
2079                s.edge_vars.insert(v, endpoints);
2080            }
2081        }
2082        for (name, binding) in forwarded_paths {
2083            s.path_vars.insert(name, binding);
2084        }
2085
2086        s.builder.push_op_mut(GraphOp::With {
2087            items,
2088            distinct: w.distinct,
2089            where_predicate: None,
2090        });
2091        if let Some(wc) = where_pattern_predicate {
2092            self.lower_where_predicate(&wc.predicate, wc.span, s);
2093        }
2094        if let Some(ob) = &w.order_by
2095            && !sort_before_projection
2096        {
2097            self.push_sort_rewritten(&ob.items, &projection_bindings, s);
2098        }
2099        push_skip_limit(self, w.skip.as_ref(), w.limit.as_ref(), s);
2100    }
2101
2102    /// Lower a WITH containing a top-level aggregate (#958) into a
2103    /// [`GraphOp::Aggregate`] that also introduces the post-aggregate scope:
2104    /// non-aggregate items become group-by keys, aggregates become
2105    /// [`AggExpr`]s, and every output column is bound to a fresh `out_var` so
2106    /// the lowerer's decomposed-aggregate path resets the scope to exactly the
2107    /// projected aliases (the same scope-reset contract a plain WITH enforces).
2108    /// A following `WHERE` becomes a `Filter` over those aliases; `ORDER BY` /
2109    /// `SKIP` / `LIMIT` follow as for a non-aggregate WITH.
2110    ///
2111    /// Whole node, relationship, and path keys retain their original variables;
2112    /// the relational lowerer groups their qualified physical columns and keeps
2113    /// those variables available to downstream graph operators.
2114    #[allow(clippy::too_many_lines)]
2115    fn lower_with_aggregate(&self, w: &WithClause, items: &[ReturnItem], s: &mut BinderState) {
2116        let mut group_by: Vec<ExprId> = Vec::new();
2117        let mut group_aliases: Vec<Option<String>> = Vec::new();
2118        let mut group_vars: Vec<Option<VarId>> = Vec::new();
2119        let mut aggs: Vec<AggExpr> = Vec::new();
2120        let mut new_scope: Vec<(String, VarId)> = Vec::new();
2121        let mut forwarded_nodes: Vec<(VarId, Option<String>)> = Vec::new();
2122        let mut forwarded_edges: Vec<ForwardedEdgeBinding> = Vec::new();
2123        let mut forwarded_paths: Vec<(String, PathBinding)> = Vec::new();
2124        let mut projection_bindings: Vec<(Expr, String, VarId)> = Vec::new();
2125
2126        for item in items {
2127            // Output column name: explicit `AS alias`, or a bare variable's name.
2128            let alias = item.alias.clone().or_else(|| match &item.expr {
2129                Expr::Var(VarRef { name, .. }) => Some(name.clone()),
2130                _ => None,
2131            });
2132
2133            if let Some(func) = agg_func_of(&item.expr) {
2134                let Expr::FunctionCall(call) = &item.expr else {
2135                    unreachable!("agg_func_of only matches FunctionCall");
2136                };
2137                let Some(alias) = alias else {
2138                    s.errors.push(BindError::new(
2139                        BindErrorKind::UnsupportedClause,
2140                        item.span,
2141                        "an aggregate in WITH must be aliased (`count(*) AS name`)".to_string(),
2142                    ));
2143                    continue;
2144                };
2145                let out_var = alloc_anon_var(s);
2146                aggs.push(self.build_agg(call, func, alias.clone(), Some(out_var), s));
2147                projection_bindings.push((item.expr.clone(), alias.clone(), out_var));
2148                new_scope.push((alias, out_var));
2149            } else {
2150                if let Expr::Var(VarRef { name, .. }) = &item.expr
2151                    && let Some(binding) = s.path_vars.get(name).cloned()
2152                {
2153                    let Some(alias) = alias else {
2154                        unreachable!("a bare path always has its variable name as alias")
2155                    };
2156                    let mut vars = binding.nodes.clone();
2157                    vars.extend(binding.segments.iter().map(|segment| segment.edge));
2158                    vars.sort_by_key(|var| var.0);
2159                    vars.dedup();
2160                    for var in vars {
2161                        let expr = s.builder.push_expr(IrExpr::VarRef(var));
2162                        group_by.push(expr);
2163                        group_aliases.push(None);
2164                        group_vars.push(Some(var));
2165                        if let Some(label) = s.node_vars.get(&var).cloned() {
2166                            forwarded_nodes.push((var, label));
2167                        }
2168                        if let Some(rel_name) = s.edge_rel_names.get(&var).cloned() {
2169                            forwarded_edges.push(ForwardedEdgeBinding {
2170                                var,
2171                                rel_name,
2172                                endpoints: s.edge_vars.get(&var).copied(),
2173                            });
2174                        }
2175                    }
2176                    forwarded_paths.push((alias, binding));
2177                    continue;
2178                }
2179
2180                let entity_var = match &item.expr {
2181                    Expr::Var(VarRef { name, .. }) => s.vars.get(name).copied().filter(|var| {
2182                        s.node_vars.contains_key(var) || s.edge_rel_names.contains_key(var)
2183                    }),
2184                    Expr::FunctionCall(call) => Self::resolve_endpoint_node(call, s),
2185                    _ => None,
2186                };
2187                if let Some(var) = entity_var {
2188                    let Some(alias) = alias else {
2189                        unreachable!("a graph variable always has an output alias")
2190                    };
2191                    let expr = s.builder.push_expr(IrExpr::VarRef(var));
2192                    group_by.push(expr);
2193                    group_aliases.push(None);
2194                    group_vars.push(Some(var));
2195                    projection_bindings.push((item.expr.clone(), alias.clone(), var));
2196                    new_scope.push((alias, var));
2197                    if let Some(label) = s.node_vars.get(&var).cloned() {
2198                        forwarded_nodes.push((var, label));
2199                    }
2200                    if let Some(rel_name) = s.edge_rel_names.get(&var).cloned() {
2201                        forwarded_edges.push(ForwardedEdgeBinding {
2202                            var,
2203                            rel_name,
2204                            endpoints: s.edge_vars.get(&var).copied(),
2205                        });
2206                    }
2207                    continue;
2208                }
2209                let Some(alias) = alias else {
2210                    s.errors.push(BindError::new(
2211                        BindErrorKind::UnsupportedClause,
2212                        item.span,
2213                        "a non-variable WITH item must be aliased (`expr AS name`)".to_string(),
2214                    ));
2215                    continue;
2216                };
2217                let expr_id = self.lower_expr(&item.expr, item.span, s);
2218                let out_var = alloc_anon_var(s);
2219                group_by.push(expr_id);
2220                group_aliases.push(Some(alias.clone()));
2221                group_vars.push(Some(out_var));
2222                projection_bindings.push((item.expr.clone(), alias.clone(), out_var));
2223                new_scope.push((alias, out_var));
2224            }
2225        }
2226        // Scope reset: WITH exposes only its projected aliases downstream.
2227        s.vars.clear();
2228        s.node_vars.clear();
2229        s.edge_vars.clear();
2230        s.edge_rel_names.clear();
2231        s.path_vars.clear();
2232        for (name, v) in new_scope {
2233            s.vars.insert(name, v);
2234        }
2235        for (var, label) in forwarded_nodes {
2236            s.node_vars.insert(var, label);
2237        }
2238        for edge in forwarded_edges {
2239            s.edge_rel_names.insert(edge.var, edge.rel_name);
2240            if let Some(endpoints) = edge.endpoints {
2241                s.edge_vars.insert(edge.var, endpoints);
2242            }
2243        }
2244        for (name, binding) in forwarded_paths {
2245            s.path_vars.insert(name, binding);
2246        }
2247
2248        s.builder.push_op_mut(GraphOp::Aggregate {
2249            group_by,
2250            group_aliases,
2251            group_vars,
2252            aggs,
2253        });
2254
2255        // WHERE over the post-aggregate aliases cannot inline into the Aggregate,
2256        // so it becomes a Filter over the new scope.
2257        if let Some(wc) = &w.where_clause {
2258            let pred = self.lower_expr(&wc.predicate, wc.span, s);
2259            s.builder.push_op_mut(GraphOp::Filter { predicate: pred });
2260        }
2261        if let Some(ob) = &w.order_by {
2262            self.push_sort_rewritten(&ob.items, &projection_bindings, s);
2263        }
2264        push_skip_limit(self, w.skip.as_ref(), w.limit.as_ref(), s);
2265    }
2266
2267    /// Lower aggregate calls nested inside WITH expressions as Aggregate -> Project.
2268    /// Non-aggregate WITH items define the only legal implicit grouping leaves.
2269    #[allow(clippy::too_many_lines)]
2270    fn lower_with_aggregate_arith(
2271        &self,
2272        w: &WithClause,
2273        items: &[ReturnItem],
2274        s: &mut BinderState,
2275    ) {
2276        let group_items: Vec<&ReturnItem> = items
2277            .iter()
2278            .filter(|item| !expr_contains_aggregate(&item.expr))
2279            .collect();
2280        let mut group_by = Vec::with_capacity(group_items.len());
2281        let mut group_aliases = Vec::with_capacity(group_items.len());
2282        let mut group_vars = Vec::with_capacity(group_items.len());
2283        let mut group_bindings: Vec<(Expr, String, VarId)> = Vec::with_capacity(group_items.len());
2284        let mut grouped_nodes: Vec<(String, VarId, Option<String>)> = Vec::new();
2285        let mut grouped_edges: Vec<GroupedEdgeBinding> = Vec::new();
2286        let mut grouped_paths: Vec<(String, PathBinding)> = Vec::new();
2287        let mut grouped_path_nodes: Vec<(VarId, Option<String>)> = Vec::new();
2288        let mut grouped_path_edges: Vec<ForwardedEdgeBinding> = Vec::new();
2289
2290        for item in &group_items {
2291            let alias = item.alias.clone().or_else(|| match &item.expr {
2292                Expr::Var(VarRef { name, .. }) => Some(name.clone()),
2293                _ => None,
2294            });
2295            let Some(alias) = alias else {
2296                s.errors.push(BindError::new(
2297                    BindErrorKind::UnsupportedClause,
2298                    item.span,
2299                    "a non-variable WITH item must be aliased (`expr AS name`)",
2300                ));
2301                continue;
2302            };
2303            if let Expr::Var(VarRef { name, .. }) = &item.expr
2304                && let Some(binding) = s.path_vars.get(name).cloned()
2305            {
2306                let mut vars = binding.nodes.clone();
2307                vars.extend(binding.segments.iter().map(|segment| segment.edge));
2308                vars.sort_by_key(|var| var.0);
2309                vars.dedup();
2310                for var in vars {
2311                    group_by.push(s.builder.push_expr(IrExpr::VarRef(var)));
2312                    group_aliases.push(None);
2313                    group_vars.push(Some(var));
2314                    if let Some(label) = s.node_vars.get(&var).cloned() {
2315                        grouped_path_nodes.push((var, label));
2316                    }
2317                    if let Some(rel_name) = s.edge_rel_names.get(&var).cloned() {
2318                        grouped_path_edges.push(ForwardedEdgeBinding {
2319                            var,
2320                            rel_name,
2321                            endpoints: s.edge_vars.get(&var).copied(),
2322                        });
2323                    }
2324                }
2325                grouped_paths.push((alias, binding));
2326                continue;
2327            }
2328            let entity_var = match &item.expr {
2329                Expr::Var(VarRef { name, .. }) => s.vars.get(name).copied().filter(|var| {
2330                    s.node_vars.contains_key(var) || s.edge_rel_names.contains_key(var)
2331                }),
2332                Expr::FunctionCall(call) => Self::resolve_endpoint_node(call, s),
2333                _ => None,
2334            };
2335            if let Some(var) = entity_var {
2336                group_by.push(s.builder.push_expr(IrExpr::VarRef(var)));
2337                group_aliases.push(None);
2338                group_vars.push(Some(var));
2339                group_bindings.push((item.expr.clone(), alias.clone(), var));
2340                if let Some(label) = s.node_vars.get(&var).cloned() {
2341                    grouped_nodes.push((alias, var, label));
2342                } else if let Some(rel_name) = s.edge_rel_names.get(&var).cloned() {
2343                    grouped_edges.push(GroupedEdgeBinding {
2344                        alias,
2345                        var,
2346                        rel_name,
2347                        endpoints: s.edge_vars.get(&var).copied(),
2348                    });
2349                }
2350                continue;
2351            }
2352
2353            let out_var = alloc_anon_var(s);
2354            group_by.push(self.lower_expr(&item.expr, item.span, s));
2355            group_aliases.push(Some(alias.clone()));
2356            group_vars.push(Some(out_var));
2357            group_bindings.push((item.expr.clone(), alias, out_var));
2358        }
2359
2360        let mut aggs = Vec::new();
2361        let mut aggregate_bindings = Vec::new();
2362        let mut rewritten_items = Vec::with_capacity(items.len());
2363        for item in items {
2364            let alias = item.alias.clone().or_else(|| match &item.expr {
2365                Expr::Var(VarRef { name, .. }) => Some(name.clone()),
2366                _ => None,
2367            });
2368            let Some(alias) = alias else {
2369                s.errors.push(BindError::new(
2370                    BindErrorKind::UnsupportedClause,
2371                    item.span,
2372                    "an aggregate expression in WITH must be aliased (`expr AS name`)",
2373                ));
2374                continue;
2375            };
2376
2377            if expr_contains_aggregate(&item.expr) {
2378                let mut refs = Vec::new();
2379                collect_grouping_refs(&item.expr, &mut refs);
2380                let ambiguous = refs.iter().any(|reference| {
2381                    !group_bindings.iter().any(|(group, _, _)| {
2382                        is_atomic_grouping_expr(group) && same_grouping_expr(reference, group)
2383                    })
2384                });
2385                if ambiguous {
2386                    s.errors.push(BindError::new(
2387                        BindErrorKind::InvalidArgument,
2388                        item.span,
2389                        "ambiguous aggregation expression: every variable or property outside an \
2390                         aggregate must be projected as its own WITH grouping key",
2391                    ));
2392                }
2393                let first_agg = aggs.len();
2394                let rewritten = self.rewrite_aggs(&item.expr, &mut aggs, s);
2395                for (index, agg) in aggs.iter().enumerate().skip(first_agg) {
2396                    if let Some(var) = agg.out_var {
2397                        aggregate_bindings.push((format!("__agg_{index}"), var));
2398                    }
2399                }
2400                if aggs.len() == first_agg + 1 {
2401                    aggs[first_agg].alias.clone_from(&alias);
2402                }
2403                rewritten_items.push((rewritten, alias, item.span));
2404            } else {
2405                rewritten_items.push((
2406                    Expr::Var(VarRef {
2407                        name: alias.clone(),
2408                        span: item.span,
2409                    }),
2410                    alias,
2411                    item.span,
2412                ));
2413            }
2414        }
2415
2416        let aggregate_scope: Vec<(String, VarId)> = group_bindings
2417            .iter()
2418            .map(|(_, alias, var)| (alias.clone(), *var))
2419            .chain(aggregate_bindings)
2420            .collect();
2421
2422        s.vars.clear();
2423        s.node_vars.clear();
2424        s.edge_vars.clear();
2425        s.edge_rel_names.clear();
2426        s.path_vars.clear();
2427        for (name, var) in &aggregate_scope {
2428            s.vars.insert(name.clone(), *var);
2429        }
2430        for (_, var, label) in &grouped_nodes {
2431            s.node_vars.insert(*var, label.clone());
2432        }
2433        for edge in &grouped_edges {
2434            s.edge_rel_names.insert(edge.var, edge.rel_name.clone());
2435            if let Some(endpoints) = edge.endpoints {
2436                s.edge_vars.insert(edge.var, endpoints);
2437            }
2438        }
2439        for (var, label) in &grouped_path_nodes {
2440            s.node_vars.insert(*var, label.clone());
2441        }
2442        for edge in &grouped_path_edges {
2443            s.edge_rel_names.insert(edge.var, edge.rel_name.clone());
2444            if let Some(endpoints) = edge.endpoints {
2445                s.edge_vars.insert(edge.var, endpoints);
2446            }
2447        }
2448        for (alias, binding) in &grouped_paths {
2449            s.path_vars.insert(alias.clone(), binding.clone());
2450        }
2451
2452        s.builder.push_op_mut(GraphOp::Aggregate {
2453            group_by,
2454            group_aliases,
2455            group_vars,
2456            aggs,
2457        });
2458
2459        let mut project = Vec::with_capacity(rewritten_items.len());
2460        let mut final_scope = Vec::with_capacity(rewritten_items.len());
2461        let mut final_nodes = Vec::new();
2462        let mut final_edges = Vec::new();
2463        let mut final_paths = Vec::new();
2464        for (expr, alias, span) in rewritten_items {
2465            let expr = rewrite_grouping_refs(expr, &group_bindings);
2466            let expr = self.lower_expr(&expr, span, s);
2467            let node_binding = grouped_nodes
2468                .iter()
2469                .find(|(group_alias, _, _)| group_alias == &alias);
2470            let edge_binding = grouped_edges.iter().find(|binding| binding.alias == alias);
2471            let out_var = node_binding
2472                .map(|(_, var, _)| *var)
2473                .or_else(|| edge_binding.map(|binding| binding.var))
2474                .unwrap_or_else(|| alloc_anon_var(s));
2475            project.push(ProjectItem {
2476                expr,
2477                alias: Some(alias.clone()),
2478                out_var: Some(out_var),
2479            });
2480            if let Some((_, _, label)) = node_binding {
2481                final_nodes.push((out_var, label.clone()));
2482            }
2483            if let Some(binding) = edge_binding {
2484                final_edges.push((out_var, binding.rel_name.clone(), binding.endpoints));
2485            }
2486            if let Some((_, binding)) = grouped_paths
2487                .iter()
2488                .find(|(group_alias, _)| group_alias == &alias)
2489            {
2490                final_paths.push((alias.clone(), binding.clone()));
2491            }
2492            final_scope.push((alias, out_var));
2493        }
2494        // Keep path components as hidden WITH outputs. They are not installed
2495        // as named scope entries, so `RETURN *` exposes only declared aliases,
2496        // while downstream path functions can still rebuild the path value.
2497        let mut hidden_path_vars = grouped_paths
2498            .iter()
2499            .flat_map(|(_, binding)| {
2500                binding
2501                    .nodes
2502                    .iter()
2503                    .copied()
2504                    .chain(binding.segments.iter().map(|segment| segment.edge))
2505            })
2506            .collect::<Vec<_>>();
2507        hidden_path_vars.sort_by_key(|var| var.0);
2508        hidden_path_vars.dedup();
2509        for var in hidden_path_vars {
2510            project.push(ProjectItem {
2511                expr: s.builder.push_expr(IrExpr::VarRef(var)),
2512                alias: Some(format!("__path_{}", var.0)),
2513                out_var: Some(var),
2514            });
2515        }
2516        s.builder.push_op_mut(GraphOp::With {
2517            items: project,
2518            distinct: w.distinct,
2519            where_predicate: None,
2520        });
2521
2522        s.vars.clear();
2523        s.node_vars.clear();
2524        s.edge_vars.clear();
2525        s.edge_rel_names.clear();
2526        s.path_vars.clear();
2527        for (name, var) in final_scope {
2528            s.vars.insert(name, var);
2529        }
2530        for (var, label) in final_nodes {
2531            s.node_vars.insert(var, label);
2532        }
2533        for (var, rel_name, endpoints) in final_edges {
2534            s.edge_rel_names.insert(var, rel_name);
2535            if let Some(endpoints) = endpoints {
2536                s.edge_vars.insert(var, endpoints);
2537            }
2538        }
2539        for (name, binding) in final_paths {
2540            s.path_vars.insert(name, binding);
2541        }
2542        for (var, label) in grouped_path_nodes {
2543            s.node_vars.insert(var, label);
2544        }
2545        for edge in grouped_path_edges {
2546            s.edge_rel_names.insert(edge.var, edge.rel_name);
2547            if let Some(endpoints) = edge.endpoints {
2548                s.edge_vars.insert(edge.var, endpoints);
2549            }
2550        }
2551        if let Some(wc) = &w.where_clause {
2552            self.lower_where_predicate(&wc.predicate, wc.span, s);
2553        }
2554        if let Some(ob) = &w.order_by {
2555            self.push_sort(&ob.items, s);
2556        }
2557        push_skip_limit(self, w.skip.as_ref(), w.limit.as_ref(), s);
2558    }
2559
2560    fn lower_return(&self, r: &graphforge_ast::ReturnClause, s: &mut BinderState) {
2561        if reject_empty_projection_wildcard(&r.items, s) {
2562            return;
2563        }
2564        check_duplicate_aliases(&r.items, s);
2565        // Expand a `RETURN *` wildcard to one item per in-scope named variable.
2566        let items = expand_projection_wildcard(&r.items, s);
2567        if r.order_by
2568            .as_ref()
2569            .is_some_and(|order_by| has_unprojected_order_aggregate(&items, &order_by.items))
2570        {
2571            s.errors.push(BindError::new(
2572                BindErrorKind::InvalidArgument,
2573                r.order_by.as_ref().expect("checked above").span,
2574                "an aggregate function in ORDER BY must also appear in the RETURN projection",
2575            ));
2576            return;
2577        }
2578        // A NESTED aggregate (`count(*) + 1`, `count(a) + count(b)`) is one that
2579        // is not the whole item. Reuse WITH's aggregate-to-project path: it
2580        // validates implicit grouping leaves and supports aggregates nested in
2581        // maps, lists, and arithmetic expressions.
2582        let has_nested = items
2583            .iter()
2584            .any(|i| agg_func_of(&i.expr).is_none() && expr_contains_aggregate(&i.expr));
2585        if items
2586            .iter()
2587            .any(|item| expr_contains_aggregate_inside_aggregate(&item.expr, false))
2588        {
2589            s.errors.push(BindError::new(
2590                BindErrorKind::InvalidArgument,
2591                r.span,
2592                "an aggregate function may not contain another aggregate function",
2593            ));
2594            return;
2595        }
2596        let mut aggregate_exprs = Vec::new();
2597        for item in &items {
2598            collect_aggregate_exprs(&item.expr, &mut aggregate_exprs);
2599        }
2600        if aggregate_exprs.iter().any(|expr| match expr {
2601            Expr::FunctionCall(call) => call.args.iter().any(expr_contains_volatile_function),
2602            _ => false,
2603        }) {
2604            s.errors.push(BindError::new(
2605                BindErrorKind::InvalidArgument,
2606                r.span,
2607                "non-deterministic functions are not allowed inside aggregate arguments",
2608            ));
2609            return;
2610        }
2611        // If any item is a top-level aggregate (`count(...)`, `sum(...)`, …),
2612        // emit an `Aggregate` rather than a `Project`: the non-aggregate items
2613        // become group-by keys and the aggregates become `AggExpr`s. openCypher
2614        // implicit grouping = group by every non-aggregated return expression.
2615        if has_nested {
2616            self.lower_nested_return(r, &items, s);
2617        } else if items.iter().any(|i| agg_func_of(&i.expr).is_some()) {
2618            let bindings = self.lower_return_aggregate(&items, s);
2619            if let Some(ob) = &r.order_by {
2620                self.push_sort_rewritten(&ob.items, &bindings, s);
2621            }
2622            push_skip_limit(self, r.skip.as_ref(), r.limit.as_ref(), s);
2623        } else {
2624            let sort_before_projection = r.order_by.is_some() && !r.distinct;
2625            let (lowered, projection_scope) =
2626                self.lower_return_items(&items, s, true, r.order_by.is_some());
2627            if sort_before_projection {
2628                self.push_sort_before_projection(
2629                    &r.order_by.as_ref().expect("checked above").items,
2630                    &items,
2631                    s,
2632                );
2633            }
2634            let projection_bindings: Vec<(Expr, String, VarId)> = items
2635                .iter()
2636                .zip(&projection_scope)
2637                .map(|(item, (alias, var))| (item.expr.clone(), alias.clone(), *var))
2638                .collect();
2639            s.builder.push_op_mut(GraphOp::Project {
2640                items: lowered,
2641                distinct: r.distinct,
2642            });
2643            if r.distinct {
2644                s.vars.clear();
2645                s.node_vars.clear();
2646                s.edge_vars.clear();
2647                s.edge_rel_names.clear();
2648                s.path_vars.clear();
2649            }
2650            for (name, v) in &projection_scope {
2651                s.vars.insert(name.clone(), *v);
2652            }
2653            if let Some(ob) = &r.order_by
2654                && !sort_before_projection
2655            {
2656                self.push_sort_rewritten(&ob.items, &projection_bindings, s);
2657            }
2658            push_skip_limit(self, r.skip.as_ref(), r.limit.as_ref(), s);
2659        }
2660    }
2661
2662    fn lower_nested_return(
2663        &self,
2664        r: &graphforge_ast::ReturnClause,
2665        items: &[ReturnItem],
2666        s: &mut BinderState,
2667    ) {
2668        let aggregate_items = items
2669            .iter()
2670            .cloned()
2671            .map(|mut item| {
2672                if item.alias.is_none() {
2673                    item.alias.clone_from(&item.display);
2674                }
2675                item
2676            })
2677            .collect::<Vec<_>>();
2678        let aggregate_return = WithClause {
2679            distinct: r.distinct,
2680            items: aggregate_items.clone(),
2681            order_by: r.order_by.clone(),
2682            skip: r.skip.clone(),
2683            limit: r.limit.clone(),
2684            where_clause: None,
2685            span: r.span,
2686        };
2687        self.lower_with_aggregate_arith(&aggregate_return, &aggregate_items, s);
2688        let terminal_items = aggregate_items
2689            .iter()
2690            .filter_map(|item| {
2691                let name = item.alias.clone()?;
2692                Some(ReturnItem {
2693                    expr: Expr::Var(VarRef {
2694                        name: name.clone(),
2695                        span: item.span,
2696                    }),
2697                    alias: Some(name.clone()),
2698                    display: Some(name),
2699                    span: item.span,
2700                })
2701            })
2702            .collect();
2703        self.lower_return(
2704            &graphforge_ast::ReturnClause {
2705                distinct: false,
2706                items: terminal_items,
2707                order_by: None,
2708                skip: None,
2709                limit: None,
2710                span: r.span,
2711            },
2712            s,
2713        );
2714    }
2715
2716    fn lower_unwind(&self, u: &graphforge_ast::UnwindClause, s: &mut BinderState) {
2717        let list_expr = self.lower_expr(&u.expr, u.span, s);
2718        let alias = ensure_var_name(&u.alias, s);
2719        s.var_kinds.insert(alias, VarKind::Unknown);
2720        s.builder.push_op_mut(GraphOp::Unwind { list_expr, alias });
2721    }
2722
2723    /// Bind a registered procedure call and introduce its yielded outputs.
2724    #[allow(clippy::too_many_lines)]
2725    fn lower_call(&self, call: &CallClause, s: &mut BinderState) {
2726        if call.procedure.is_empty() {
2727            s.errors.push(BindError::new(
2728                BindErrorKind::UnsupportedClause,
2729                call.span,
2730                "CALL subqueries are not procedure calls",
2731            ));
2732            return;
2733        }
2734
2735        let name = call.procedure.join(".");
2736        let Some(procedure) = self.procedures.get(&name).cloned() else {
2737            s.errors.push(BindError::new(
2738                BindErrorKind::InvalidArgument,
2739                call.span,
2740                format!("ProcedureNotFound: `{name}` is not registered"),
2741            ));
2742            return;
2743        };
2744
2745        if call.args.iter().any(expr_contains_aggregate) {
2746            s.errors.push(BindError::new(
2747                BindErrorKind::InvalidArgument,
2748                call.span,
2749                "InvalidAggregation: aggregate expressions are not valid procedure arguments",
2750            ));
2751            return;
2752        }
2753
2754        let args = if call.args_explicit {
2755            if call.args.len() != procedure.inputs.len() {
2756                s.errors.push(BindError::new(
2757                    BindErrorKind::InvalidArgument,
2758                    call.span,
2759                    format!(
2760                        "InvalidNumberOfArguments: `{name}` expects {}, found {}",
2761                        procedure.inputs.len(),
2762                        call.args.len()
2763                    ),
2764                ));
2765                return;
2766            }
2767            for (arg, field) in call.args.iter().zip(&procedure.inputs) {
2768                if !procedure_argument_type_matches(arg, field) {
2769                    s.errors.push(BindError::new(
2770                        BindErrorKind::InvalidArgument,
2771                        arg.span(),
2772                        format!("InvalidArgumentType: expected {}", field.type_name),
2773                    ));
2774                }
2775            }
2776            call.args
2777                .iter()
2778                .map(|arg| self.lower_expr(arg, call.span, s))
2779                .collect()
2780        } else {
2781            if !s.standalone_call && !procedure.inputs.is_empty() {
2782                s.errors.push(BindError::new(
2783                    BindErrorKind::InvalidArgument,
2784                    call.span,
2785                    "InvalidArgumentPassingMode: in-query calls require explicit arguments",
2786                ));
2787                return;
2788            }
2789            procedure
2790                .inputs
2791                .iter()
2792                .map(|field| s.builder.push_expr(IrExpr::Parameter(field.name.clone())))
2793                .collect()
2794        };
2795
2796        let yield_all = call.yield_items.len() == 1
2797            && matches!(&call.yield_items[0].expr, Expr::Var(VarRef { name, .. }) if name == "*");
2798        if yield_all && !s.standalone_call {
2799            s.errors.push(BindError::new(
2800                BindErrorKind::InvalidArgument,
2801                call.span,
2802                "UnexpectedSyntax: YIELD * is only valid for standalone calls",
2803            ));
2804            return;
2805        }
2806        let selected: Vec<(String, String)> = if call.yield_items.is_empty() && !s.standalone_call {
2807            vec![]
2808        } else if call.yield_items.is_empty() || yield_all {
2809            procedure
2810                .outputs
2811                .iter()
2812                .map(|field| (field.name.clone(), field.name.clone()))
2813                .collect()
2814        } else {
2815            let mut selected = Vec::with_capacity(call.yield_items.len());
2816            for item in &call.yield_items {
2817                let Expr::Var(VarRef { name: field, .. }) = &item.expr else {
2818                    s.errors.push(BindError::new(
2819                        BindErrorKind::InvalidArgument,
2820                        item.span,
2821                        "YIELD items must name procedure outputs",
2822                    ));
2823                    continue;
2824                };
2825                if !procedure.outputs.iter().any(|output| output.name == *field) {
2826                    s.errors.push(BindError::new(
2827                        BindErrorKind::InvalidArgument,
2828                        item.span,
2829                        format!("ProcedureOutputNotFound: `{name}` has no output `{field}`"),
2830                    ));
2831                    continue;
2832                }
2833                selected.push((
2834                    field.clone(),
2835                    item.alias.clone().unwrap_or_else(|| field.clone()),
2836                ));
2837            }
2838            selected
2839        };
2840
2841        let mut yields = Vec::with_capacity(selected.len());
2842        for (field, alias) in selected {
2843            if s.vars.contains_key(&alias) {
2844                s.errors.push(BindError::new(
2845                    BindErrorKind::DuplicateVariable,
2846                    call.span,
2847                    format!("VariableAlreadyBound: `{alias}`"),
2848                ));
2849                continue;
2850            }
2851            let var = ensure_var_name(&alias, s);
2852            yields.push(ProcedureYield { field, alias, var });
2853        }
2854
2855        s.builder.push_op_mut(GraphOp::Call {
2856            procedure,
2857            args,
2858            yields,
2859        });
2860    }
2861
2862    fn push_sort(&self, items: &[SortItem], s: &mut BinderState) {
2863        let keys: Vec<SortKey> = items
2864            .iter()
2865            .map(|item| {
2866                let expr = self.lower_expr(&item.expr, item.span, s);
2867                SortKey {
2868                    expr,
2869                    order: match item.order {
2870                        AstSortOrder::Ascending => SortOrder::Asc,
2871                        AstSortOrder::Descending => SortOrder::Desc,
2872                    },
2873                    nulls_first: false,
2874                }
2875            })
2876            .collect();
2877        if !keys.is_empty() {
2878            s.builder.push_op_mut(GraphOp::Sort { keys });
2879        }
2880    }
2881
2882    fn push_sort_rewritten(
2883        &self,
2884        items: &[SortItem],
2885        bindings: &[(Expr, String, VarId)],
2886        s: &mut BinderState,
2887    ) {
2888        let keys = items
2889            .iter()
2890            .map(|item| {
2891                let rewritten = rewrite_grouping_refs(item.expr.clone(), bindings);
2892                SortKey {
2893                    expr: self.lower_expr(&rewritten, item.span, s),
2894                    order: match item.order {
2895                        AstSortOrder::Ascending => SortOrder::Asc,
2896                        AstSortOrder::Descending => SortOrder::Desc,
2897                    },
2898                    nulls_first: false,
2899                }
2900            })
2901            .collect::<Vec<_>>();
2902        if !keys.is_empty() {
2903            s.builder.push_op_mut(GraphOp::Sort { keys });
2904        }
2905    }
2906
2907    fn push_sort_before_projection(
2908        &self,
2909        items: &[SortItem],
2910        projections: &[ReturnItem],
2911        s: &mut BinderState,
2912    ) {
2913        let rewritten = items
2914            .iter()
2915            .map(|item| {
2916                let expr = rewrite_projection_alias_refs(item.expr.clone(), projections);
2917                SortKey {
2918                    expr: self.lower_expr(&expr, item.span, s),
2919                    order: match item.order {
2920                        AstSortOrder::Ascending => SortOrder::Asc,
2921                        AstSortOrder::Descending => SortOrder::Desc,
2922                    },
2923                    nulls_first: false,
2924                }
2925            })
2926            .collect::<Vec<_>>();
2927        if !rewritten.is_empty() {
2928            s.builder.push_op_mut(GraphOp::Sort { keys: rewritten });
2929        }
2930    }
2931
2932    fn lower_return_items(
2933        &self,
2934        items: &[ReturnItem],
2935        s: &mut BinderState,
2936        materialize_nodes: bool,
2937        bind_projection_scope: bool,
2938    ) -> (Vec<ProjectItem>, Vec<(String, VarId)>) {
2939        let mut projection_scope = Vec::new();
2940        let out = items
2941            .iter()
2942            .map(|item| {
2943                let expr = self.lower_return_item_expr(&item.expr, item.span, s, materialize_nodes);
2944                // Column name: an explicit `AS alias` wins. Otherwise a bare path
2945                // (`RETURN p`, #754) or — in a terminal RETURN — node (`RETURN n`,
2946                // #785) variable rewrites to a composed expression whose generated
2947                // column name would be the expression display string, so alias it
2948                // back to the variable name the query wrote. Every other un-aliased
2949                // item (`n.prop`, `count(*)`, `a.x IS NULL`) is named by its verbatim
2950                // source text (`item.display`), matching openCypher (#598).
2951                let alias = item.alias.clone().or_else(|| match &item.expr {
2952                    Expr::Var(VarRef { name, .. })
2953                        if s.path_vars.contains_key(name)
2954                            || (materialize_nodes
2955                                && s.vars.get(name).is_some_and(|v| {
2956                                    s.node_vars.contains_key(v) || s.edge_rel_names.contains_key(v)
2957                                })) =>
2958                    {
2959                        Some(name.clone())
2960                    }
2961                    _ => item.display.clone(),
2962                });
2963                let out_var = if bind_projection_scope {
2964                    alias.as_ref().map(|name| {
2965                        let v = alloc_anon_var(s);
2966                        projection_scope.push((name.clone(), v));
2967                        v
2968                    })
2969                } else {
2970                    None
2971                };
2972                ProjectItem {
2973                    expr,
2974                    alias,
2975                    out_var,
2976                }
2977            })
2978            .collect();
2979        (out, projection_scope)
2980    }
2981
2982    /// Lower a single RETURN item's expression. A bare node variable (`RETURN n`)
2983    /// over a `NodeScan`-bound var rewrites to a `_node_struct` call so the
2984    /// lowerer materializes a whole node value (identity + labels + properties) —
2985    /// #785. A bare fixed-hop relationship variable (`RETURN r`) rewrites to a
2986    /// `_rel_struct` call so it materializes as a whole relationship value —
2987    /// identity + type + properties (#889). A property access (`n.name`,
2988    /// `r.since`) keeps its `VarRef` base unchanged.
2989    fn lower_return_item_expr(
2990        &self,
2991        expr: &Expr,
2992        span: Span,
2993        s: &mut BinderState,
2994        materialize_nodes: bool,
2995    ) -> ExprId {
2996        if let Some(id) = self.lower_projection_value_expr(expr, span, s, materialize_nodes) {
2997            return id;
2998        }
2999        self.lower_expr(expr, span, s)
3000    }
3001
3002    fn lower_projection_value_expr(
3003        &self,
3004        expr: &Expr,
3005        span: Span,
3006        s: &mut BinderState,
3007        materialize_nodes: bool,
3008    ) -> Option<ExprId> {
3009        if materialize_nodes {
3010            // A bare node var (`RETURN n`, #785) or a relationship endpoint
3011            // (`RETURN startNode(r)` / `endNode(r)`, #753) materializes a whole
3012            // node value. Both resolve to a node var bound to a `NodeScan`.
3013            let node_var = match expr {
3014                Expr::Var(VarRef { name, .. }) => s
3015                    .vars
3016                    .get(name)
3017                    .copied()
3018                    .filter(|v| s.node_vars.contains_key(v)),
3019                Expr::FunctionCall(call) => Self::resolve_endpoint_node(call, s),
3020                _ => None,
3021            };
3022            if let Some(v) = node_var {
3023                return Some(Self::node_struct_expr(v, s));
3024            }
3025        }
3026
3027        match expr {
3028            Expr::Var(VarRef { name, .. }) => {
3029                let var = s
3030                    .vars
3031                    .get(name)
3032                    .copied()
3033                    .filter(|v| s.edge_rel_names.contains_key(v))?;
3034                Some(if s.scalar_list_edges.contains(&var) {
3035                    Self::relationship_struct_list_expr(var, s)
3036                } else {
3037                    Self::relationship_struct_expr(var, s)
3038                })
3039            }
3040            Expr::FunctionCall(call)
3041                if is_function_named(call, "type")
3042                    && call.args.len() == 1
3043                    && !Self::invalid_direct_graph_function_argument(call, s) =>
3044            {
3045                let arg = self
3046                    .lower_projection_value_expr(&call.args[0], call.span, s, materialize_nodes)
3047                    .unwrap_or_else(|| self.lower_expr(&call.args[0], call.span, s));
3048                Some(s.builder.push_expr(IrExpr::FunctionCall {
3049                    name: "type".into(),
3050                    args: vec![arg],
3051                }))
3052            }
3053            Expr::FunctionCall(call)
3054                if materialize_nodes
3055                    && is_function_named(call, "coalesce")
3056                    && !call.args.is_empty() =>
3057            {
3058                let args = call
3059                    .args
3060                    .iter()
3061                    .map(|arg| self.lower_projection_value_expr(arg, span, s, true))
3062                    .collect::<Option<Vec<_>>>()?;
3063                Some(s.builder.push_expr(IrExpr::FunctionCall {
3064                    name: "coalesce".into(),
3065                    args,
3066                }))
3067            }
3068            Expr::List(graphforge_ast::ListLiteral { elements, .. }) => {
3069                let ids: Vec<ExprId> = elements
3070                    .iter()
3071                    .map(|e| {
3072                        self.lower_projection_value_expr(e, span, s, materialize_nodes)
3073                            .unwrap_or_else(|| self.lower_expr(e, span, s))
3074                    })
3075                    .collect();
3076                Some(s.builder.push_expr(IrExpr::ListLiteral(ids)))
3077            }
3078            _ => None,
3079        }
3080    }
3081
3082    /// Lower a RETURN that contains at least one aggregate into a
3083    /// [`GraphOp::Aggregate`]. Non-aggregate items become group-by keys;
3084    /// aggregate items become [`AggExpr`]s.
3085    fn lower_return_aggregate(
3086        &self,
3087        items: &[ReturnItem],
3088        s: &mut BinderState,
3089    ) -> Vec<(Expr, String, VarId)> {
3090        let mut group_by: Vec<ExprId> = Vec::new();
3091        let mut group_aliases: Vec<Option<String>> = Vec::new();
3092        let mut group_vars: Vec<Option<VarId>> = Vec::new();
3093        let mut aggs: Vec<AggExpr> = Vec::new();
3094        let mut bindings = Vec::with_capacity(items.len());
3095        for (idx, item) in items.iter().enumerate() {
3096            if let Some(func) = agg_func_of(&item.expr) {
3097                let Expr::FunctionCall(call) = &item.expr else {
3098                    unreachable!("agg_func_of only matches FunctionCall");
3099                };
3100                // openCypher names an un-aliased aggregate column by its source
3101                // text (`count(*)`, `min(x)`); fall back to the verbatim `display`
3102                // before the synthetic `agg_N` (#598/#599). Matches the non-
3103                // aggregate naming in `lower_return_items`.
3104                let alias = item
3105                    .alias
3106                    .clone()
3107                    .or_else(|| item.display.clone())
3108                    .unwrap_or_else(|| format!("agg_{idx}"));
3109                let out_var = alloc_anon_var(s);
3110                aggs.push(self.build_agg(call, func, alias.clone(), Some(out_var), s));
3111                bindings.push((item.expr.clone(), alias, out_var));
3112            } else {
3113                // Materialize whole-node / path group keys (`RETURN n, count(*)`)
3114                // the same way terminal RETURN items do — a bare node var rewrites
3115                // to `_node_struct`, not a bare `var_N` (which is not a real column
3116                // and would lower against an unbound reference). Non-node exprs
3117                // (`n.name`) fall through to the same `lower_expr` inside.
3118                group_by.push(self.lower_return_item_expr(&item.expr, item.span, s, true));
3119                // Name the group-key column by the RETURN item's source text
3120                // (or its `AS` alias) so a mixed `RETURN n.name, count(*)` yields
3121                // the `n.name` header openCypher expects (#599). A bare variable
3122                // (`RETURN n, count(*)`) keeps its lowered name — `display` is the
3123                // var name, which is the right column name anyway.
3124                let alias = item
3125                    .alias
3126                    .clone()
3127                    .or_else(|| item.display.clone())
3128                    .unwrap_or_else(|| format!("group_{idx}"));
3129                let out_var = alloc_anon_var(s);
3130                group_aliases.push(Some(alias.clone()));
3131                group_vars.push(Some(out_var));
3132                bindings.push((item.expr.clone(), alias, out_var));
3133            }
3134        }
3135        s.builder.push_op_mut(GraphOp::Aggregate {
3136            group_by,
3137            group_aliases,
3138            group_vars,
3139            aggs,
3140        });
3141        s.vars.clear();
3142        s.node_vars.clear();
3143        s.edge_vars.clear();
3144        s.edge_rel_names.clear();
3145        s.path_vars.clear();
3146        for (_, alias, var) in &bindings {
3147            s.vars.insert(alias.clone(), *var);
3148        }
3149        bindings
3150    }
3151
3152    /// Build one [`AggExpr`] from an aggregate call. `count(*)` has no argument;
3153    /// `count(n)` over a bare variable counts bound rows (equivalent to `count(*)`
3154    /// — a MATCH variable is always bound — and avoids referencing the bare
3155    /// `var_N`, which is not a real column). Distinct aggregates keep the
3156    /// argument so optional/unbound values can be filtered correctly. Distinct
3157    /// aggregate calls are preserved in the IR so lowering can pick the matching
3158    /// DataFusion/Cypher implementation. `out_var`, when set, binds the result
3159    /// column for a following `Project` (#599 nested aggregates).
3160    fn build_agg(
3161        &self,
3162        call: &graphforge_ast::FunctionCall,
3163        func: AggFunc,
3164        alias: String,
3165        out_var: Option<VarId>,
3166        s: &mut BinderState,
3167    ) -> AggExpr {
3168        let bare_var_arg = matches!(call.args.first(), Some(Expr::Var(_)));
3169        let is_percentile = matches!(func, AggFunc::PercentileDisc | AggFunc::PercentileCont);
3170        if is_percentile {
3171            if call.star || call.args.len() != 2 {
3172                s.errors.push(BindError::new(
3173                    BindErrorKind::InvalidArgument,
3174                    call.span,
3175                    "percentile aggregate functions require value and percentile arguments",
3176                ));
3177            }
3178            if call.distinct {
3179                s.errors.push(BindError::new(
3180                    BindErrorKind::InvalidArgument,
3181                    call.span,
3182                    "percentile aggregate functions do not support DISTINCT",
3183                ));
3184            }
3185        }
3186        let arg = if !is_percentile
3187            && (call.star || (func == AggFunc::Count && bare_var_arg && !call.distinct))
3188        {
3189            None
3190        } else {
3191            call.args.first().map(|a| {
3192                if func == AggFunc::Collect {
3193                    self.lower_projection_value_expr(a, call.span, s, true)
3194                        .unwrap_or_else(|| self.lower_expr(a, call.span, s))
3195                } else {
3196                    self.lower_expr(a, call.span, s)
3197                }
3198            })
3199        };
3200        let percentile = if is_percentile {
3201            call.args.get(1).map(|p| self.lower_expr(p, call.span, s))
3202        } else {
3203            None
3204        };
3205        let func = match (call.distinct, func, arg.is_some()) {
3206            (true, AggFunc::Count, true) => AggFunc::CountDistinct,
3207            (true, AggFunc::Sum, true) => AggFunc::SumDistinct,
3208            (true, AggFunc::Avg, true) => AggFunc::AvgDistinct,
3209            (true, AggFunc::Collect, true) => AggFunc::CollectDistinct,
3210            _ => func,
3211        };
3212        AggExpr {
3213            func,
3214            arg,
3215            percentile,
3216            alias,
3217            out_var,
3218        }
3219    }
3220
3221    /// Lower a RETURN where some item NESTS an aggregate inside a larger
3222    /// Rewrite an expression, replacing each aggregate call with a fresh synthetic
3223    /// variable (registered in scope, bound to the aggregate's output column) and
3224    /// recording the [`AggExpr`]. Non-aggregate container nodes are rebuilt with
3225    /// rewritten children; leaves are cloned. Used by
3226    /// [`lower_return_aggregate_arith`]. (#599)
3227    #[allow(clippy::too_many_lines)]
3228    fn rewrite_aggs(&self, expr: &Expr, aggs: &mut Vec<AggExpr>, s: &mut BinderState) -> Expr {
3229        if let Some(func) = agg_func_of(expr) {
3230            let Expr::FunctionCall(call) = expr else {
3231                unreachable!("agg_func_of only matches FunctionCall");
3232            };
3233            let name = format!("__agg_{}", aggs.len());
3234            let out_var = ensure_var_name(&name, s);
3235            let agg = self.build_agg(call, func, name.clone(), Some(out_var), s);
3236            aggs.push(agg);
3237            return Expr::Var(VarRef {
3238                name,
3239                span: call.span,
3240            });
3241        }
3242        match expr {
3243            Expr::BinaryOp(b) => Expr::BinaryOp(graphforge_ast::BinaryOp {
3244                op: b.op,
3245                left: Box::new(self.rewrite_aggs(&b.left, aggs, s)),
3246                right: Box::new(self.rewrite_aggs(&b.right, aggs, s)),
3247                span: b.span,
3248            }),
3249            Expr::UnaryOp(u) => Expr::UnaryOp(graphforge_ast::UnaryOp {
3250                op: u.op,
3251                expr: Box::new(self.rewrite_aggs(&u.expr, aggs, s)),
3252                span: u.span,
3253            }),
3254            Expr::Parenthesized { inner, span } => Expr::Parenthesized {
3255                inner: Box::new(self.rewrite_aggs(inner, aggs, s)),
3256                span: *span,
3257            },
3258            Expr::ListComprehension(lc) => {
3259                Expr::ListComprehension(graphforge_ast::ListComprehension {
3260                    var: lc.var.clone(),
3261                    list: Box::new(self.rewrite_aggs(&lc.list, aggs, s)),
3262                    // Aggregation inside a comprehension body is invalid Cypher. Keep
3263                    // those children intact so lower_expr emits the established error.
3264                    filter: lc.filter.clone(),
3265                    projection: lc.projection.clone(),
3266                    span: lc.span,
3267                })
3268            }
3269            Expr::FunctionCall(c) => Expr::FunctionCall(graphforge_ast::FunctionCall {
3270                name: c.name.clone(),
3271                distinct: c.distinct,
3272                star: c.star,
3273                args: c
3274                    .args
3275                    .iter()
3276                    .map(|a| self.rewrite_aggs(a, aggs, s))
3277                    .collect(),
3278                span: c.span,
3279            }),
3280            Expr::List(l) => Expr::List(graphforge_ast::ListLiteral {
3281                elements: l
3282                    .elements
3283                    .iter()
3284                    .map(|e| self.rewrite_aggs(e, aggs, s))
3285                    .collect(),
3286                span: l.span,
3287            }),
3288            Expr::Map(m) => Expr::Map(graphforge_ast::MapLiteral {
3289                entries: m
3290                    .entries
3291                    .iter()
3292                    .map(|(key, value)| (key.clone(), self.rewrite_aggs(value, aggs, s)))
3293                    .collect(),
3294                key_spans: m.key_spans.clone(),
3295                span: m.span,
3296            }),
3297            Expr::Case(c) => Expr::Case(graphforge_ast::CaseExpr {
3298                subject: c
3299                    .subject
3300                    .as_deref()
3301                    .map(|subject| Box::new(self.rewrite_aggs(subject, aggs, s))),
3302                when_clauses: c
3303                    .when_clauses
3304                    .iter()
3305                    .map(|when| graphforge_ast::WhenClause {
3306                        condition: self.rewrite_aggs(&when.condition, aggs, s),
3307                        result: self.rewrite_aggs(&when.result, aggs, s),
3308                        span: when.span,
3309                    })
3310                    .collect(),
3311                else_expr: c
3312                    .else_expr
3313                    .as_deref()
3314                    .map(|else_expr| Box::new(self.rewrite_aggs(else_expr, aggs, s))),
3315                span: c.span,
3316            }),
3317            Expr::Quantifier(q) => Expr::Quantifier(graphforge_ast::Quantifier {
3318                kind: q.kind,
3319                var: q.var.clone(),
3320                list: Box::new(self.rewrite_aggs(&q.list, aggs, s)),
3321                predicate: Box::new(self.rewrite_aggs(&q.predicate, aggs, s)),
3322                span: q.span,
3323            }),
3324            Expr::PatternComprehension(pc) => {
3325                Expr::PatternComprehension(graphforge_ast::PatternComprehension {
3326                    var: pc.var.clone(),
3327                    pattern: pc.pattern.clone(),
3328                    filter: pc.filter.clone(),
3329                    projection: pc.projection.clone(),
3330                    span: pc.span,
3331                })
3332            }
3333            Expr::ExistentialSubquery(es) => Expr::ExistentialSubquery(es.clone()),
3334            Expr::Property(p) => Expr::Property(graphforge_ast::PropertyAccess {
3335                object: Box::new(self.rewrite_aggs(&p.object, aggs, s)),
3336                key: p.key.clone(),
3337                span: p.span,
3338            }),
3339            Expr::IsNull {
3340                expr,
3341                negated,
3342                span,
3343            } => Expr::IsNull {
3344                expr: Box::new(self.rewrite_aggs(expr, aggs, s)),
3345                negated: *negated,
3346                span: *span,
3347            },
3348            Expr::InList {
3349                expr,
3350                list,
3351                negated,
3352                span,
3353            } => Expr::InList {
3354                expr: Box::new(self.rewrite_aggs(expr, aggs, s)),
3355                list: Box::new(self.rewrite_aggs(list, aggs, s)),
3356                negated: *negated,
3357                span: *span,
3358            },
3359            Expr::StringOp {
3360                expr,
3361                op,
3362                pattern,
3363                span,
3364            } => Expr::StringOp {
3365                expr: Box::new(self.rewrite_aggs(expr, aggs, s)),
3366                op: *op,
3367                pattern: Box::new(self.rewrite_aggs(pattern, aggs, s)),
3368                span: *span,
3369            },
3370            Expr::RegexMatch {
3371                expr,
3372                pattern,
3373                span,
3374            } => Expr::RegexMatch {
3375                expr: Box::new(self.rewrite_aggs(expr, aggs, s)),
3376                pattern: Box::new(self.rewrite_aggs(pattern, aggs, s)),
3377                span: *span,
3378            },
3379            other => other.clone(),
3380        }
3381    }
3382
3383    // -----------------------------------------------------------------------
3384    // Expression lowering
3385    // -----------------------------------------------------------------------
3386
3387    fn typed_uuid_param_in<'a>(&self, expr: &'a Expr) -> Option<&'a str> {
3388        match expr {
3389            Expr::Param(param) if self.typed_uuid_params.contains_key(&param.name) => {
3390                Some(&param.name)
3391            }
3392            Expr::Parenthesized { inner, .. }
3393            | Expr::UnaryOp(graphforge_ast::UnaryOp { expr: inner, .. }) => {
3394                self.typed_uuid_param_in(inner)
3395            }
3396            Expr::List(list) => list
3397                .elements
3398                .iter()
3399                .find_map(|item| self.typed_uuid_param_in(item)),
3400            Expr::Map(map) => map
3401                .entries
3402                .values()
3403                .find_map(|value| self.typed_uuid_param_in(value)),
3404            Expr::FunctionCall(call) => call
3405                .args
3406                .iter()
3407                .find_map(|arg| self.typed_uuid_param_in(arg)),
3408            Expr::BinaryOp(binary) => self
3409                .typed_uuid_param_in(&binary.left)
3410                .or_else(|| self.typed_uuid_param_in(&binary.right)),
3411            Expr::Case(case) => case
3412                .subject
3413                .as_deref()
3414                .and_then(|subject| self.typed_uuid_param_in(subject))
3415                .or_else(|| {
3416                    case.when_clauses.iter().find_map(|when| {
3417                        self.typed_uuid_param_in(&when.condition)
3418                            .or_else(|| self.typed_uuid_param_in(&when.result))
3419                    })
3420                })
3421                .or_else(|| {
3422                    case.else_expr
3423                        .as_deref()
3424                        .and_then(|otherwise| self.typed_uuid_param_in(otherwise))
3425                }),
3426            Expr::ListComprehension(comprehension) => self
3427                .typed_uuid_param_in(&comprehension.list)
3428                .or_else(|| {
3429                    comprehension
3430                        .filter
3431                        .as_deref()
3432                        .and_then(|filter| self.typed_uuid_param_in(filter))
3433                })
3434                .or_else(|| {
3435                    comprehension
3436                        .projection
3437                        .as_deref()
3438                        .and_then(|projection| self.typed_uuid_param_in(projection))
3439                }),
3440            Expr::Quantifier(quantifier) => self
3441                .typed_uuid_param_in(&quantifier.list)
3442                .or_else(|| self.typed_uuid_param_in(&quantifier.predicate)),
3443            Expr::PatternComprehension(comprehension) => comprehension
3444                .filter
3445                .as_deref()
3446                .and_then(|filter| self.typed_uuid_param_in(filter))
3447                .or_else(|| self.typed_uuid_param_in(&comprehension.projection)),
3448            Expr::IsNull { expr, .. } => self.typed_uuid_param_in(expr),
3449            Expr::InList { expr, list, .. } => self
3450                .typed_uuid_param_in(expr)
3451                .or_else(|| self.typed_uuid_param_in(list)),
3452            Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => self
3453                .typed_uuid_param_in(expr)
3454                .or_else(|| self.typed_uuid_param_in(pattern)),
3455            _ => None,
3456        }
3457    }
3458
3459    fn direct_typed_uuid_identity_parameter(
3460        &self,
3461        property_expr: &Expr,
3462        value_expr: &Expr,
3463        s: &BinderState,
3464    ) -> Option<String> {
3465        let mut value_expr = value_expr;
3466        while let Expr::Parenthesized { inner, .. } = value_expr {
3467            value_expr = inner;
3468        }
3469        let Expr::Param(param) = value_expr else {
3470            return None;
3471        };
3472        if self.typed_uuid_params.get(&param.name) != Some(&UuidParamClass::ExactUuid) {
3473            return None;
3474        }
3475        let mut property_expr = property_expr;
3476        while let Expr::Parenthesized { inner, .. } = property_expr {
3477            property_expr = inner;
3478        }
3479        let Expr::Property(PropertyAccess { object, key, .. }) = property_expr else {
3480            return None;
3481        };
3482        let mut object = object.as_ref();
3483        while let Expr::Parenthesized { inner, .. } = object {
3484            object = inner;
3485        }
3486        let actual = match object {
3487            Expr::Var(VarRef { name, .. }) => s
3488                .vars
3489                .get(name)
3490                .and_then(|var| s.var_kinds.get(var))
3491                .copied(),
3492            _ => None,
3493        };
3494        let compatible = matches!(
3495            (key.as_str(), actual),
3496            ("node_uuid", Some(VarKind::Node)) | ("edge_uuid", Some(VarKind::Relationship))
3497        );
3498        compatible.then(|| param.name.clone())
3499    }
3500
3501    fn lower_expr_with_direct_uuid_allowed(
3502        &self,
3503        expr: &Expr,
3504        parent_span: Span,
3505        allowed: Option<&str>,
3506        s: &mut BinderState,
3507    ) -> ExprId {
3508        let mut direct = expr;
3509        while let Expr::Parenthesized { inner, .. } = direct {
3510            direct = inner;
3511        }
3512        if let Expr::Param(param) = direct
3513            && allowed == Some(param.name.as_str())
3514        {
3515            return s.builder.push_expr(IrExpr::Parameter(param.name.clone()));
3516        }
3517        self.lower_expr(expr, parent_span, s)
3518    }
3519
3520    #[allow(clippy::only_used_in_recursion, clippy::too_many_lines)]
3521    fn lower_expr(&self, expr: &Expr, parent_span: Span, s: &mut BinderState) -> ExprId {
3522        match expr {
3523            Expr::Literal(lit) => {
3524                if let Literal::Float(f, span) = lit
3525                    && !f.is_finite()
3526                {
3527                    s.errors.push(BindError::new(
3528                        BindErrorKind::InvalidArgument,
3529                        *span,
3530                        "float literal is outside the supported f64 range",
3531                    ));
3532                }
3533                s.builder.push_expr(IrExpr::Literal(lower_literal(lit)))
3534            }
3535
3536            Expr::Var(VarRef { name, span }) => {
3537                if let Some(&var_id) = s.vars.get(name) {
3538                    s.builder.push_expr(IrExpr::VarRef(var_id))
3539                } else if let Some(binding) = s.path_vars.get(name).cloned() {
3540                    // A bare path value (`RETURN p`): a Struct{nodes,
3541                    // relationships} assembled from the path's constituents.
3542                    Self::path_struct_expr(&binding, s)
3543                } else {
3544                    s.errors.push(BindError::new(
3545                        BindErrorKind::UndeclaredVariable,
3546                        *span,
3547                        format!("variable `{name}` used before it was introduced"),
3548                    ));
3549                    s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
3550                }
3551            }
3552
3553            Expr::Property(graphforge_ast::PropertyAccess { object, key, span }) => {
3554                if matches!(object.as_ref(), Expr::Var(VarRef { name, .. }) if s.path_vars.contains_key(name))
3555                {
3556                    s.errors.push(BindError::new(
3557                        BindErrorKind::InvalidArgument,
3558                        *span,
3559                        "property access is not valid on a path value",
3560                    ));
3561                    return s.builder.push_expr(IrExpr::Literal(IrLiteral::Null));
3562                }
3563                if matches!(key.as_str(), "node_uuid" | "edge_uuid") {
3564                    let expected = if key == "node_uuid" {
3565                        VarKind::Node
3566                    } else {
3567                        VarKind::Relationship
3568                    };
3569                    let actual = match object.as_ref() {
3570                        Expr::Var(VarRef { name, .. }) => s
3571                            .vars
3572                            .get(name)
3573                            .and_then(|var| s.var_kinds.get(var))
3574                            .copied(),
3575                        _ => None,
3576                    };
3577                    if actual != Some(expected) {
3578                        s.errors.push(BindError::new(
3579                            BindErrorKind::InvalidArgument,
3580                            *span,
3581                            format!(
3582                                "structural identity field `{key}` is valid only on {expected}"
3583                            ),
3584                        ));
3585                        return s.builder.push_expr(IrExpr::Literal(IrLiteral::Null));
3586                    }
3587                }
3588                let owner = property_owner_for_expr(object, s);
3589                let base = self.lower_expr(object, *span, s);
3590                let prop = self.resolve_property(key, *span, owner, s);
3591                s.builder.push_expr(IrExpr::PropertyAccess { base, prop })
3592            }
3593
3594            Expr::BinaryOp(graphforge_ast::BinaryOp {
3595                op,
3596                left,
3597                right,
3598                span,
3599            }) => {
3600                let right_allowed = (*op == AstBinOp::Eq)
3601                    .then(|| self.direct_typed_uuid_identity_parameter(left, right, s))
3602                    .flatten();
3603                let left_allowed = (*op == AstBinOp::Eq)
3604                    .then(|| self.direct_typed_uuid_identity_parameter(right, left, s))
3605                    .flatten();
3606                match op {
3607                    AstBinOp::Concat => {
3608                        let a = self.lower_expr_with_direct_uuid_allowed(
3609                            left,
3610                            *span,
3611                            left_allowed.as_deref(),
3612                            s,
3613                        );
3614                        let b = self.lower_expr_with_direct_uuid_allowed(
3615                            right,
3616                            *span,
3617                            right_allowed.as_deref(),
3618                            s,
3619                        );
3620                        s.builder.push_expr(IrExpr::FunctionCall {
3621                            name: "string.concat".into(),
3622                            args: vec![a, b],
3623                        })
3624                    }
3625                    other => {
3626                        let l = self.lower_expr_with_direct_uuid_allowed(
3627                            left,
3628                            *span,
3629                            left_allowed.as_deref(),
3630                            s,
3631                        );
3632                        let r = self.lower_expr_with_direct_uuid_allowed(
3633                            right,
3634                            *span,
3635                            right_allowed.as_deref(),
3636                            s,
3637                        );
3638                        s.builder.push_expr(IrExpr::BinaryOp {
3639                            op: lower_binop(*other),
3640                            left: l,
3641                            right: r,
3642                        })
3643                    }
3644                }
3645            }
3646
3647            Expr::UnaryOp(graphforge_ast::UnaryOp {
3648                op,
3649                expr: inner,
3650                span,
3651            }) => {
3652                if let Some(name) = self.typed_uuid_param_in(inner) {
3653                    s.errors.push(BindError::new(
3654                        BindErrorKind::InvalidArgument,
3655                        *span,
3656                        format!(
3657                            "typed UUID parameter `${name}` is only supported as a direct node_uuid or edge_uuid identity equality predicate"
3658                        ),
3659                    ));
3660                }
3661                let ir_op = match op {
3662                    AstUnOp::Not => UnaryOpKind::Not,
3663                    AstUnOp::Neg => UnaryOpKind::Neg,
3664                };
3665                let e = self.lower_expr(inner, *span, s);
3666                s.builder.push_expr(IrExpr::UnaryOp { op: ir_op, expr: e })
3667            }
3668
3669            Expr::IsNull {
3670                expr: inner,
3671                negated,
3672                span,
3673            } => {
3674                let op = if *negated {
3675                    UnaryOpKind::IsNotNull
3676                } else {
3677                    UnaryOpKind::IsNull
3678                };
3679                let e = self.lower_expr(inner, *span, s);
3680                s.builder.push_expr(IrExpr::UnaryOp { op, expr: e })
3681            }
3682
3683            Expr::InList {
3684                expr: lhs,
3685                list: rhs,
3686                negated,
3687                span,
3688            } => {
3689                let l = self.lower_expr(lhs, *span, s);
3690                let r = self.lower_expr(rhs, *span, s);
3691                let in_id = s.builder.push_expr(IrExpr::BinaryOp {
3692                    op: BinaryOpKind::In,
3693                    left: l,
3694                    right: r,
3695                });
3696                if *negated {
3697                    s.builder.push_expr(IrExpr::UnaryOp {
3698                        op: UnaryOpKind::Not,
3699                        expr: in_id,
3700                    })
3701                } else {
3702                    in_id
3703                }
3704            }
3705
3706            Expr::StringOp {
3707                expr: lhs,
3708                op,
3709                pattern: rhs,
3710                span,
3711            } => {
3712                let ir_op = match op {
3713                    StringOpKind::StartsWith => BinaryOpKind::StartsWith,
3714                    StringOpKind::EndsWith => BinaryOpKind::EndsWith,
3715                    StringOpKind::Contains => BinaryOpKind::Contains,
3716                };
3717                let l = self.lower_expr(lhs, *span, s);
3718                let r = self.lower_expr(rhs, *span, s);
3719                s.builder.push_expr(IrExpr::BinaryOp {
3720                    op: ir_op,
3721                    left: l,
3722                    right: r,
3723                })
3724            }
3725
3726            Expr::RegexMatch {
3727                expr: lhs,
3728                pattern: rhs,
3729                span,
3730            } => {
3731                let l = self.lower_expr(lhs, *span, s);
3732                let r = self.lower_expr(rhs, *span, s);
3733                s.builder.push_expr(IrExpr::BinaryOp {
3734                    op: BinaryOpKind::RegexMatch,
3735                    left: l,
3736                    right: r,
3737                })
3738            }
3739
3740            Expr::Parenthesized { inner, .. } => self.lower_expr(inner, parent_span, s),
3741
3742            Expr::FunctionCall(call) => {
3743                if !is_known_cypher_function(call) {
3744                    s.errors.push(BindError::new(
3745                        BindErrorKind::InvalidArgument,
3746                        call.span,
3747                        format!("unknown function `{}`", call.name.join(".")),
3748                    ));
3749                    s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
3750                } else if Self::invalid_direct_graph_function_argument(call, s) {
3751                    s.errors.push(BindError::new(
3752                        BindErrorKind::InvalidArgument,
3753                        call.span,
3754                        format!(
3755                            "{}() does not accept this graph value type",
3756                            call.name.join(".")
3757                        ),
3758                    ));
3759                    s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
3760                } else if Self::is_size_of_path_variable(call, s) {
3761                    s.errors.push(BindError::new(
3762                        BindErrorKind::InvalidArgument,
3763                        call.span,
3764                        "size() is not valid for paths; use length(path) instead",
3765                    ));
3766                    s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
3767                } else if is_function_named(call, "type") && call.args.len() == 1 {
3768                    let arg = match &call.args[0] {
3769                        Expr::Var(VarRef { name, .. }) => {
3770                            let edge_var = s
3771                                .vars
3772                                .get(name)
3773                                .copied()
3774                                .filter(|var| s.edge_rel_names.contains_key(var));
3775                            if let Some(var) = edge_var {
3776                                Self::relationship_struct_expr(var, s)
3777                            } else {
3778                                self.lower_expr(&call.args[0], parent_span, s)
3779                            }
3780                        }
3781                        other => self.lower_expr(other, parent_span, s),
3782                    };
3783                    s.builder.push_expr(IrExpr::FunctionCall {
3784                        name: "type".into(),
3785                        args: vec![arg],
3786                    })
3787                } else if let Some(id) = Self::lower_path_function(call, s) {
3788                    id
3789                } else if let Some(node_var) = Self::resolve_endpoint_node(call, s) {
3790                    // `startNode(r)` / `endNode(r)` over a matched relationship
3791                    // is the src / dst node var (#753). As a bare value it is a
3792                    // node reference; property access (`startNode(r).name`)
3793                    // resolves against that var's columns. A terminal RETURN
3794                    // upgrades it to a whole node value in `lower_return_item_expr`.
3795                    s.builder.push_expr(IrExpr::VarRef(node_var))
3796                } else {
3797                    let fn_name = call.name.join(".");
3798                    let ir_args: Vec<ExprId> = call
3799                        .args
3800                        .iter()
3801                        .map(|a| self.lower_expr(a, parent_span, s))
3802                        .collect();
3803                    s.builder.push_expr(IrExpr::FunctionCall {
3804                        name: fn_name,
3805                        args: ir_args,
3806                    })
3807                }
3808            }
3809
3810            Expr::Param(graphforge_ast::ParamRef { name, span }) => {
3811                if self.typed_uuid_params.contains_key(name) {
3812                    s.errors.push(BindError::new(
3813                        BindErrorKind::InvalidArgument,
3814                        *span,
3815                        format!(
3816                            "typed UUID parameter `${name}` is only supported as a direct node_uuid or edge_uuid identity equality predicate"
3817                        ),
3818                    ));
3819                }
3820                s.builder.push_expr(IrExpr::Parameter(name.clone()))
3821            }
3822
3823            Expr::Case(CaseExpr {
3824                subject,
3825                when_clauses,
3826                else_expr,
3827                ..
3828            }) => {
3829                let operand = subject
3830                    .as_deref()
3831                    .map(|e| self.lower_expr(e, parent_span, s));
3832                let arms: Vec<CaseArm> = when_clauses
3833                    .iter()
3834                    .map(|w| {
3835                        let when = self.lower_expr(&w.condition, w.span, s);
3836                        let when = operand.map_or(when, |subject| {
3837                            s.builder.push_expr(IrExpr::BinaryOp {
3838                                op: BinaryOpKind::Eq,
3839                                left: subject,
3840                                right: when,
3841                            })
3842                        });
3843                        CaseArm {
3844                            when,
3845                            then: self.lower_expr(&w.result, w.span, s),
3846                        }
3847                    })
3848                    .collect();
3849                let else_id = else_expr
3850                    .as_deref()
3851                    .map(|e| self.lower_expr(e, parent_span, s));
3852                s.builder.push_expr(IrExpr::Case {
3853                    operand: None,
3854                    arms,
3855                    else_expr: else_id,
3856                })
3857            }
3858
3859            Expr::List(graphforge_ast::ListLiteral { elements, .. }) => {
3860                let ids: Vec<ExprId> = elements
3861                    .iter()
3862                    .map(|e| self.lower_expr(e, parent_span, s))
3863                    .collect();
3864                s.builder.push_expr(IrExpr::ListLiteral(ids))
3865            }
3866
3867            Expr::Map(MapLiteral { entries, .. }) => {
3868                let mut pairs: Vec<(&String, &Expr)> = entries.iter().collect();
3869                pairs.sort_by_key(|(k, _)| k.as_str());
3870                let ids: Vec<(String, ExprId)> = pairs
3871                    .into_iter()
3872                    .map(|(k, v)| (k.clone(), self.lower_expr(v, parent_span, s)))
3873                    .collect();
3874                s.builder.push_expr(IrExpr::MapLiteral(ids))
3875            }
3876
3877            // `[var IN list WHERE filter | projection]` (#955): the list is
3878            // lowered with the loop var OUT of scope; the loop var is then bound
3879            // (shadowing) only while lowering the filter + projection, and
3880            // restored after — mirrors `Quantifier` below.
3881            Expr::ListComprehension(lc) => {
3882                if lc.filter.as_deref().is_some_and(expr_contains_aggregate)
3883                    || lc
3884                        .projection
3885                        .as_deref()
3886                        .is_some_and(expr_contains_aggregate)
3887                {
3888                    s.errors.push(BindError::new(
3889                        BindErrorKind::InvalidArgument,
3890                        lc.span,
3891                        "an aggregate function may not be used inside a list \
3892                         comprehension filter or projection",
3893                    ));
3894                }
3895                let list = self.lower_expr(&lc.list, parent_span, s);
3896                let has_nested_pattern = lc
3897                    .filter
3898                    .as_deref()
3899                    .is_some_and(expr_contains_pattern_comprehension)
3900                    || lc
3901                        .projection
3902                        .as_deref()
3903                        .is_some_and(expr_contains_pattern_comprehension);
3904                let prev = s.vars.get(&lc.var).copied();
3905                let loop_var = VarId(s.next_var);
3906                s.next_var += 1;
3907                s.vars.insert(lc.var.clone(), loop_var);
3908                let previous_node = s.node_vars.get(&loop_var).cloned();
3909                let previous_kind = s.var_kinds.get(&loop_var).copied();
3910                if has_nested_pattern {
3911                    // The currently supported graph-valued source is `nodes(path)`,
3912                    // whose elements are whole node structs at execution time.
3913                    s.node_vars.insert(loop_var, None);
3914                    s.var_kinds.insert(loop_var, VarKind::Node);
3915                }
3916                let previous_capture = if has_nested_pattern {
3917                    s.captured_pattern_comprehensions.replace(Vec::new())
3918                } else {
3919                    None
3920                };
3921                let filter = lc
3922                    .filter
3923                    .as_ref()
3924                    .map(|f| self.lower_expr(f, parent_span, s));
3925                let projection = lc
3926                    .projection
3927                    .as_ref()
3928                    .map(|p| self.lower_expr(p, parent_span, s));
3929                let captured = if has_nested_pattern {
3930                    let captured = s.captured_pattern_comprehensions.take().unwrap_or_default();
3931                    s.captured_pattern_comprehensions = previous_capture;
3932                    captured
3933                } else {
3934                    Vec::new()
3935                };
3936                match prev {
3937                    Some(v) => {
3938                        s.vars.insert(lc.var.clone(), v);
3939                    }
3940                    None => {
3941                        s.vars.remove(&lc.var);
3942                    }
3943                }
3944                match previous_node {
3945                    Some(shape) => {
3946                        s.node_vars.insert(loop_var, shape);
3947                    }
3948                    None => {
3949                        s.node_vars.remove(&loop_var);
3950                    }
3951                }
3952                match previous_kind {
3953                    Some(kind) => {
3954                        s.var_kinds.insert(loop_var, kind);
3955                    }
3956                    None => {
3957                        s.var_kinds.remove(&loop_var);
3958                    }
3959                }
3960                if has_nested_pattern {
3961                    let [(child, pattern_output)] = captured.try_into().unwrap_or_else(|captured: Vec<_>| {
3962                        s.errors.push(BindError::new(
3963                            BindErrorKind::InvalidArgument,
3964                            lc.span,
3965                            format!(
3966                                "a list comprehension currently supports exactly one nested pattern comprehension, found {}",
3967                                captured.len()
3968                            ),
3969                        ));
3970                        [(Box::new(GraphPlan::builder("openCypher").build()), loop_var)]
3971                    });
3972                    let output = VarId(s.next_var);
3973                    s.next_var += 1;
3974                    s.builder
3975                        .push_op_mut(GraphOp::ListElementPatternComprehension {
3976                            list_expr: list,
3977                            loop_var,
3978                            child,
3979                            pattern_output,
3980                            filter,
3981                            projection,
3982                            output,
3983                        });
3984                    return s.builder.push_expr(IrExpr::VarRef(output));
3985                }
3986                s.builder.push_expr(IrExpr::ListComprehension {
3987                    loop_var,
3988                    list,
3989                    filter,
3990                    projection,
3991                })
3992            }
3993
3994            // `all/any/none/single(var IN list WHERE pred)` (#955): the list is
3995            // lowered with the loop var OUT of scope; the loop var is then bound
3996            // (shadowing) only while lowering the predicate, and restored after.
3997            Expr::Quantifier(q) => {
3998                let list = self.lower_expr(&q.list, parent_span, s);
3999                let prev = s.vars.get(&q.var).copied();
4000                let loop_var = VarId(s.next_var);
4001                s.next_var += 1;
4002                s.vars.insert(q.var.clone(), loop_var);
4003                let predicate = self.lower_expr(&q.predicate, parent_span, s);
4004                match prev {
4005                    Some(v) => {
4006                        s.vars.insert(q.var.clone(), v);
4007                    }
4008                    None => {
4009                        s.vars.remove(&q.var);
4010                    }
4011                }
4012                s.builder.push_expr(IrExpr::Quantifier {
4013                    kind: q.kind,
4014                    loop_var,
4015                    list,
4016                    predicate,
4017                })
4018            }
4019
4020            Expr::PatternPredicate(pp) => {
4021                s.errors.push(BindError::new(
4022                    BindErrorKind::InvalidArgument,
4023                    pp.span,
4024                    "pattern predicates are invalid outside WHERE",
4025                ));
4026                s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
4027            }
4028
4029            Expr::ExistentialSubquery(es) => {
4030                s.errors.push(BindError::new(
4031                    BindErrorKind::InvalidArgument,
4032                    es.span,
4033                    "existential subqueries are currently valid only as WHERE predicates",
4034                ));
4035                s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
4036            }
4037
4038            Expr::PatternComprehension(pc) => self.lower_pattern_comprehension(pc, s),
4039
4040            Expr::LabelPredicate(lp) => self.lower_label_predicate(lp, s),
4041
4042            // Expr is #[non_exhaustive] — catch future variants gracefully.
4043            _ => s.builder.push_expr(IrExpr::Literal(IrLiteral::Null)),
4044        }
4045    }
4046
4047    fn lower_label_predicate(&self, lp: &LabelPredicate, s: &mut BinderState) -> ExprId {
4048        let Some(&var_id) = s.vars.get(&lp.var) else {
4049            s.errors.push(BindError::new(
4050                BindErrorKind::UndeclaredVariable,
4051                lp.span,
4052                format!("variable `{}` used before it was introduced", lp.var),
4053            ));
4054            return s.builder.push_expr(IrExpr::Literal(IrLiteral::Null));
4055        };
4056
4057        let kind = s.var_kinds.get(&var_id).copied().or_else(|| {
4058            if s.node_vars.contains_key(&var_id) {
4059                Some(VarKind::Node)
4060            } else if s.edge_rel_names.contains_key(&var_id) {
4061                Some(VarKind::Relationship)
4062            } else {
4063                None
4064            }
4065        });
4066
4067        match kind {
4068            Some(VarKind::Node) => self.lower_node_label_predicate(var_id, &lp.labels, lp.span, s),
4069            Some(VarKind::Relationship) => {
4070                self.lower_relationship_type_predicate(var_id, &lp.labels, lp.span, s)
4071            }
4072            Some(VarKind::Unknown) | None => {
4073                s.errors.push(BindError::new(
4074                    BindErrorKind::InvalidArgument,
4075                    lp.span,
4076                    format!(
4077                        "`{}:{}` requires a node or relationship variable",
4078                        lp.var,
4079                        lp.labels.join(":")
4080                    ),
4081                ));
4082                s.builder.push_expr(IrExpr::Literal(IrLiteral::Null))
4083            }
4084        }
4085    }
4086
4087    fn lower_node_label_predicate(
4088        &self,
4089        var_id: VarId,
4090        labels: &[String],
4091        span: Span,
4092        s: &mut BinderState,
4093    ) -> ExprId {
4094        let var_expr = s.builder.push_expr(IrExpr::VarRef(var_id));
4095        let node_labels_expr = s.builder.push_expr(IrExpr::FunctionCall {
4096            name: "labels".into(),
4097            args: vec![var_expr],
4098        });
4099        let mut predicates = Vec::with_capacity(labels.len());
4100        for label in labels {
4101            self.resolve_label(label, span, s);
4102            let expected_label_expr = s
4103                .builder
4104                .push_expr(IrExpr::Literal(IrLiteral::Str(label.clone())));
4105            predicates.push(s.builder.push_expr(IrExpr::BinaryOp {
4106                op: BinaryOpKind::In,
4107                left: expected_label_expr,
4108                right: node_labels_expr,
4109            }));
4110        }
4111        push_conjunction(predicates, s)
4112    }
4113
4114    fn lower_relationship_type_predicate(
4115        &self,
4116        var_id: VarId,
4117        types: &[String],
4118        span: Span,
4119        s: &mut BinderState,
4120    ) -> ExprId {
4121        let rel_expr = if s.edge_rel_names.contains_key(&var_id) {
4122            Self::relationship_struct_expr(var_id, s)
4123        } else {
4124            s.builder.push_expr(IrExpr::VarRef(var_id))
4125        };
4126        let type_expr = s.builder.push_expr(IrExpr::FunctionCall {
4127            name: "type".into(),
4128            args: vec![rel_expr],
4129        });
4130        let mut predicates = Vec::with_capacity(types.len());
4131        for rel_type in types {
4132            self.resolve_relation_type(rel_type, span, s);
4133            let type_lit = s
4134                .builder
4135                .push_expr(IrExpr::Literal(IrLiteral::Str(rel_type.clone())));
4136            predicates.push(s.builder.push_expr(IrExpr::BinaryOp {
4137                op: BinaryOpKind::Eq,
4138                left: type_expr,
4139                right: type_lit,
4140            }));
4141        }
4142        let mut predicates = predicates.into_iter();
4143        let Some(mut predicate) = predicates.next() else {
4144            return s.builder.push_expr(IrExpr::Literal(IrLiteral::Bool(false)));
4145        };
4146        for next in predicates {
4147            predicate = s.builder.push_expr(IrExpr::BinaryOp {
4148                op: BinaryOpKind::Or,
4149                left: predicate,
4150                right: next,
4151            });
4152        }
4153        predicate
4154    }
4155
4156    /// Rewrite `nodes(p)` / `relationships(p)` / `length(p)` over a named path
4157    /// variable onto the path's constituent variables (#754).
4158    ///
4159    /// Returns `None` when the call is not a path function — including these
4160    /// names over a non-path argument (`length(r)` on an edge list keeps its
4161    /// generic lowering) — so the caller falls through unchanged.
4162    fn lower_path_function(call: &FunctionCall, s: &mut BinderState) -> Option<ExprId> {
4163        let [fn_name] = call.name.as_slice() else {
4164            return None;
4165        };
4166        let fn_name = fn_name.to_ascii_lowercase();
4167        if !matches!(fn_name.as_str(), "nodes" | "relationships" | "length") {
4168            return None;
4169        }
4170        let [Expr::Var(VarRef { name, .. })] = call.args.as_slice() else {
4171            return None;
4172        };
4173        let binding = s.path_vars.get(name)?.clone();
4174        let id = match fn_name.as_str() {
4175            "nodes" => Self::path_nodes_expr(&binding, s),
4176            "relationships" => Self::path_rels_expr(&binding, s),
4177            "length" => Self::path_length_expr(&binding, s),
4178            _ => unreachable!("guarded by the name match above"),
4179        };
4180        Some(id)
4181    }
4182
4183    fn invalid_direct_graph_function_argument(call: &FunctionCall, s: &BinderState) -> bool {
4184        let [name] = call.name.as_slice() else {
4185            return false;
4186        };
4187        let [Expr::Var(VarRef { name: var_name, .. })] = call.args.as_slice() else {
4188            return false;
4189        };
4190        let function = name.to_ascii_lowercase();
4191        if s.path_vars.contains_key(var_name) {
4192            return matches!(function.as_str(), "labels" | "type");
4193        }
4194        let Some(var) = s.vars.get(var_name) else {
4195            return false;
4196        };
4197        let is_node = s.node_vars.contains_key(var);
4198        let is_relationship = s.edge_rel_names.contains_key(var);
4199        matches!(function.as_str(), "type" | "length") && is_node
4200            || matches!(function.as_str(), "labels" | "length") && is_relationship
4201    }
4202
4203    fn is_size_of_path_variable(call: &FunctionCall, s: &BinderState) -> bool {
4204        let [fn_name] = call.name.as_slice() else {
4205            return false;
4206        };
4207        if !fn_name.eq_ignore_ascii_case("size") {
4208            return false;
4209        }
4210        let [Expr::Var(VarRef { name, .. })] = call.args.as_slice() else {
4211            return false;
4212        };
4213        s.path_vars.contains_key(name)
4214    }
4215
4216    /// Resolve `startNode(r)` / `endNode(r)` over a fixed-hop matched
4217    /// relationship to the relationship's src / dst node variable (#753).
4218    ///
4219    /// Returns `None` for anything else — a non-matching name, a non-variable
4220    /// argument, an unbound name, or an edge var that is not a recorded
4221    /// fixed-hop endpoint (variable-length edges bind to a list, not one
4222    /// relationship) — so the caller falls through to generic lowering, where
4223    /// the function name surfaces as the usual `UnknownFunction` error.
4224    fn resolve_endpoint_node(call: &FunctionCall, s: &BinderState) -> Option<VarId> {
4225        let [fn_name] = call.name.as_slice() else {
4226            return None;
4227        };
4228        let start = match fn_name.to_ascii_lowercase().as_str() {
4229            "startnode" => true,
4230            "endnode" => false,
4231            _ => return None,
4232        };
4233        let [Expr::Var(VarRef { name, .. })] = call.args.as_slice() else {
4234            return None;
4235        };
4236        let edge_var = s.vars.get(name)?;
4237        let (src, dst) = s.edge_vars.get(edge_var)?;
4238        Some(if start { *src } else { *dst })
4239    }
4240
4241    /// Build a `_node_struct` call that materializes the given node var as a
4242    /// whole node value — identity + labels + properties (#785). Shared by a
4243    /// bare `RETURN n` and by `RETURN startNode(r)` / `endNode(r)` (#753).
4244    fn node_struct_expr(var: VarId, s: &mut BinderState) -> ExprId {
4245        // The pattern's label name, passed through because the lowerer's
4246        // ontology map is empty in exploratory mode; absent for an unlabelled
4247        // match (one arg).
4248        let label_opt = s.node_vars.get(&var).cloned().flatten();
4249        let var_ref = s.builder.push_expr(IrExpr::VarRef(var));
4250        let mut args = vec![var_ref];
4251        if let Some(label) = label_opt {
4252            let lit = s.builder.push_expr(IrExpr::Literal(IrLiteral::Str(label)));
4253            args.push(lit);
4254        }
4255        s.builder.push_expr(IrExpr::FunctionCall {
4256            name: "_node_struct".to_string(),
4257            args,
4258        })
4259    }
4260
4261    fn relationship_struct_expr(var: VarId, s: &mut BinderState) -> ExprId {
4262        let rel_name = s.edge_rel_names.get(&var).cloned().flatten();
4263        let edge = s.builder.push_expr(IrExpr::VarRef(var));
4264        let rel_name = s.builder.push_expr(IrExpr::Literal(match rel_name {
4265            Some(name) => IrLiteral::Str(name),
4266            None => IrLiteral::Null,
4267        }));
4268        s.builder.push_expr(IrExpr::FunctionCall {
4269            name: "_rel_struct".into(),
4270            args: vec![edge, rel_name],
4271        })
4272    }
4273
4274    fn relationship_struct_list_expr(var: VarId, s: &mut BinderState) -> ExprId {
4275        let edge = s.builder.push_expr(IrExpr::VarRef(var));
4276        let rel_name = s.builder.push_expr(IrExpr::Literal(
4277            match s.edge_rel_names.get(&var).cloned().flatten() {
4278                Some(name) => IrLiteral::Str(name),
4279                None => IrLiteral::Null,
4280            },
4281        ));
4282        s.builder.push_expr(IrExpr::FunctionCall {
4283            name: "_rel_struct_list".into(),
4284            args: vec![edge, rel_name],
4285        })
4286    }
4287
4288    /// IR for `nodes(p)`: the traversal node sequence as a
4289    /// `List<Struct{node_uuid}>` value.
4290    ///
4291    /// Variable-length: recovered at runtime by walking the edge list from the
4292    /// start node (`_path_nodes`, graphforge-rel). Fixed single hop: the two endpoint
4293    /// node vars directly (`_node_struct_list`) — traversal order comes from
4294    /// the binder's pattern walk, so `(a)<-[r]-(b)` yields `[a, b]` regardless
4295    /// of storage orientation.
4296    fn path_nodes_expr(binding: &PathBinding, s: &mut BinderState) -> ExprId {
4297        if binding.segments.is_empty() {
4298            let node = Self::node_struct_expr(binding.nodes[0], s);
4299            return s.builder.push_expr(IrExpr::ListLiteral(vec![node]));
4300        }
4301        if binding.segments.len() > 1 {
4302            let mut combined = None;
4303            for (index, segment) in binding.segments.iter().enumerate() {
4304                let part = PathBinding {
4305                    nodes: binding.nodes[index..=index + 1].to_vec(),
4306                    segments: vec![segment.clone()],
4307                };
4308                let mut nodes = Self::path_nodes_expr(&part, s);
4309                if index > 0 {
4310                    nodes = s.builder.push_expr(IrExpr::FunctionCall {
4311                        name: "tail".into(),
4312                        args: vec![nodes],
4313                    });
4314                }
4315                combined = Some(match combined {
4316                    None => nodes,
4317                    Some(left) => s.builder.push_expr(IrExpr::BinaryOp {
4318                        op: BinaryOpKind::Add,
4319                        left,
4320                        right: nodes,
4321                    }),
4322                });
4323            }
4324            return combined.expect("multi-segment path has node parts");
4325        }
4326        let seg = &binding.segments[0];
4327        if seg.var_len {
4328            let start = s.builder.push_expr(IrExpr::VarRef(binding.nodes[0]));
4329            let rels = s.builder.push_expr(IrExpr::VarRef(seg.edge));
4330            s.builder.push_expr(IrExpr::FunctionCall {
4331                name: "_path_nodes".into(),
4332                args: vec![start, rels],
4333            })
4334        } else {
4335            // The trailing edge VarRef is the null gate: an unmatched
4336            // OPTIONAL MATCH row's path is Cypher null, and graphforge-rel keys that
4337            // on the edge's `edge_uuid` being null.
4338            let a = s.builder.push_expr(IrExpr::VarRef(binding.nodes[0]));
4339            let b = s.builder.push_expr(IrExpr::VarRef(binding.nodes[1]));
4340            let edge = s.builder.push_expr(IrExpr::VarRef(seg.edge));
4341            s.builder.push_expr(IrExpr::FunctionCall {
4342                name: "_node_struct_list".into(),
4343                args: vec![a, b, edge],
4344            })
4345        }
4346    }
4347
4348    /// IR for `relationships(p)`: the relationship sequence.
4349    ///
4350    /// Variable-length: the #709 relationship-list column verbatim. Fixed
4351    /// single hop: a one-element list built from the edge var's scalar columns
4352    /// (`_rel_struct_list`), with the bind-time relation name as `rel_type` —
4353    /// topology fields only, no edge properties (a documented gap vs the
4354    /// var-length list; see #754 follow-ups).
4355    fn path_rels_expr(binding: &PathBinding, s: &mut BinderState) -> ExprId {
4356        if binding.segments.is_empty() {
4357            return s.builder.push_expr(IrExpr::ListLiteral(Vec::new()));
4358        }
4359        if binding.segments.len() > 1 {
4360            let mut combined = None;
4361            for (index, segment) in binding.segments.iter().enumerate() {
4362                let part = PathBinding {
4363                    nodes: binding.nodes[index..=index + 1].to_vec(),
4364                    segments: vec![segment.clone()],
4365                };
4366                let rels = Self::path_rels_expr(&part, s);
4367                combined = Some(match combined {
4368                    None => rels,
4369                    Some(left) => s.builder.push_expr(IrExpr::BinaryOp {
4370                        op: BinaryOpKind::Add,
4371                        left,
4372                        right: rels,
4373                    }),
4374                });
4375            }
4376            return combined.expect("multi-segment path has relationship parts");
4377        }
4378        let seg = &binding.segments[0];
4379        if seg.var_len {
4380            s.builder.push_expr(IrExpr::VarRef(seg.edge))
4381        } else {
4382            let edge = s.builder.push_expr(IrExpr::VarRef(seg.edge));
4383            let rel_name = s.builder.push_expr(IrExpr::Literal(match &seg.rel_name {
4384                Some(n) => IrLiteral::Str(n.clone()),
4385                None => IrLiteral::Null,
4386            }));
4387            s.builder.push_expr(IrExpr::FunctionCall {
4388                name: "_rel_struct_list".into(),
4389                args: vec![edge, rel_name],
4390            })
4391        }
4392    }
4393
4394    /// IR for `length(p)`: the relationship count (openCypher path length).
4395    ///
4396    /// Variable-length: the edge list's element count (0-hop → 0). Fixed
4397    /// single hop: the constant 1 when the hop matched, null otherwise
4398    /// (`_path_fixed_length` over the edge VarRef gate; UInt64 so both forms
4399    /// agree on the output type).
4400    fn path_length_expr(binding: &PathBinding, s: &mut BinderState) -> ExprId {
4401        if binding.segments.is_empty() {
4402            return s.builder.push_expr(IrExpr::Literal(IrLiteral::Int(0)));
4403        }
4404        if binding.segments.len() > 1 {
4405            let mut total = None;
4406            for (index, segment) in binding.segments.iter().enumerate() {
4407                let part = PathBinding {
4408                    nodes: binding.nodes[index..=index + 1].to_vec(),
4409                    segments: vec![segment.clone()],
4410                };
4411                let length = Self::path_length_expr(&part, s);
4412                total = Some(match total {
4413                    None => length,
4414                    Some(left) => s.builder.push_expr(IrExpr::BinaryOp {
4415                        op: BinaryOpKind::Add,
4416                        left,
4417                        right: length,
4418                    }),
4419                });
4420            }
4421            return total.expect("multi-segment path has lengths");
4422        }
4423        let seg = &binding.segments[0];
4424        let rels = s.builder.push_expr(IrExpr::VarRef(seg.edge));
4425        if seg.var_len {
4426            s.builder.push_expr(IrExpr::FunctionCall {
4427                name: "length".into(),
4428                args: vec![rels],
4429            })
4430        } else {
4431            s.builder.push_expr(IrExpr::FunctionCall {
4432                name: "_path_fixed_length".into(),
4433                args: vec![rels],
4434            })
4435        }
4436    }
4437
4438    /// IR for a bare path value (`RETURN p`): a
4439    /// `Struct{nodes, relationships}` assembled from the same expressions the
4440    /// path functions use (`_path_struct` → `named_struct` in graphforge-rel).
4441    fn path_struct_expr(binding: &PathBinding, s: &mut BinderState) -> ExprId {
4442        let nodes = Self::path_nodes_expr(binding, s);
4443        let rels = Self::path_rels_expr(binding, s);
4444        s.builder.push_expr(IrExpr::FunctionCall {
4445            name: "_path_struct".into(),
4446            args: vec![nodes, rels],
4447        })
4448    }
4449
4450    // -----------------------------------------------------------------------
4451    // Type resolution
4452    // -----------------------------------------------------------------------
4453
4454    fn resolve_label(&self, name: &str, span: Span, s: &mut BinderState) -> TypeId {
4455        if let Some(handle) = &self.ontology
4456            && let Some(id) = handle.entity_type_id(name)
4457        {
4458            return id;
4459        }
4460        match self.mode {
4461            OntologyMode::Strict => {
4462                s.errors.push(BindError::new(
4463                    BindErrorKind::UnknownLabel,
4464                    span,
4465                    format!("unknown label `{name}` (strict mode)"),
4466                ));
4467                TypeId(u32::MAX)
4468            }
4469            OntologyMode::Advisory => {
4470                s.warnings.push(BindError::new(
4471                    BindErrorKind::UnknownLabel,
4472                    span,
4473                    format!("unknown label `{name}` — using runtime catalog"),
4474                ));
4475                TypeId(self.catalog.lock().unwrap().intern_label(name).0)
4476            }
4477            OntologyMode::Exploratory => TypeId(self.catalog.lock().unwrap().intern_label(name).0),
4478        }
4479    }
4480
4481    fn resolve_relation_type(&self, name: &str, span: Span, s: &mut BinderState) -> TypeId {
4482        if let Some(handle) = &self.ontology
4483            && let Some(id) = handle.relation_type_id(name)
4484        {
4485            return id;
4486        }
4487        match self.mode {
4488            OntologyMode::Strict => {
4489                s.errors.push(BindError::new(
4490                    BindErrorKind::UnknownRelationType,
4491                    span,
4492                    format!("unknown relation type `{name}` (strict mode)"),
4493                ));
4494                TypeId(u32::MAX)
4495            }
4496            OntologyMode::Advisory => {
4497                s.warnings.push(BindError::new(
4498                    BindErrorKind::UnknownRelationType,
4499                    span,
4500                    format!("unknown relation type `{name}` — using runtime catalog"),
4501                ));
4502                crate::runtime_relation_type_id(
4503                    self.catalog.lock().unwrap().intern_relation_type(name),
4504                )
4505            }
4506            OntologyMode::Exploratory => crate::runtime_relation_type_id(
4507                self.catalog.lock().unwrap().intern_relation_type(name),
4508            ),
4509        }
4510    }
4511
4512    fn resolve_property(
4513        &self,
4514        name: &str,
4515        span: Span,
4516        owner: BoundPropertyOwner,
4517        s: &mut BinderState,
4518    ) -> PropId {
4519        // Durable graph identity columns are structural fields supplied by the
4520        // engine, not user-defined ontology properties. They remain readable
4521        // in strict mode so internal and public graph projections can address
4522        // persisted entities without weakening ontology validation.
4523        if matches!(name, "node_uuid" | "edge_uuid") {
4524            return PropId(self.catalog.lock().unwrap().intern_property(name, None).0);
4525        }
4526        match self.mode {
4527            OntologyMode::Strict => self.resolve_strict_property(name, span, owner, s),
4528            OntologyMode::Advisory => {
4529                s.warnings.push(BindError::new(
4530                    BindErrorKind::UnknownProperty,
4531                    span,
4532                    format!("unknown property `{name}` — using runtime catalog"),
4533                ));
4534                PropId(self.catalog.lock().unwrap().intern_property(name, None).0)
4535            }
4536            OntologyMode::Exploratory => {
4537                PropId(self.catalog.lock().unwrap().intern_property(name, None).0)
4538            }
4539        }
4540    }
4541
4542    fn resolve_strict_property(
4543        &self,
4544        name: &str,
4545        span: Span,
4546        owner: BoundPropertyOwner,
4547        s: &mut BinderState,
4548    ) -> PropId {
4549        if owner == BoundPropertyOwner::Value {
4550            return PropId(self.catalog.lock().unwrap().intern_property(name, None).0);
4551        }
4552        let Some(handle) = &self.ontology else {
4553            s.errors.push(BindError::new(
4554                BindErrorKind::UnknownProperty,
4555                span,
4556                format!("unknown property `{name}` (strict mode has no ontology)"),
4557            ));
4558            return PropId(u32::MAX);
4559        };
4560
4561        let (declarations, description, runtime_owner, entity_owner) = match owner {
4562            BoundPropertyOwner::Entity(Some(owner)) => (
4563                handle
4564                    .entity_type_id(&owner)
4565                    .map(|id| handle.entity_property_declarations(id, name))
4566                    .unwrap_or_default(),
4567                format!("entity `{owner}`"),
4568                Some(owner),
4569                true,
4570            ),
4571            BoundPropertyOwner::Entity(None) => (
4572                handle.all_entity_property_declarations(name),
4573                "unlabeled entity".to_owned(),
4574                None,
4575                true,
4576            ),
4577            BoundPropertyOwner::Relationship(Some(owner)) => (
4578                handle
4579                    .relation_type_id(&owner)
4580                    .map(|id| handle.relation_property_declarations(id, name))
4581                    .unwrap_or_default(),
4582                format!("relationship `{owner}`"),
4583                Some(owner),
4584                false,
4585            ),
4586            BoundPropertyOwner::Relationship(None) => (
4587                handle.all_relation_property_declarations(name),
4588                "untyped relationship".to_owned(),
4589                None,
4590                false,
4591            ),
4592            BoundPropertyOwner::Value => unreachable!("value properties returned above"),
4593        };
4594
4595        if declarations.len() == 1 {
4596            return PropId(
4597                self.catalog
4598                    .lock()
4599                    .unwrap()
4600                    .intern_property(name, runtime_owner.as_deref())
4601                    .0,
4602            );
4603        }
4604        let (kind, message) = if declarations.is_empty() {
4605            (
4606                BindErrorKind::UnknownProperty,
4607                format!("property `{name}` is not declared for {description} (strict mode)"),
4608            )
4609        } else {
4610            let owners = declarations
4611                .iter()
4612                .filter_map(|(id, _)| {
4613                    if entity_owner {
4614                        handle.entity_type_name(*id)
4615                    } else {
4616                        handle.relation_type_name(*id)
4617                    }
4618                })
4619                .collect::<Vec<_>>()
4620                .join(", ");
4621            (
4622                BindErrorKind::AmbiguousProperty,
4623                format!("property `{name}` is ambiguous for {description}; declarations: {owners}"),
4624            )
4625        };
4626        s.errors.push(BindError::new(kind, span, message));
4627        PropId(u32::MAX)
4628    }
4629
4630    // -----------------------------------------------------------------------
4631    // Variable management
4632    // -----------------------------------------------------------------------
4633}
4634
4635/// Classify a return expression as a top-level aggregate function call.
4636///
4637/// Returns the [`AggFunc`] when `expr` is a bare `count(...)`/`sum(...)`/…
4638/// call (the openCypher aggregating functions). `DISTINCT` variants are refined
4639/// by the caller using the call's `distinct` flag. Names are matched
4640/// case-insensitively and unqualified.
4641fn agg_func_of(expr: &Expr) -> Option<AggFunc> {
4642    let Expr::FunctionCall(call) = expr else {
4643        return None;
4644    };
4645    // Only bare (unqualified) names are aggregates here.
4646    let [name] = call.name.as_slice() else {
4647        return None;
4648    };
4649    match name.to_ascii_lowercase().as_str() {
4650        "count" => Some(AggFunc::Count),
4651        "sum" => Some(AggFunc::Sum),
4652        "avg" => Some(AggFunc::Avg),
4653        "min" => Some(AggFunc::Min),
4654        "max" => Some(AggFunc::Max),
4655        "collect" => Some(AggFunc::Collect),
4656        "percentiledisc" => Some(AggFunc::PercentileDisc),
4657        "percentilecont" => Some(AggFunc::PercentileCont),
4658        _ => None,
4659    }
4660}
4661
4662fn is_function_named(call: &FunctionCall, name: &str) -> bool {
4663    matches!(call.name.as_slice(), [n] if n.eq_ignore_ascii_case(name))
4664}
4665
4666#[allow(
4667    clippy::too_many_lines,
4668    reason = "flat compile-time registry mirrors the scalar-function dispatch table"
4669)]
4670fn is_known_cypher_function(call: &FunctionCall) -> bool {
4671    let name = call.name.join(".").to_ascii_lowercase();
4672    matches!(
4673        name.as_str(),
4674        "abs"
4675            | "allshortestpaths"
4676            | "avg"
4677            | "ceil"
4678            | "char_length"
4679            | "character_length"
4680            | "coalesce"
4681            | "collect"
4682            | "concat"
4683            | "count"
4684            | "date"
4685            | "datetime"
4686            | "datetime.fromepoch"
4687            | "datetime.fromepochmillis"
4688            | "date.truncate"
4689            | "datetime.truncate"
4690            | "duration"
4691            | "duration.between"
4692            | "duration.indays"
4693            | "duration.inmonths"
4694            | "duration.inseconds"
4695            | "elementid"
4696            | "endnode"
4697            | "exp"
4698            | "exists"
4699            | "extract"
4700            | "filter"
4701            | "floor"
4702            | "head"
4703            | "id"
4704            | "keys"
4705            | "labels"
4706            | "last"
4707            | "length"
4708            | "localdatetime"
4709            | "localdatetime.truncate"
4710            | "localtime"
4711            | "localtime.truncate"
4712            | "log"
4713            | "lower"
4714            | "ltrim"
4715            | "max"
4716            | "min"
4717            | "nodes"
4718            | "percentilecont"
4719            | "percentiledisc"
4720            | "point"
4721            | "power"
4722            | "properties"
4723            | "rand"
4724            | "range"
4725            | "reduce"
4726            | "relationships"
4727            | "replace"
4728            | "reverse"
4729            | "round"
4730            | "rtrim"
4731            | "shortestpath"
4732            | "sign"
4733            | "size"
4734            | "split"
4735            | "sqrt"
4736            | "startnode"
4737            | "string.concat"
4738            | "substring"
4739            | "sum"
4740            | "tail"
4741            | "time"
4742            | "time.truncate"
4743            | "toboolean"
4744            | "tofloat"
4745            | "tointeger"
4746            | "tolower"
4747            | "tostring"
4748            | "toupper"
4749            | "trim"
4750            | "type"
4751            | "upper"
4752            | "timestamp"
4753            | "_slice"
4754            | "_slice_from_start"
4755            | "_slice_to_end"
4756            | "_subscript"
4757    ) || matches!(
4758        name.as_str(),
4759        "date.transaction"
4760            | "date.statement"
4761            | "date.realtime"
4762            | "datetime.transaction"
4763            | "datetime.statement"
4764            | "datetime.realtime"
4765            | "localdatetime.transaction"
4766            | "localdatetime.statement"
4767            | "localdatetime.realtime"
4768            | "localtime.transaction"
4769            | "localtime.statement"
4770            | "localtime.realtime"
4771            | "time.transaction"
4772            | "time.statement"
4773            | "time.realtime"
4774    )
4775}
4776
4777fn expr_contains_aggregate(expr: &Expr) -> bool {
4778    if agg_func_of(expr).is_some() {
4779        return true;
4780    }
4781    match expr {
4782        Expr::BinaryOp(b) => expr_contains_aggregate(&b.left) || expr_contains_aggregate(&b.right),
4783        Expr::UnaryOp(u) => expr_contains_aggregate(&u.expr),
4784        Expr::Parenthesized { inner, .. } => expr_contains_aggregate(inner),
4785        Expr::FunctionCall(c) => c.args.iter().any(expr_contains_aggregate),
4786        Expr::Property(p) => expr_contains_aggregate(&p.object),
4787        Expr::List(l) => l.elements.iter().any(expr_contains_aggregate),
4788        Expr::Map(m) => m.entries.values().any(expr_contains_aggregate),
4789        Expr::Case(c) => {
4790            c.subject.as_deref().is_some_and(expr_contains_aggregate)
4791                || c.when_clauses.iter().any(|when| {
4792                    expr_contains_aggregate(&when.condition)
4793                        || expr_contains_aggregate(&when.result)
4794                })
4795                || c.else_expr.as_deref().is_some_and(expr_contains_aggregate)
4796        }
4797        Expr::ListComprehension(lc) => {
4798            expr_contains_aggregate(&lc.list)
4799                || lc.filter.as_deref().is_some_and(expr_contains_aggregate)
4800                || lc
4801                    .projection
4802                    .as_deref()
4803                    .is_some_and(expr_contains_aggregate)
4804        }
4805        Expr::Quantifier(q) => {
4806            expr_contains_aggregate(&q.list) || expr_contains_aggregate(&q.predicate)
4807        }
4808        Expr::PatternComprehension(pc) => {
4809            pc.filter.as_deref().is_some_and(expr_contains_aggregate)
4810                || expr_contains_aggregate(&pc.projection)
4811        }
4812        Expr::ExistentialSubquery(es) => match &es.body {
4813            ExistentialSubqueryBody::Simple { filter, .. } => {
4814                filter.as_deref().is_some_and(expr_contains_aggregate)
4815            }
4816            ExistentialSubqueryBody::Full(_) => false,
4817        },
4818        Expr::IsNull { expr, .. } => expr_contains_aggregate(expr),
4819        Expr::InList { expr, list, .. } => {
4820            expr_contains_aggregate(expr) || expr_contains_aggregate(list)
4821        }
4822        Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
4823            expr_contains_aggregate(expr) || expr_contains_aggregate(pattern)
4824        }
4825        _ => false,
4826    }
4827}
4828
4829fn procedure_argument_type_matches(expr: &Expr, field: &crate::ProcedureField) -> bool {
4830    let expected = field.type_name.to_ascii_uppercase();
4831    match expr {
4832        Expr::Literal(Literal::Null(_)) => field.nullable,
4833        Expr::Literal(Literal::Int(_, _)) => {
4834            matches!(expected.as_str(), "INTEGER" | "FLOAT" | "NUMBER")
4835        }
4836        Expr::Literal(Literal::Float(_, _)) => matches!(expected.as_str(), "FLOAT" | "NUMBER"),
4837        Expr::Literal(Literal::Str(_, _)) => expected == "STRING",
4838        Expr::Literal(Literal::Bool(_, _)) => expected == "BOOLEAN",
4839        Expr::Parenthesized { inner, .. } => procedure_argument_type_matches(inner, field),
4840        _ => true,
4841    }
4842}
4843
4844fn union_output_names(plan: &GraphPlan) -> Option<Vec<String>> {
4845    match plan.ops.last() {
4846        Some(GraphOp::Project { items, .. } | GraphOp::With { items, .. }) => Some(
4847            items
4848                .iter()
4849                .map(|item| item.alias.clone().unwrap_or_default())
4850                .collect(),
4851        ),
4852        Some(GraphOp::Aggregate {
4853            group_aliases,
4854            aggs,
4855            ..
4856        }) => Some(
4857            group_aliases
4858                .iter()
4859                .map(|alias| alias.clone().unwrap_or_default())
4860                .chain(aggs.iter().map(|agg| agg.alias.clone()))
4861                .collect(),
4862        ),
4863        _ => None,
4864    }
4865}
4866
4867fn collect_aggregate_exprs(expr: &Expr, out: &mut Vec<Expr>) {
4868    if agg_func_of(expr).is_some() {
4869        if !out.iter().any(|existing| same_expr_shape(existing, expr)) {
4870            out.push(expr.clone());
4871        }
4872        return;
4873    }
4874    match expr {
4875        Expr::BinaryOp(binary) => {
4876            collect_aggregate_exprs(&binary.left, out);
4877            collect_aggregate_exprs(&binary.right, out);
4878        }
4879        Expr::UnaryOp(unary) => collect_aggregate_exprs(&unary.expr, out),
4880        Expr::Parenthesized { inner, .. } => collect_aggregate_exprs(inner, out),
4881        Expr::FunctionCall(call) => {
4882            for arg in &call.args {
4883                collect_aggregate_exprs(arg, out);
4884            }
4885        }
4886        Expr::Property(property) => collect_aggregate_exprs(&property.object, out),
4887        Expr::List(list) => {
4888            for element in &list.elements {
4889                collect_aggregate_exprs(element, out);
4890            }
4891        }
4892        Expr::Map(map) => {
4893            for value in map.entries.values() {
4894                collect_aggregate_exprs(value, out);
4895            }
4896        }
4897        Expr::Case(case) => {
4898            if let Some(subject) = &case.subject {
4899                collect_aggregate_exprs(subject, out);
4900            }
4901            for when in &case.when_clauses {
4902                collect_aggregate_exprs(&when.condition, out);
4903                collect_aggregate_exprs(&when.result, out);
4904            }
4905            if let Some(else_expr) = &case.else_expr {
4906                collect_aggregate_exprs(else_expr, out);
4907            }
4908        }
4909        Expr::IsNull { expr, .. } => collect_aggregate_exprs(expr, out),
4910        Expr::InList { expr, list, .. } => {
4911            collect_aggregate_exprs(expr, out);
4912            collect_aggregate_exprs(list, out);
4913        }
4914        Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
4915            collect_aggregate_exprs(expr, out);
4916            collect_aggregate_exprs(pattern, out);
4917        }
4918        _ => {}
4919    }
4920}
4921
4922fn has_unprojected_order_aggregate(projections: &[ReturnItem], order_by: &[SortItem]) -> bool {
4923    let mut projected = Vec::new();
4924    for item in projections {
4925        collect_aggregate_exprs(&item.expr, &mut projected);
4926    }
4927    let mut ordered = Vec::new();
4928    for item in order_by {
4929        collect_aggregate_exprs(&item.expr, &mut ordered);
4930    }
4931    ordered
4932        .iter()
4933        .any(|order_agg| !projected.iter().any(|agg| same_expr_shape(agg, order_agg)))
4934}
4935
4936fn expr_contains_aggregate_inside_aggregate(expr: &Expr, inside_aggregate: bool) -> bool {
4937    let is_aggregate = agg_func_of(expr).is_some();
4938    if is_aggregate && inside_aggregate {
4939        return true;
4940    }
4941    let inside_aggregate = inside_aggregate || is_aggregate;
4942    match expr {
4943        Expr::BinaryOp(b) => {
4944            expr_contains_aggregate_inside_aggregate(&b.left, inside_aggregate)
4945                || expr_contains_aggregate_inside_aggregate(&b.right, inside_aggregate)
4946        }
4947        Expr::UnaryOp(u) => expr_contains_aggregate_inside_aggregate(&u.expr, inside_aggregate),
4948        Expr::Parenthesized { inner, .. } => {
4949            expr_contains_aggregate_inside_aggregate(inner, inside_aggregate)
4950        }
4951        Expr::FunctionCall(c) => c
4952            .args
4953            .iter()
4954            .any(|arg| expr_contains_aggregate_inside_aggregate(arg, inside_aggregate)),
4955        Expr::Property(p) => expr_contains_aggregate_inside_aggregate(&p.object, inside_aggregate),
4956        Expr::List(l) => l
4957            .elements
4958            .iter()
4959            .any(|element| expr_contains_aggregate_inside_aggregate(element, inside_aggregate)),
4960        Expr::Map(m) => m
4961            .entries
4962            .values()
4963            .any(|value| expr_contains_aggregate_inside_aggregate(value, inside_aggregate)),
4964        Expr::Case(c) => {
4965            c.subject.as_deref().is_some_and(|subject| {
4966                expr_contains_aggregate_inside_aggregate(subject, inside_aggregate)
4967            }) || c.when_clauses.iter().any(|when| {
4968                expr_contains_aggregate_inside_aggregate(&when.condition, inside_aggregate)
4969                    || expr_contains_aggregate_inside_aggregate(&when.result, inside_aggregate)
4970            }) || c.else_expr.as_deref().is_some_and(|else_expr| {
4971                expr_contains_aggregate_inside_aggregate(else_expr, inside_aggregate)
4972            })
4973        }
4974        Expr::ListComprehension(lc) => {
4975            expr_contains_aggregate_inside_aggregate(&lc.list, inside_aggregate)
4976                || lc.filter.as_deref().is_some_and(|filter| {
4977                    expr_contains_aggregate_inside_aggregate(filter, inside_aggregate)
4978                })
4979                || lc.projection.as_deref().is_some_and(|projection| {
4980                    expr_contains_aggregate_inside_aggregate(projection, inside_aggregate)
4981                })
4982        }
4983        Expr::Quantifier(q) => {
4984            expr_contains_aggregate_inside_aggregate(&q.list, inside_aggregate)
4985                || expr_contains_aggregate_inside_aggregate(&q.predicate, inside_aggregate)
4986        }
4987        Expr::PatternComprehension(pc) => {
4988            pc.filter.as_deref().is_some_and(|filter| {
4989                expr_contains_aggregate_inside_aggregate(filter, inside_aggregate)
4990            }) || expr_contains_aggregate_inside_aggregate(&pc.projection, inside_aggregate)
4991        }
4992        Expr::ExistentialSubquery(es) => match &es.body {
4993            ExistentialSubqueryBody::Simple { filter, .. } => {
4994                filter.as_deref().is_some_and(|filter| {
4995                    expr_contains_aggregate_inside_aggregate(filter, inside_aggregate)
4996                })
4997            }
4998            ExistentialSubqueryBody::Full(_) => false,
4999        },
5000        Expr::IsNull { expr, .. } => {
5001            expr_contains_aggregate_inside_aggregate(expr, inside_aggregate)
5002        }
5003        Expr::InList { expr, list, .. } => {
5004            expr_contains_aggregate_inside_aggregate(expr, inside_aggregate)
5005                || expr_contains_aggregate_inside_aggregate(list, inside_aggregate)
5006        }
5007        Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
5008            expr_contains_aggregate_inside_aggregate(expr, inside_aggregate)
5009                || expr_contains_aggregate_inside_aggregate(pattern, inside_aggregate)
5010        }
5011        _ => false,
5012    }
5013}
5014
5015fn expr_contains_volatile_function(expr: &Expr) -> bool {
5016    match expr {
5017        Expr::FunctionCall(call) => {
5018            is_function_named(call, "rand") || call.args.iter().any(expr_contains_volatile_function)
5019        }
5020        Expr::BinaryOp(binary) => {
5021            expr_contains_volatile_function(&binary.left)
5022                || expr_contains_volatile_function(&binary.right)
5023        }
5024        Expr::UnaryOp(unary) => expr_contains_volatile_function(&unary.expr),
5025        Expr::Parenthesized { inner, .. } => expr_contains_volatile_function(inner),
5026        Expr::Property(property) => expr_contains_volatile_function(&property.object),
5027        Expr::List(list) => list.elements.iter().any(expr_contains_volatile_function),
5028        Expr::Map(map) => map.entries.values().any(expr_contains_volatile_function),
5029        Expr::Case(case) => {
5030            case.subject
5031                .as_deref()
5032                .is_some_and(expr_contains_volatile_function)
5033                || case.when_clauses.iter().any(|when| {
5034                    expr_contains_volatile_function(&when.condition)
5035                        || expr_contains_volatile_function(&when.result)
5036                })
5037                || case
5038                    .else_expr
5039                    .as_deref()
5040                    .is_some_and(expr_contains_volatile_function)
5041        }
5042        Expr::IsNull { expr, .. } => expr_contains_volatile_function(expr),
5043        Expr::InList { expr, list, .. } => {
5044            expr_contains_volatile_function(expr) || expr_contains_volatile_function(list)
5045        }
5046        Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
5047            expr_contains_volatile_function(expr) || expr_contains_volatile_function(pattern)
5048        }
5049        _ => false,
5050    }
5051}
5052
5053fn is_atomic_grouping_expr(expr: &Expr) -> bool {
5054    matches!(strip_parens(expr), Expr::Var(_) | Expr::Property(_))
5055}
5056
5057fn grouping_ref_root_name(expr: &Expr) -> Option<&str> {
5058    match strip_parens(expr) {
5059        Expr::Var(var) => Some(var.name.as_str()),
5060        Expr::Property(property) => grouping_ref_root_name(&property.object),
5061        _ => None,
5062    }
5063}
5064
5065fn same_grouping_expr(left: &Expr, right: &Expr) -> bool {
5066    match (strip_parens(left), strip_parens(right)) {
5067        (Expr::Var(a), Expr::Var(b)) => a.name == b.name,
5068        (Expr::Property(a), Expr::Property(b)) => {
5069            a.key == b.key && same_grouping_expr(&a.object, &b.object)
5070        }
5071        (Expr::FunctionCall(a), Expr::FunctionCall(b)) => {
5072            a.name
5073                .iter()
5074                .map(|part| part.to_ascii_lowercase())
5075                .eq(b.name.iter().map(|part| part.to_ascii_lowercase()))
5076                && a.distinct == b.distinct
5077                && a.star == b.star
5078                && a.args.len() == b.args.len()
5079                && a.args
5080                    .iter()
5081                    .zip(&b.args)
5082                    .all(|(left, right)| same_expr_shape(left, right))
5083        }
5084        (Expr::List(a), Expr::List(b)) => {
5085            a.elements.len() == b.elements.len()
5086                && a.elements
5087                    .iter()
5088                    .zip(&b.elements)
5089                    .all(|(left, right)| same_expr_shape(left, right))
5090        }
5091        (Expr::Map(a), Expr::Map(b)) => {
5092            a.entries.len() == b.entries.len()
5093                && a.entries.iter().all(|(key, left)| {
5094                    b.entries
5095                        .get(key)
5096                        .is_some_and(|right| same_expr_shape(left, right))
5097                })
5098        }
5099        _ => false,
5100    }
5101}
5102
5103fn same_expr_shape(left: &Expr, right: &Expr) -> bool {
5104    match (strip_parens(left), strip_parens(right)) {
5105        (Expr::Literal(Literal::Int(a, _)), Expr::Literal(Literal::Int(b, _))) => a == b,
5106        (Expr::Literal(Literal::Float(a, _)), Expr::Literal(Literal::Float(b, _))) => {
5107            a.to_bits() == b.to_bits()
5108        }
5109        (Expr::Literal(Literal::Str(a, _)), Expr::Literal(Literal::Str(b, _))) => a == b,
5110        (Expr::Literal(Literal::Bool(a, _)), Expr::Literal(Literal::Bool(b, _))) => a == b,
5111        (Expr::Literal(Literal::Null(_)), Expr::Literal(Literal::Null(_))) => true,
5112        (Expr::Param(a), Expr::Param(b)) => a.name == b.name,
5113        (Expr::Var(a), Expr::Var(b)) => a.name == b.name,
5114        (Expr::Property(a), Expr::Property(b)) => {
5115            a.key == b.key && same_expr_shape(&a.object, &b.object)
5116        }
5117        (Expr::BinaryOp(a), Expr::BinaryOp(b)) => {
5118            a.op == b.op && same_expr_shape(&a.left, &b.left) && same_expr_shape(&a.right, &b.right)
5119        }
5120        (Expr::UnaryOp(a), Expr::UnaryOp(b)) => a.op == b.op && same_expr_shape(&a.expr, &b.expr),
5121        (Expr::FunctionCall(a), Expr::FunctionCall(b)) => {
5122            a.name
5123                .iter()
5124                .map(|part| part.to_ascii_lowercase())
5125                .eq(b.name.iter().map(|part| part.to_ascii_lowercase()))
5126                && a.distinct == b.distinct
5127                && a.star == b.star
5128                && a.args.len() == b.args.len()
5129                && a.args
5130                    .iter()
5131                    .zip(&b.args)
5132                    .all(|(left, right)| same_expr_shape(left, right))
5133        }
5134        _ => false,
5135    }
5136}
5137
5138fn rewrite_projection_alias_refs(expr: Expr, projections: &[ReturnItem]) -> Expr {
5139    rewrite_projection_alias_refs_except(expr, projections, &[])
5140}
5141
5142#[allow(clippy::too_many_lines)]
5143fn rewrite_projection_alias_refs_except(
5144    expr: Expr,
5145    projections: &[ReturnItem],
5146    hidden: &[String],
5147) -> Expr {
5148    if let Expr::Var(var) = &expr
5149        && !hidden.contains(&var.name)
5150        && let Some(projection) = projections
5151            .iter()
5152            .find(|projection| projection.alias.as_deref() == Some(var.name.as_str()))
5153    {
5154        return projection.expr.clone();
5155    }
5156    match expr {
5157        Expr::BinaryOp(binary) => Expr::BinaryOp(graphforge_ast::BinaryOp {
5158            op: binary.op,
5159            left: Box::new(rewrite_projection_alias_refs_except(
5160                *binary.left,
5161                projections,
5162                hidden,
5163            )),
5164            right: Box::new(rewrite_projection_alias_refs_except(
5165                *binary.right,
5166                projections,
5167                hidden,
5168            )),
5169            span: binary.span,
5170        }),
5171        Expr::UnaryOp(unary) => Expr::UnaryOp(graphforge_ast::UnaryOp {
5172            op: unary.op,
5173            expr: Box::new(rewrite_projection_alias_refs_except(
5174                *unary.expr,
5175                projections,
5176                hidden,
5177            )),
5178            span: unary.span,
5179        }),
5180        Expr::Parenthesized { inner, span } => Expr::Parenthesized {
5181            inner: Box::new(rewrite_projection_alias_refs_except(
5182                *inner,
5183                projections,
5184                hidden,
5185            )),
5186            span,
5187        },
5188        Expr::Property(mut property) => {
5189            property.object = Box::new(rewrite_projection_alias_refs_except(
5190                *property.object,
5191                projections,
5192                hidden,
5193            ));
5194            Expr::Property(property)
5195        }
5196        Expr::FunctionCall(mut call) => {
5197            call.args = call
5198                .args
5199                .into_iter()
5200                .map(|arg| rewrite_projection_alias_refs_except(arg, projections, hidden))
5201                .collect();
5202            Expr::FunctionCall(call)
5203        }
5204        Expr::List(mut list) => {
5205            list.elements = list
5206                .elements
5207                .into_iter()
5208                .map(|element| rewrite_projection_alias_refs_except(element, projections, hidden))
5209                .collect();
5210            Expr::List(list)
5211        }
5212        Expr::Map(mut map) => {
5213            map.entries = map
5214                .entries
5215                .into_iter()
5216                .map(|(key, value)| {
5217                    (
5218                        key,
5219                        rewrite_projection_alias_refs_except(value, projections, hidden),
5220                    )
5221                })
5222                .collect();
5223            Expr::Map(map)
5224        }
5225        Expr::Case(mut case) => {
5226            case.subject = case.subject.map(|subject| {
5227                Box::new(rewrite_projection_alias_refs_except(
5228                    *subject,
5229                    projections,
5230                    hidden,
5231                ))
5232            });
5233            for when in &mut case.when_clauses {
5234                when.condition = rewrite_projection_alias_refs_except(
5235                    when.condition.clone(),
5236                    projections,
5237                    hidden,
5238                );
5239                when.result =
5240                    rewrite_projection_alias_refs_except(when.result.clone(), projections, hidden);
5241            }
5242            case.else_expr = case.else_expr.map(|else_expr| {
5243                Box::new(rewrite_projection_alias_refs_except(
5244                    *else_expr,
5245                    projections,
5246                    hidden,
5247                ))
5248            });
5249            Expr::Case(case)
5250        }
5251        Expr::ListComprehension(mut comprehension) => {
5252            comprehension.list = Box::new(rewrite_projection_alias_refs_except(
5253                *comprehension.list,
5254                projections,
5255                hidden,
5256            ));
5257            let mut body_hidden = hidden.to_vec();
5258            body_hidden.push(comprehension.var.clone());
5259            comprehension.filter = comprehension.filter.map(|filter| {
5260                Box::new(rewrite_projection_alias_refs_except(
5261                    *filter,
5262                    projections,
5263                    &body_hidden,
5264                ))
5265            });
5266            comprehension.projection = comprehension.projection.map(|projection| {
5267                Box::new(rewrite_projection_alias_refs_except(
5268                    *projection,
5269                    projections,
5270                    &body_hidden,
5271                ))
5272            });
5273            Expr::ListComprehension(comprehension)
5274        }
5275        Expr::Quantifier(mut quantifier) => {
5276            quantifier.list = Box::new(rewrite_projection_alias_refs_except(
5277                *quantifier.list,
5278                projections,
5279                hidden,
5280            ));
5281            let mut body_hidden = hidden.to_vec();
5282            body_hidden.push(quantifier.var.clone());
5283            quantifier.predicate = Box::new(rewrite_projection_alias_refs_except(
5284                *quantifier.predicate,
5285                projections,
5286                &body_hidden,
5287            ));
5288            Expr::Quantifier(quantifier)
5289        }
5290        Expr::IsNull {
5291            expr,
5292            negated,
5293            span,
5294        } => Expr::IsNull {
5295            expr: Box::new(rewrite_projection_alias_refs_except(
5296                *expr,
5297                projections,
5298                hidden,
5299            )),
5300            negated,
5301            span,
5302        },
5303        Expr::InList {
5304            expr,
5305            list,
5306            negated,
5307            span,
5308        } => Expr::InList {
5309            expr: Box::new(rewrite_projection_alias_refs_except(
5310                *expr,
5311                projections,
5312                hidden,
5313            )),
5314            list: Box::new(rewrite_projection_alias_refs_except(
5315                *list,
5316                projections,
5317                hidden,
5318            )),
5319            negated,
5320            span,
5321        },
5322        Expr::StringOp {
5323            expr,
5324            op,
5325            pattern,
5326            span,
5327        } => Expr::StringOp {
5328            expr: Box::new(rewrite_projection_alias_refs_except(
5329                *expr,
5330                projections,
5331                hidden,
5332            )),
5333            op,
5334            pattern: Box::new(rewrite_projection_alias_refs_except(
5335                *pattern,
5336                projections,
5337                hidden,
5338            )),
5339            span,
5340        },
5341        Expr::RegexMatch {
5342            expr,
5343            pattern,
5344            span,
5345        } => Expr::RegexMatch {
5346            expr: Box::new(rewrite_projection_alias_refs_except(
5347                *expr,
5348                projections,
5349                hidden,
5350            )),
5351            pattern: Box::new(rewrite_projection_alias_refs_except(
5352                *pattern,
5353                projections,
5354                hidden,
5355            )),
5356            span,
5357        },
5358        other => other,
5359    }
5360}
5361
5362fn collect_grouping_refs(expr: &Expr, out: &mut Vec<Expr>) {
5363    collect_grouping_refs_except(expr, out, &[]);
5364}
5365
5366#[allow(clippy::too_many_lines)]
5367fn collect_grouping_refs_except(expr: &Expr, out: &mut Vec<Expr>, hidden: &[String]) {
5368    if agg_func_of(expr).is_some() {
5369        return;
5370    }
5371    match expr {
5372        Expr::Var(_) | Expr::Property(_) if !grouping_ref_is_hidden(expr, hidden) => {
5373            out.push(expr.clone());
5374        }
5375        Expr::BinaryOp(binary) => {
5376            collect_grouping_refs_except(&binary.left, out, hidden);
5377            collect_grouping_refs_except(&binary.right, out, hidden);
5378        }
5379        Expr::UnaryOp(unary) => collect_grouping_refs_except(&unary.expr, out, hidden),
5380        Expr::Parenthesized { inner, .. } => collect_grouping_refs_except(inner, out, hidden),
5381        Expr::FunctionCall(call) => {
5382            for arg in &call.args {
5383                collect_grouping_refs_except(arg, out, hidden);
5384            }
5385        }
5386        Expr::List(list) => {
5387            for element in &list.elements {
5388                collect_grouping_refs_except(element, out, hidden);
5389            }
5390        }
5391        Expr::Map(map) => {
5392            for value in map.entries.values() {
5393                collect_grouping_refs_except(value, out, hidden);
5394            }
5395        }
5396        Expr::Case(case) => {
5397            if let Some(subject) = &case.subject {
5398                collect_grouping_refs_except(subject, out, hidden);
5399            }
5400            for when in &case.when_clauses {
5401                collect_grouping_refs_except(&when.condition, out, hidden);
5402                collect_grouping_refs_except(&when.result, out, hidden);
5403            }
5404            if let Some(else_expr) = &case.else_expr {
5405                collect_grouping_refs_except(else_expr, out, hidden);
5406            }
5407        }
5408        Expr::ListComprehension(lc) => {
5409            collect_grouping_refs_except(&lc.list, out, hidden);
5410            let mut body_hidden = hidden.to_vec();
5411            body_hidden.push(lc.var.clone());
5412            if let Some(filter) = &lc.filter {
5413                collect_grouping_refs_except(filter, out, &body_hidden);
5414            }
5415            if let Some(projection) = &lc.projection {
5416                collect_grouping_refs_except(projection, out, &body_hidden);
5417            }
5418        }
5419        Expr::Quantifier(q) => {
5420            collect_grouping_refs_except(&q.list, out, hidden);
5421            let mut predicate_hidden = hidden.to_vec();
5422            predicate_hidden.push(q.var.clone());
5423            collect_grouping_refs_except(&q.predicate, out, &predicate_hidden);
5424        }
5425        Expr::PatternComprehension(pc) => {
5426            let body_hidden = hidden_with_pattern_vars(hidden, &pc.pattern, pc.var.as_ref());
5427            if let Some(filter) = &pc.filter {
5428                collect_grouping_refs_except(filter, out, &body_hidden);
5429            }
5430            collect_grouping_refs_except(&pc.projection, out, &body_hidden);
5431        }
5432        Expr::ExistentialSubquery(es) => {
5433            if let ExistentialSubqueryBody::Simple { pattern, filter } = &es.body {
5434                let body_hidden = hidden_with_pattern_vars(hidden, pattern, None);
5435                if let Some(filter) = filter {
5436                    collect_grouping_refs_except(filter, out, &body_hidden);
5437                }
5438            }
5439        }
5440        Expr::LabelPredicate(label) if !hidden.contains(&label.var) => {
5441            out.push(Expr::Var(VarRef {
5442                name: label.var.clone(),
5443                span: label.span,
5444            }));
5445        }
5446        Expr::IsNull { expr, .. } => collect_grouping_refs_except(expr, out, hidden),
5447        Expr::InList { expr, list, .. } => {
5448            collect_grouping_refs_except(expr, out, hidden);
5449            collect_grouping_refs_except(list, out, hidden);
5450        }
5451        Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
5452            collect_grouping_refs_except(expr, out, hidden);
5453            collect_grouping_refs_except(pattern, out, hidden);
5454        }
5455        _ => {}
5456    }
5457}
5458
5459fn grouping_ref_is_hidden(expr: &Expr, hidden: &[String]) -> bool {
5460    match strip_parens(expr) {
5461        Expr::Var(var) => hidden.contains(&var.name),
5462        Expr::Property(property) => grouping_ref_is_hidden(&property.object, hidden),
5463        _ => false,
5464    }
5465}
5466
5467fn hidden_with_pattern_vars(
5468    hidden: &[String],
5469    pattern: &PathPattern,
5470    path_var: Option<&String>,
5471) -> Vec<String> {
5472    let mut body_hidden = hidden.to_vec();
5473    body_hidden.extend(pattern.elements.iter().filter_map(|element| match element {
5474        PathElement::Node(node) => node.var.clone(),
5475        PathElement::Rel(rel) => rel.var.clone(),
5476    }));
5477    body_hidden.extend(path_var.cloned());
5478    body_hidden
5479}
5480
5481#[allow(clippy::too_many_lines)]
5482fn rewrite_grouping_refs(expr: Expr, bindings: &[(Expr, String, VarId)]) -> Expr {
5483    rewrite_grouping_refs_except(expr, bindings, &[])
5484}
5485
5486#[allow(clippy::too_many_lines)]
5487fn rewrite_grouping_refs_except(
5488    expr: Expr,
5489    bindings: &[(Expr, String, VarId)],
5490    hidden: &[String],
5491) -> Expr {
5492    if !grouping_ref_is_hidden(&expr, hidden)
5493        && let Some((_, alias, _)) = bindings
5494            .iter()
5495            .find(|(group, _, _)| same_grouping_expr(&expr, group))
5496    {
5497        return Expr::Var(VarRef {
5498            name: alias.clone(),
5499            span: expr.span(),
5500        });
5501    }
5502    match expr {
5503        Expr::BinaryOp(binary) => Expr::BinaryOp(graphforge_ast::BinaryOp {
5504            op: binary.op,
5505            left: Box::new(rewrite_grouping_refs_except(*binary.left, bindings, hidden)),
5506            right: Box::new(rewrite_grouping_refs_except(
5507                *binary.right,
5508                bindings,
5509                hidden,
5510            )),
5511            span: binary.span,
5512        }),
5513        Expr::UnaryOp(unary) => Expr::UnaryOp(graphforge_ast::UnaryOp {
5514            op: unary.op,
5515            expr: Box::new(rewrite_grouping_refs_except(*unary.expr, bindings, hidden)),
5516            span: unary.span,
5517        }),
5518        Expr::Parenthesized { inner, span } => Expr::Parenthesized {
5519            inner: Box::new(rewrite_grouping_refs_except(*inner, bindings, hidden)),
5520            span,
5521        },
5522        Expr::FunctionCall(call) => Expr::FunctionCall(graphforge_ast::FunctionCall {
5523            name: call.name,
5524            distinct: call.distinct,
5525            star: call.star,
5526            args: call
5527                .args
5528                .into_iter()
5529                .map(|arg| rewrite_grouping_refs_except(arg, bindings, hidden))
5530                .collect(),
5531            span: call.span,
5532        }),
5533        Expr::Property(mut property) => {
5534            property.object = Box::new(rewrite_grouping_refs_except(
5535                *property.object,
5536                bindings,
5537                hidden,
5538            ));
5539            Expr::Property(property)
5540        }
5541        Expr::List(list) => Expr::List(graphforge_ast::ListLiteral {
5542            elements: list
5543                .elements
5544                .into_iter()
5545                .map(|element| rewrite_grouping_refs_except(element, bindings, hidden))
5546                .collect(),
5547            span: list.span,
5548        }),
5549        Expr::Map(mut map) => {
5550            map.entries = map
5551                .entries
5552                .into_iter()
5553                .map(|(key, value)| (key, rewrite_grouping_refs_except(value, bindings, hidden)))
5554                .collect();
5555            Expr::Map(map)
5556        }
5557        Expr::Case(mut case) => {
5558            case.subject = case
5559                .subject
5560                .map(|subject| Box::new(rewrite_grouping_refs_except(*subject, bindings, hidden)));
5561            for when in &mut case.when_clauses {
5562                when.condition =
5563                    rewrite_grouping_refs_except(when.condition.clone(), bindings, hidden);
5564                when.result = rewrite_grouping_refs_except(when.result.clone(), bindings, hidden);
5565            }
5566            case.else_expr = case.else_expr.map(|else_expr| {
5567                Box::new(rewrite_grouping_refs_except(*else_expr, bindings, hidden))
5568            });
5569            Expr::Case(case)
5570        }
5571        Expr::ListComprehension(mut lc) => {
5572            lc.list = Box::new(rewrite_grouping_refs_except(*lc.list, bindings, hidden));
5573            let mut body_hidden = hidden.to_vec();
5574            body_hidden.push(lc.var.clone());
5575            lc.filter = lc.filter.map(|filter| {
5576                Box::new(rewrite_grouping_refs_except(
5577                    *filter,
5578                    bindings,
5579                    &body_hidden,
5580                ))
5581            });
5582            lc.projection = lc.projection.map(|projection| {
5583                Box::new(rewrite_grouping_refs_except(
5584                    *projection,
5585                    bindings,
5586                    &body_hidden,
5587                ))
5588            });
5589            Expr::ListComprehension(lc)
5590        }
5591        Expr::Quantifier(mut q) => {
5592            q.list = Box::new(rewrite_grouping_refs_except(*q.list, bindings, hidden));
5593            let mut predicate_hidden = hidden.to_vec();
5594            predicate_hidden.push(q.var.clone());
5595            q.predicate = Box::new(rewrite_grouping_refs_except(
5596                *q.predicate,
5597                bindings,
5598                &predicate_hidden,
5599            ));
5600            Expr::Quantifier(q)
5601        }
5602        Expr::PatternComprehension(mut pc) => {
5603            let body_hidden = hidden_with_pattern_vars(hidden, &pc.pattern, pc.var.as_ref());
5604            pc.filter = pc.filter.map(|filter| {
5605                Box::new(rewrite_grouping_refs_except(
5606                    *filter,
5607                    bindings,
5608                    &body_hidden,
5609                ))
5610            });
5611            pc.projection = Box::new(rewrite_grouping_refs_except(
5612                *pc.projection,
5613                bindings,
5614                &body_hidden,
5615            ));
5616            Expr::PatternComprehension(pc)
5617        }
5618        Expr::ExistentialSubquery(mut es) => {
5619            if let ExistentialSubqueryBody::Simple { pattern, filter } = &mut es.body {
5620                let body_hidden = hidden_with_pattern_vars(hidden, pattern, None);
5621                *filter = filter.take().map(|filter| {
5622                    Box::new(rewrite_grouping_refs_except(
5623                        *filter,
5624                        bindings,
5625                        &body_hidden,
5626                    ))
5627                });
5628            }
5629            Expr::ExistentialSubquery(es)
5630        }
5631        Expr::LabelPredicate(mut label) => {
5632            let reference = Expr::Var(VarRef {
5633                name: label.var.clone(),
5634                span: label.span,
5635            });
5636            if !hidden.contains(&label.var)
5637                && let Some((_, alias, _)) = bindings
5638                    .iter()
5639                    .find(|(group, _, _)| same_grouping_expr(&reference, group))
5640            {
5641                label.var.clone_from(alias);
5642            }
5643            Expr::LabelPredicate(label)
5644        }
5645        Expr::IsNull {
5646            expr,
5647            negated,
5648            span,
5649        } => Expr::IsNull {
5650            expr: Box::new(rewrite_grouping_refs_except(*expr, bindings, hidden)),
5651            negated,
5652            span,
5653        },
5654        Expr::InList {
5655            expr,
5656            list,
5657            negated,
5658            span,
5659        } => Expr::InList {
5660            expr: Box::new(rewrite_grouping_refs_except(*expr, bindings, hidden)),
5661            list: Box::new(rewrite_grouping_refs_except(*list, bindings, hidden)),
5662            negated,
5663            span,
5664        },
5665        Expr::StringOp {
5666            expr,
5667            op,
5668            pattern,
5669            span,
5670        } => Expr::StringOp {
5671            expr: Box::new(rewrite_grouping_refs_except(*expr, bindings, hidden)),
5672            op,
5673            pattern: Box::new(rewrite_grouping_refs_except(*pattern, bindings, hidden)),
5674            span,
5675        },
5676        Expr::RegexMatch {
5677            expr,
5678            pattern,
5679            span,
5680        } => Expr::RegexMatch {
5681            expr: Box::new(rewrite_grouping_refs_except(*expr, bindings, hidden)),
5682            pattern: Box::new(rewrite_grouping_refs_except(*pattern, bindings, hidden)),
5683            span,
5684        },
5685        other => other,
5686    }
5687}
5688
5689fn expr_contains_pattern_comprehension(expr: &Expr) -> bool {
5690    match expr {
5691        Expr::PatternComprehension(_) => true,
5692        Expr::Parenthesized { inner, .. } => expr_contains_pattern_comprehension(inner),
5693        Expr::BinaryOp(b) => {
5694            expr_contains_pattern_comprehension(&b.left)
5695                || expr_contains_pattern_comprehension(&b.right)
5696        }
5697        Expr::UnaryOp(u) => expr_contains_pattern_comprehension(&u.expr),
5698        Expr::IsNull { expr, .. } => expr_contains_pattern_comprehension(expr),
5699        Expr::InList { expr, list, .. } => {
5700            expr_contains_pattern_comprehension(expr) || expr_contains_pattern_comprehension(list)
5701        }
5702        Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
5703            expr_contains_pattern_comprehension(expr)
5704                || expr_contains_pattern_comprehension(pattern)
5705        }
5706        Expr::FunctionCall(call) => call.args.iter().any(expr_contains_pattern_comprehension),
5707        Expr::Property(property) => expr_contains_pattern_comprehension(&property.object),
5708        Expr::List(list) => list
5709            .elements
5710            .iter()
5711            .any(expr_contains_pattern_comprehension),
5712        Expr::Map(map) => map
5713            .entries
5714            .values()
5715            .any(expr_contains_pattern_comprehension),
5716        Expr::Case(case) => {
5717            case.subject
5718                .as_deref()
5719                .is_some_and(expr_contains_pattern_comprehension)
5720                || case.when_clauses.iter().any(|when| {
5721                    expr_contains_pattern_comprehension(&when.condition)
5722                        || expr_contains_pattern_comprehension(&when.result)
5723                })
5724                || case
5725                    .else_expr
5726                    .as_deref()
5727                    .is_some_and(expr_contains_pattern_comprehension)
5728        }
5729        Expr::ListComprehension(lc) => {
5730            expr_contains_pattern_comprehension(&lc.list)
5731                || lc
5732                    .filter
5733                    .as_deref()
5734                    .is_some_and(expr_contains_pattern_comprehension)
5735                || lc
5736                    .projection
5737                    .as_deref()
5738                    .is_some_and(expr_contains_pattern_comprehension)
5739        }
5740        Expr::Quantifier(q) => {
5741            expr_contains_pattern_comprehension(&q.list)
5742                || expr_contains_pattern_comprehension(&q.predicate)
5743        }
5744        _ => false,
5745    }
5746}
5747
5748fn strip_parens(expr: &Expr) -> &Expr {
5749    match expr {
5750        Expr::Parenthesized { inner, .. } => strip_parens(inner),
5751        other => other,
5752    }
5753}
5754
5755fn matches_pattern_predicate(expr: &Expr) -> bool {
5756    matches!(strip_parens(expr), Expr::PatternPredicate(_))
5757}
5758
5759fn expr_contains_pattern_predicate(expr: &Expr) -> bool {
5760    match expr {
5761        Expr::PatternPredicate(_) => true,
5762        Expr::Parenthesized { inner, .. } => expr_contains_pattern_predicate(inner),
5763        Expr::BinaryOp(b) => {
5764            expr_contains_pattern_predicate(&b.left) || expr_contains_pattern_predicate(&b.right)
5765        }
5766        Expr::UnaryOp(u) => expr_contains_pattern_predicate(&u.expr),
5767        Expr::IsNull { expr, .. } => expr_contains_pattern_predicate(expr),
5768        Expr::InList { expr, list, .. } => {
5769            expr_contains_pattern_predicate(expr) || expr_contains_pattern_predicate(list)
5770        }
5771        Expr::StringOp { expr, pattern, .. } | Expr::RegexMatch { expr, pattern, .. } => {
5772            expr_contains_pattern_predicate(expr) || expr_contains_pattern_predicate(pattern)
5773        }
5774        Expr::FunctionCall(call) => call.args.iter().any(expr_contains_pattern_predicate),
5775        Expr::List(list) => list.elements.iter().any(expr_contains_pattern_predicate),
5776        Expr::Map(map) => map.entries.values().any(expr_contains_pattern_predicate),
5777        Expr::Case(case) => {
5778            case.subject
5779                .as_deref()
5780                .is_some_and(expr_contains_pattern_predicate)
5781                || case.when_clauses.iter().any(|when| {
5782                    expr_contains_pattern_predicate(&when.condition)
5783                        || expr_contains_pattern_predicate(&when.result)
5784                })
5785                || case
5786                    .else_expr
5787                    .as_deref()
5788                    .is_some_and(expr_contains_pattern_predicate)
5789        }
5790        Expr::ListComprehension(lc) => {
5791            expr_contains_pattern_predicate(&lc.list)
5792                || lc
5793                    .filter
5794                    .as_deref()
5795                    .is_some_and(expr_contains_pattern_predicate)
5796                || lc
5797                    .projection
5798                    .as_deref()
5799                    .is_some_and(expr_contains_pattern_predicate)
5800        }
5801        Expr::Quantifier(q) => {
5802            expr_contains_pattern_predicate(&q.list)
5803                || expr_contains_pattern_predicate(&q.predicate)
5804        }
5805        Expr::PatternComprehension(pc) => {
5806            pc.filter
5807                .as_deref()
5808                .is_some_and(expr_contains_pattern_predicate)
5809                || expr_contains_pattern_predicate(&pc.projection)
5810        }
5811        _ => false,
5812    }
5813}
5814
5815fn relationship_type_alternatives(pattern: &PathPattern) -> Vec<PathPattern> {
5816    let Some(PathElement::Rel(rel)) = pattern.elements.get(1) else {
5817        return vec![pattern.clone()];
5818    };
5819    if rel.types.len() <= 1 {
5820        return vec![pattern.clone()];
5821    }
5822
5823    rel.types
5824        .iter()
5825        .map(|rel_type| {
5826            let mut alternative = pattern.clone();
5827            let PathElement::Rel(rel) = &mut alternative.elements[1] else {
5828                unreachable!("single-relationship pattern shape was checked")
5829            };
5830            rel.types = vec![rel_type.clone()];
5831            alternative
5832        })
5833        .collect()
5834}
5835
5836fn is_single_relationship_pattern(pattern: &PathPattern) -> bool {
5837    matches!(
5838        pattern.elements.as_slice(),
5839        [
5840            PathElement::Node(_),
5841            PathElement::Rel(_),
5842            PathElement::Node(_)
5843        ]
5844    )
5845}
5846
5847fn pattern_has_var_length_relationship_properties(pattern: &PathPattern) -> bool {
5848    pattern.elements.iter().any(|element| {
5849        let PathElement::Rel(rel) = element else {
5850            return false;
5851        };
5852        (rel.min_hops.is_some() || rel.max_hops.is_some()) && rel.properties.is_some()
5853    })
5854}
5855
5856fn collect_pattern_disjunction<'a>(
5857    expr: &'a Expr,
5858    alternatives: &mut Vec<&'a PatternPredicate>,
5859) -> bool {
5860    match expr {
5861        Expr::Parenthesized { inner, .. } => collect_pattern_disjunction(inner, alternatives),
5862        Expr::PatternPredicate(pp) => {
5863            alternatives.push(pp);
5864            true
5865        }
5866        Expr::BinaryOp(graphforge_ast::BinaryOp {
5867            op: AstBinOp::Or,
5868            left,
5869            right,
5870            ..
5871        }) => {
5872            collect_pattern_disjunction(left, alternatives)
5873                && collect_pattern_disjunction(right, alternatives)
5874        }
5875        _ => false,
5876    }
5877}
5878
5879struct MixedPatternBranch<'a> {
5880    pattern: &'a PatternPredicate,
5881    scalar_filters: Vec<&'a Expr>,
5882}
5883
5884fn collect_mixed_pattern_disjunction<'a>(
5885    expr: &'a Expr,
5886    branches: &mut Vec<MixedPatternBranch<'a>>,
5887) -> bool {
5888    match expr {
5889        Expr::Parenthesized { inner, .. } => collect_mixed_pattern_disjunction(inner, branches),
5890        Expr::BinaryOp(graphforge_ast::BinaryOp {
5891            op: AstBinOp::Or,
5892            left,
5893            right,
5894            ..
5895        }) => {
5896            collect_mixed_pattern_disjunction(left, branches)
5897                && collect_mixed_pattern_disjunction(right, branches)
5898        }
5899        branch => {
5900            let mut conjuncts = Vec::new();
5901            collect_conjuncts(branch, &mut conjuncts);
5902            let mut pattern = None;
5903            let mut scalar_filters = Vec::new();
5904            for conjunct in conjuncts {
5905                let conjunct = strip_parens(conjunct);
5906                if let Expr::PatternPredicate(found) = conjunct {
5907                    if pattern.replace(found).is_some() {
5908                        return false;
5909                    }
5910                } else if expr_contains_pattern_predicate(conjunct) {
5911                    return false;
5912                } else {
5913                    scalar_filters.push(conjunct);
5914                }
5915            }
5916            let Some(pattern) = pattern else {
5917                return false;
5918            };
5919            branches.push(MixedPatternBranch {
5920                pattern,
5921                scalar_filters,
5922            });
5923            true
5924        }
5925    }
5926}
5927
5928fn collect_conjuncts<'a>(expr: &'a Expr, conjuncts: &mut Vec<&'a Expr>) {
5929    match expr {
5930        Expr::Parenthesized { inner, .. } => collect_conjuncts(inner, conjuncts),
5931        Expr::BinaryOp(graphforge_ast::BinaryOp {
5932            op: AstBinOp::And,
5933            left,
5934            right,
5935            ..
5936        }) => {
5937            collect_conjuncts(left, conjuncts);
5938            collect_conjuncts(right, conjuncts);
5939        }
5940        other => conjuncts.push(other),
5941    }
5942}
5943
5944fn plan_references_any_var(plan: &GraphPlan, vars: &HashSet<VarId>) -> bool {
5945    let expression_reference = (0..plan.exprs.len()).any(|index| {
5946        let index = u32::try_from(index).expect("ExprArena length is capped at u32::MAX");
5947        matches!(
5948            plan.exprs.get(ExprId(index)),
5949            IrExpr::VarRef(var) if vars.contains(var)
5950        )
5951    });
5952    expression_reference
5953        || plan.ops.iter().any(|op| match op {
5954            GraphOp::NodeScan { var, .. }
5955            | GraphOp::EdgeScan { var, .. }
5956            | GraphOp::TypedEdgeScan { var, .. } => vars.contains(var),
5957            GraphOp::Expand { src, edge, dst, .. } => {
5958                vars.contains(src) || vars.contains(edge) || vars.contains(dst)
5959            }
5960            GraphOp::Optional { child }
5961            | GraphOp::Exists { child, .. }
5962            | GraphOp::PatternComprehension { child, .. }
5963            | GraphOp::ListElementPatternComprehension { child, .. } => {
5964                plan_references_any_var(child, vars)
5965            }
5966            GraphOp::Union { inputs, .. } => inputs
5967                .iter()
5968                .any(|input| plan_references_any_var(input, vars)),
5969            _ => false,
5970        })
5971}
5972
5973fn graph_op_bound_vars(op: &GraphOp) -> Vec<VarId> {
5974    match op {
5975        GraphOp::NodeScan { var, .. }
5976        | GraphOp::EdgeScan { var, .. }
5977        | GraphOp::TypedEdgeScan { var, .. } => vec![*var],
5978        GraphOp::Expand { src, edge, dst, .. } => vec![*src, *edge, *dst],
5979        _ => Vec::new(),
5980    }
5981}
5982
5983fn pattern_references_bound_var(pattern: &PathPattern, s: &BinderState) -> bool {
5984    pattern.elements.iter().any(|element| match element {
5985        PathElement::Node(node) => node
5986            .var
5987            .as_deref()
5988            .is_some_and(|name| s.vars.contains_key(name)),
5989        PathElement::Rel(rel) => rel
5990            .var
5991            .as_deref()
5992            .is_some_and(|name| s.vars.contains_key(name)),
5993    })
5994}
5995
5996/// Expand a `RETURN *` wildcard into one item per in-scope NAMED variable
5997/// (#598). `*` parses as a `Var` named `"*"`; here it becomes the current
5998/// variables (e.g. `a, b` for `MATCH (a)-->(b) RETURN *`). The TCK compares
5999/// result columns by header name, so order is immaterial — emit them sorted for
6000/// a deterministic plan. Non-wildcard items pass through unchanged; a `*` with
6001/// no in-scope variables expands to nothing (the projection then errors, as
6002/// Cypher requires at least one).
6003///
6004/// Anonymous pattern elements (no user name) are not in scope and so are not
6005/// returned. Named path bindings live outside `vars`, so include both maps.
6006fn expand_projection_wildcard(items: &[ReturnItem], s: &BinderState) -> Vec<ReturnItem> {
6007    let is_star = |e: &Expr| matches!(e, Expr::Var(VarRef { name, .. }) if name == "*");
6008    if !items.iter().any(|i| is_star(&i.expr)) {
6009        return items.to_vec();
6010    }
6011    let mut names: Vec<String> = s.vars.keys().chain(s.path_vars.keys()).cloned().collect();
6012    names.sort();
6013    names.dedup();
6014    let mut out = Vec::new();
6015    for item in items {
6016        if is_star(&item.expr) {
6017            for name in &names {
6018                out.push(ReturnItem {
6019                    expr: Expr::Var(VarRef {
6020                        name: name.clone(),
6021                        span: item.span,
6022                    }),
6023                    alias: None,
6024                    // `RETURN *` names each column by its variable.
6025                    display: Some(name.clone()),
6026                    span: item.span,
6027                });
6028            }
6029        } else {
6030            out.push(item.clone());
6031        }
6032    }
6033    out
6034}
6035
6036fn reject_empty_projection_wildcard(items: &[ReturnItem], s: &mut BinderState) -> bool {
6037    let Some(star) = items
6038        .iter()
6039        .find(|item| matches!(&item.expr, Expr::Var(VarRef { name, .. }) if name == "*"))
6040    else {
6041        return false;
6042    };
6043    if !s.vars.is_empty() || !s.path_vars.is_empty() {
6044        return false;
6045    }
6046    s.errors.push(BindError::new(
6047        BindErrorKind::InvalidArgument,
6048        star.span,
6049        "projection wildcard requires at least one variable in scope",
6050    ));
6051    true
6052}
6053
6054fn ensure_var(name: Option<&String>, s: &mut BinderState) -> VarId {
6055    if let Some(n) = name {
6056        ensure_var_name(n, s)
6057    } else {
6058        alloc_anon_var(s)
6059    }
6060}
6061
6062fn bound_rel_type_conflict(
6063    edge_var: VarId,
6064    rel_name: Option<&str>,
6065    is_scalar_hop: bool,
6066    s: &BinderState,
6067) -> bool {
6068    is_scalar_hop
6069        && matches!(
6070            (s.edge_rel_names.get(&edge_var).and_then(Option::as_deref), rel_name),
6071            (Some(prev), Some(next)) if prev != next
6072        )
6073}
6074
6075fn push_false_filter(s: &mut BinderState) {
6076    let predicate = s.builder.push_expr(IrExpr::Literal(IrLiteral::Bool(false)));
6077    s.builder.push_op_mut(GraphOp::Filter { predicate });
6078}
6079
6080fn push_conjunction(mut predicates: Vec<ExprId>, s: &mut BinderState) -> ExprId {
6081    let Some(mut acc) = predicates.pop() else {
6082        return s.builder.push_expr(IrExpr::Literal(IrLiteral::Bool(true)));
6083    };
6084    while let Some(next) = predicates.pop() {
6085        acc = s.builder.push_expr(IrExpr::BinaryOp {
6086            op: BinaryOpKind::And,
6087            left: next,
6088            right: acc,
6089        });
6090    }
6091    acc
6092}
6093
6094/// Record a named pattern variable's [`VarKind`], or emit a
6095/// `VariableKindConflict` bind error if `id` was already bound to a different
6096/// kind (openCypher `VariableTypeConflict`, e.g. a relationship variable reused
6097/// as a node pattern — #956). Only called for NAMED variables; anonymous
6098/// anonymous elements cannot conflict but still record their owner-relevant kind.
6099fn ensure_pattern_var(name: Option<&str>, kind: VarKind, span: Span, s: &mut BinderState) -> VarId {
6100    let Some(name) = name else {
6101        let id = alloc_anon_var(s);
6102        s.var_kinds.insert(id, kind);
6103        return id;
6104    };
6105    if let Some(&existing) = s.vars.get(name)
6106        && !s.var_kinds.contains_key(&existing)
6107        && !s.node_vars.contains_key(&existing)
6108        && !s.edge_rel_names.contains_key(&existing)
6109    {
6110        s.errors.push(BindError::new(
6111            BindErrorKind::VariableKindConflict,
6112            span,
6113            format!("variable `{name}` is bound as a value but used here as {kind}"),
6114        ));
6115        return existing;
6116    }
6117    let id = ensure_var_name(name, s);
6118    bind_var_kind(id, kind, name, span, s);
6119    id
6120}
6121
6122fn bind_var_kind(id: VarId, kind: VarKind, name: &str, span: Span, s: &mut BinderState) {
6123    // A name already bound as a PATH variable (`MATCH r = ()-[]-()`) reused as a
6124    // node/relationship pattern (`MATCH (r)`) is a kind conflict too. Path vars
6125    // live in `path_vars` (no `VarId` in `vars`/`var_kinds`), so `ensure_var`
6126    // would otherwise mint a fresh var and miss the clash (#956).
6127    if s.path_vars.contains_key(name) {
6128        s.errors.push(BindError::new(
6129            BindErrorKind::VariableKindConflict,
6130            span,
6131            format!("variable `{name}` is bound as a path but used here as {kind}"),
6132        ));
6133        return;
6134    }
6135    match s.var_kinds.get(&id) {
6136        Some(VarKind::Unknown) => {
6137            s.var_kinds.insert(id, kind);
6138        }
6139        Some(prev) if *prev != kind => s.errors.push(BindError::new(
6140            BindErrorKind::VariableKindConflict,
6141            span,
6142            format!("variable `{name}` is bound as {prev} but used here as {kind}"),
6143        )),
6144        _ => {
6145            s.var_kinds.insert(id, kind);
6146        }
6147    }
6148}
6149
6150fn alloc_anon_var(s: &mut BinderState) -> VarId {
6151    let id = VarId(s.next_var);
6152    s.next_var += 1;
6153    id
6154}
6155
6156fn ensure_var_name(name: &str, s: &mut BinderState) -> VarId {
6157    if let Some(&existing) = s.vars.get(name) {
6158        return existing;
6159    }
6160    let id = VarId(s.next_var);
6161    s.next_var += 1;
6162    s.vars.insert(name.to_owned(), id);
6163    id
6164}
6165
6166#[derive(Debug, Clone, PartialEq, Eq)]
6167enum BoundPropertyOwner {
6168    Entity(Option<String>),
6169    Relationship(Option<String>),
6170    Value,
6171}
6172fn property_owner_for_expr(expr: &Expr, s: &BinderState) -> BoundPropertyOwner {
6173    let Expr::Var(VarRef { name, .. }) = expr else {
6174        return BoundPropertyOwner::Value;
6175    };
6176    s.vars.get(name).map_or(BoundPropertyOwner::Value, |var| {
6177        property_owner_for_var(*var, s)
6178    })
6179}
6180fn property_owner_for_var(var: VarId, s: &BinderState) -> BoundPropertyOwner {
6181    let kind = s.var_kinds.get(&var).copied().or_else(|| {
6182        if s.node_vars.contains_key(&var) {
6183            Some(VarKind::Node)
6184        } else if s.edge_rel_names.contains_key(&var) {
6185            Some(VarKind::Relationship)
6186        } else {
6187            None
6188        }
6189    });
6190    match kind {
6191        Some(VarKind::Node) => BoundPropertyOwner::Entity(s.node_vars.get(&var).cloned().flatten()),
6192        Some(VarKind::Relationship) => {
6193            BoundPropertyOwner::Relationship(s.edge_rel_names.get(&var).cloned().flatten())
6194        }
6195        Some(VarKind::Unknown) | None => BoundPropertyOwner::Value,
6196    }
6197}
6198
6199// ---------------------------------------------------------------------------
6200// BinderState
6201// ---------------------------------------------------------------------------
6202
6203struct BinderState {
6204    vars: HashMap<String, VarId>,
6205    /// Named path variables (`MATCH p = (a)-[*]->(b)`), kept out of `vars`:
6206    /// a path is not a column-backed value — `nodes(p)` / `relationships(p)` /
6207    /// `length(p)` are rewritten at bind time onto the constituent variables
6208    /// recorded here, and `p` itself never reaches the plan (#754).
6209    path_vars: HashMap<String, PathBinding>,
6210    /// Variables bound to a node pattern element (those with a `NodeScan`),
6211    /// mapped to the pattern's label name (if any). A bare `RETURN n` over one
6212    /// of these materializes a whole node value (#785); the label is passed
6213    /// through to lowering since the ontology map is empty in exploratory mode.
6214    node_vars: HashMap<VarId, Option<String>>,
6215    /// Fixed-hop relationship variables mapped to their `(src, dst)` node vars,
6216    /// recorded at `Expand` build. `startNode(r)` / `endNode(r)` over a matched
6217    /// relationship rewrite onto these endpoints (#753) — so they return the
6218    /// node value (reusing #785), not the raw UUID. Variable-length edges bind
6219    /// to a list, not one relationship, so they are deliberately not recorded.
6220    edge_vars: HashMap<VarId, (VarId, VarId)>,
6221    /// Fixed-hop relationship variables mapped to their relation type name as
6222    /// written in the pattern. A bare `RETURN r` uses this to materialize a
6223    /// whole relationship value; variable-length relationship vars are lists and
6224    /// deliberately absent here.
6225    edge_rel_names: HashMap<VarId, Option<String>>,
6226    /// Explicit variable-length syntax whose `1..1` bounds route through the
6227    /// scalar fixed-hop executor but whose relationship variable is still a list.
6228    scalar_list_edges: HashSet<VarId>,
6229    /// Each named pattern variable's semantic kind (node vs relationship),
6230    /// used to reject a later incompatible use as a `VariableKindConflict`
6231    /// (#956). Keyed by the immutable `VarId`; entries never need clearing
6232    /// across a WITH scope reset because a fresh scope mints fresh `VarId`s.
6233    var_kinds: HashMap<VarId, VarKind>,
6234    next_var: u32,
6235    builder: GraphPlanBuilder,
6236    errors: Vec<BindError>,
6237    warnings: Vec<BindError>,
6238    /// Nested pattern comprehensions captured while binding a graph-valued list
6239    /// comprehension. `None` means ordinary relational pattern-comprehension
6240    /// lowering; `Some` lifts the child into one list-element graph operation.
6241    captured_pattern_comprehensions: Option<Vec<(Box<GraphPlan>, VarId)>>,
6242    /// Lexical nesting level of `exists { ... }` bodies.
6243    existential_depth: usize,
6244    /// True only when the whole query consists of one procedure call.
6245    standalone_call: bool,
6246}
6247
6248/// The ordered composition of a named path: the node variables along the
6249/// traversal and one entry per relationship segment, in pattern order.
6250#[derive(Clone)]
6251struct PathBinding {
6252    /// Node variables in traversal order (`segments.len() + 1` entries).
6253    nodes: Vec<VarId>,
6254    segments: Vec<PathSegment>,
6255}
6256
6257struct ForwardedEdgeBinding {
6258    var: VarId,
6259    rel_name: Option<String>,
6260    endpoints: Option<(VarId, VarId)>,
6261}
6262
6263struct GroupedEdgeBinding {
6264    alias: String,
6265    var: VarId,
6266    rel_name: Option<String>,
6267    endpoints: Option<(VarId, VarId)>,
6268}
6269
6270/// One relationship segment of a named path.
6271#[derive(Clone)]
6272struct PathSegment {
6273    edge: VarId,
6274    /// `true` for a variable-length hop (`[*..]`) — the edge var binds to a
6275    /// relationship-list column; `false` for a fixed single hop.
6276    var_len: bool,
6277    /// The relation-type name as written (`KNOWS` in `[:KNOWS]`), captured so
6278    /// a fixed segment's `relationships(p)` struct can carry `rel_type`
6279    /// without the lowerer needing catalog access. `None` for an untyped hop.
6280    rel_name: Option<String>,
6281}
6282
6283// ---------------------------------------------------------------------------
6284// Helper functions
6285// ---------------------------------------------------------------------------
6286
6287fn classify_uuid_parameter(value: &IrLiteral) -> Option<UuidParamClass> {
6288    match value {
6289        IrLiteral::Uuid(_) => Some(UuidParamClass::ExactUuid),
6290        IrLiteral::List(items) if items.iter().any(ir_literal_contains_uuid) => {
6291            Some(UuidParamClass::ContainsUuid)
6292        }
6293        IrLiteral::Map(entries)
6294            if entries
6295                .iter()
6296                .any(|(_, value)| ir_literal_contains_uuid(value)) =>
6297        {
6298            Some(UuidParamClass::ContainsUuid)
6299        }
6300        _ => None,
6301    }
6302}
6303
6304fn ir_literal_contains_uuid(value: &IrLiteral) -> bool {
6305    classify_uuid_parameter(value).is_some()
6306}
6307
6308fn lower_literal(lit: &Literal) -> IrLiteral {
6309    match lit {
6310        Literal::Int(n, _) => IrLiteral::Int(*n),
6311        Literal::Float(f, _) => IrLiteral::Float(*f),
6312        Literal::Str(s, _) => IrLiteral::Str(s.clone()),
6313        Literal::Bool(b, _) => IrLiteral::Bool(*b),
6314        Literal::Null(_) => IrLiteral::Null,
6315    }
6316}
6317
6318fn lower_binop(op: AstBinOp) -> BinaryOpKind {
6319    match op {
6320        AstBinOp::Eq => BinaryOpKind::Eq,
6321        AstBinOp::Neq => BinaryOpKind::Neq,
6322        AstBinOp::Lt => BinaryOpKind::Lt,
6323        AstBinOp::Lte => BinaryOpKind::Lte,
6324        AstBinOp::Gt => BinaryOpKind::Gt,
6325        AstBinOp::Gte => BinaryOpKind::Gte,
6326        AstBinOp::And => BinaryOpKind::And,
6327        AstBinOp::Or => BinaryOpKind::Or,
6328        AstBinOp::Xor => BinaryOpKind::Xor,
6329        AstBinOp::Add => BinaryOpKind::Add,
6330        AstBinOp::Sub => BinaryOpKind::Sub,
6331        AstBinOp::Mul => BinaryOpKind::Mul,
6332        AstBinOp::Div => BinaryOpKind::Div,
6333        AstBinOp::Mod => BinaryOpKind::Mod,
6334        AstBinOp::Pow => BinaryOpKind::Pow,
6335        AstBinOp::Concat => unreachable!("handled separately"),
6336    }
6337}
6338
6339fn lower_direction(dir: graphforge_ast::Direction) -> Direction {
6340    match dir {
6341        graphforge_ast::Direction::Out => Direction::Out,
6342        graphforge_ast::Direction::In => Direction::In,
6343        graphforge_ast::Direction::Undirected => Direction::Undirected,
6344    }
6345}
6346
6347#[derive(Clone, Copy)]
6348enum RowCountOp {
6349    Skip,
6350    Limit,
6351}
6352
6353impl RowCountOp {
6354    fn keyword(self) -> &'static str {
6355        match self {
6356            Self::Skip => "SKIP",
6357            Self::Limit => "LIMIT",
6358        }
6359    }
6360
6361    fn graph_op(self, count: u64) -> GraphOp {
6362        match self {
6363            Self::Skip => GraphOp::Skip { count },
6364            Self::Limit => GraphOp::Limit { count },
6365        }
6366    }
6367
6368    fn graph_param_op(self, name: String) -> GraphOp {
6369        match self {
6370            Self::Skip => GraphOp::SkipParam { name },
6371            Self::Limit => GraphOp::LimitParam { name },
6372        }
6373    }
6374
6375    fn graph_expr_op(self, expr: ExprId) -> GraphOp {
6376        match self {
6377            Self::Skip => GraphOp::SkipExpr { expr },
6378            Self::Limit => GraphOp::LimitExpr { expr },
6379        }
6380    }
6381}
6382
6383fn push_skip_limit(
6384    binder: &Binder,
6385    skip: Option<&Expr>,
6386    limit: Option<&Expr>,
6387    s: &mut BinderState,
6388) {
6389    if let Some(expr) = skip {
6390        push_row_count_op(binder, RowCountOp::Skip, expr, s);
6391    }
6392    if let Some(expr) = limit {
6393        push_row_count_op(binder, RowCountOp::Limit, expr, s);
6394    }
6395}
6396
6397fn push_row_count_op(binder: &Binder, kind: RowCountOp, expr: &Expr, s: &mut BinderState) {
6398    if binder.typed_uuid_param_in(expr).is_some() {
6399        binder.lower_expr(expr, expr.span(), s);
6400        return;
6401    }
6402    if let Some(n) = extract_non_negative_int_constant(expr) {
6403        s.builder.push_op_mut(kind.graph_op(n));
6404        return;
6405    }
6406    if let Some(name) = extract_parameter_name(expr) {
6407        s.builder.push_op_mut(kind.graph_param_op(name));
6408        return;
6409    }
6410    if extract_int_constant(expr).is_some() || is_float_constant(expr) {
6411        s.errors.push(BindError::new(
6412            BindErrorKind::InvalidArgument,
6413            expr.span(),
6414            format!("{} requires a non-negative integer value", kind.keyword()),
6415        ));
6416        return;
6417    }
6418    let mut refs = Vec::new();
6419    collect_grouping_refs(expr, &mut refs);
6420    if refs.is_empty() && !expr_contains_aggregate(expr) && row_count_expr_is_integer(expr) {
6421        let expr = binder.lower_expr(expr, expr.span(), s);
6422        s.builder.push_op_mut(kind.graph_expr_op(expr));
6423        return;
6424    }
6425    s.errors.push(BindError::new(
6426        BindErrorKind::InvalidArgument,
6427        expr.span(),
6428        format!(
6429            "{} requires a non-negative variable-independent integer expression",
6430            kind.keyword()
6431        ),
6432    ));
6433}
6434
6435fn row_count_expr_is_integer(expr: &Expr) -> bool {
6436    match expr {
6437        Expr::Literal(Literal::Int(_, _)) | Expr::Param(_) => true,
6438        Expr::Parenthesized { inner, .. } => row_count_expr_is_integer(inner),
6439        Expr::UnaryOp(graphforge_ast::UnaryOp {
6440            op: AstUnOp::Neg,
6441            expr,
6442            ..
6443        }) => row_count_expr_is_integer(expr),
6444        Expr::BinaryOp(binary) => {
6445            row_count_expr_is_integer(&binary.left) && row_count_expr_is_integer(&binary.right)
6446        }
6447        Expr::FunctionCall(call) => is_function_named(call, "toInteger"),
6448        _ => false,
6449    }
6450}
6451
6452fn is_float_constant(expr: &Expr) -> bool {
6453    match expr {
6454        Expr::Literal(Literal::Float(_, _)) => true,
6455        Expr::Parenthesized { inner, .. } => is_float_constant(inner),
6456        Expr::UnaryOp(graphforge_ast::UnaryOp {
6457            op: AstUnOp::Neg,
6458            expr,
6459            ..
6460        }) => is_float_constant(expr),
6461        _ => false,
6462    }
6463}
6464
6465fn extract_parameter_name(expr: &Expr) -> Option<String> {
6466    match expr {
6467        Expr::Param(graphforge_ast::ParamRef { name, .. }) => Some(name.clone()),
6468        Expr::Parenthesized { inner, .. } => extract_parameter_name(inner),
6469        _ => None,
6470    }
6471}
6472
6473fn extract_non_negative_int_constant(expr: &Expr) -> Option<u64> {
6474    let n = extract_int_constant(expr)?;
6475    u64::try_from(n).ok()
6476}
6477
6478fn extract_int_constant(expr: &Expr) -> Option<i64> {
6479    match expr {
6480        Expr::Literal(Literal::Int(n, _)) => Some(*n),
6481        Expr::Parenthesized { inner, .. } => extract_int_constant(inner),
6482        Expr::UnaryOp(graphforge_ast::UnaryOp {
6483            op: AstUnOp::Neg,
6484            expr,
6485            ..
6486        }) => extract_int_constant(expr)?.checked_neg(),
6487        _ => None,
6488    }
6489}
6490
6491/// Reject a duplicate EXPLICIT output column name in a RETURN/WITH projection
6492/// (`RETURN 1 AS a, 2 AS a` → openCypher `ColumnNameConflict`, #956). Only
6493/// explicit `AS` aliases are checked; implicit column names mirror the source
6494/// text, where a collision is both rare and self-inflicted.
6495/// Validate a relationship element in a CREATE/MERGE pattern (#956): exactly
6496/// one type (`NoSingleRelationshipType`), fixed-length (`CreatingVarLength`),
6497/// directed (`RequiresDirectedRelationship`), and not a reused already-bound
6498/// variable (`VariableAlreadyBound`). `bound_before` is the set of variables
6499/// bound before this clause.
6500fn validate_created_rel(
6501    rel: &graphforge_ast::RelPattern,
6502    bound_before: &std::collections::HashSet<VarId>,
6503    allow_undirected: bool,
6504    s: &mut BinderState,
6505) {
6506    let mut err = |m: &str| {
6507        s.errors.push(BindError::new(
6508            BindErrorKind::InvalidArgument,
6509            rel.span,
6510            m.to_string(),
6511        ));
6512    };
6513    if rel.types.len() != 1 {
6514        err("a created relationship must have exactly one type");
6515    }
6516    if rel.min_hops.is_some() || rel.max_hops.is_some() {
6517        err("cannot create a variable-length relationship");
6518    }
6519    if !allow_undirected && matches!(rel.direction, graphforge_ast::Direction::Undirected) {
6520        err("a created relationship must be directed");
6521    }
6522    if let Some(name) = rel.var.as_deref()
6523        && s.vars.get(name).is_some_and(|v| bound_before.contains(v))
6524    {
6525        s.errors.push(BindError::new(
6526            BindErrorKind::VariableAlreadyBound,
6527            rel.span,
6528            format!("relationship variable `{name}` is already bound"),
6529        ));
6530    }
6531}
6532
6533fn check_duplicate_aliases(items: &[ReturnItem], s: &mut BinderState) {
6534    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
6535    for item in items {
6536        if let Some(a) = item.alias.as_deref()
6537            && !seen.insert(a)
6538        {
6539            s.errors.push(BindError::new(
6540                BindErrorKind::InvalidArgument,
6541                item.span,
6542                format!("multiple result columns with the same name `{a}`"),
6543            ));
6544        }
6545    }
6546}
6547
6548// ---------------------------------------------------------------------------
6549// Tests
6550// ---------------------------------------------------------------------------
6551
6552#[cfg(test)]
6553mod tests {
6554    use super::*;
6555    use crate::{ProcedureDefinition, ProcedureField};
6556    use arrow::array::{StringArray, UInt32Array, UInt64Array};
6557    use graphforge_cypher::parse;
6558    use graphforge_ontology::{OntologyCompiler, OntologyDoc, OntologyHandle};
6559
6560    fn make_binder(mode: OntologyMode) -> (Binder, Arc<Mutex<RuntimeCatalog>>) {
6561        let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
6562        let binder = Binder::new(None, Arc::clone(&catalog), mode);
6563        (binder, catalog)
6564    }
6565
6566    fn empty_state(mode: OntologyMode) -> BinderState {
6567        BinderState {
6568            vars: HashMap::new(),
6569            path_vars: HashMap::new(),
6570            node_vars: HashMap::new(),
6571            edge_vars: HashMap::new(),
6572            edge_rel_names: HashMap::new(),
6573            scalar_list_edges: HashSet::new(),
6574            var_kinds: HashMap::new(),
6575            next_var: 0,
6576            builder: GraphPlan::builder("openCypher").ontology_mode(mode),
6577            errors: Vec::new(),
6578            warnings: Vec::new(),
6579            captured_pattern_comprehensions: None,
6580            existential_depth: 0,
6581            standalone_call: false,
6582        }
6583    }
6584
6585    fn parsed_return_expr(source: &str) -> Expr {
6586        let ast =
6587            parse(source).unwrap_or_else(|error| panic!("failed to parse {source:?}: {error}"));
6588        let Some(AstClause::Return(clause)) = ast.clauses.last() else {
6589            panic!("expected RETURN clause for {source:?}");
6590        };
6591        clause.items[0].expr.clone()
6592    }
6593
6594    #[test]
6595    fn expression_rewriters_traverse_every_public_ast_container() {
6596        let expressions = [
6597            "RETURN a + 1",
6598            "RETURN NOT a",
6599            "RETURN (a)",
6600            "RETURN a.name",
6601            "RETURN coalesce(a, 1)",
6602            "RETURN [a, 1]",
6603            "RETURN {k: a}",
6604            "RETURN CASE a WHEN 1 THEN a ELSE 0 END",
6605            "RETURN [x IN a WHERE x > 0 | x]",
6606            "RETURN all(x IN a WHERE x > 0)",
6607            "RETURN a IS NULL",
6608            "RETURN a IN [1]",
6609            "RETURN a STARTS WITH 'x'",
6610            "RETURN a =~ 'x'",
6611            "RETURN a:Person",
6612            "RETURN [(a)-->(b) WHERE a.name = 'x' | a]",
6613            "RETURN exists { (a)-->(b) WHERE a.name = 'x' }",
6614        ]
6615        .map(parsed_return_expr);
6616        let alias_projection = ReturnItem {
6617            expr: Expr::Literal(Literal::Int(7, Span::new(0, 1))),
6618            alias: Some("a".into()),
6619            display: None,
6620            span: Span::new(0, 1),
6621        };
6622        let grouping = parsed_return_expr("RETURN a");
6623        let bindings = [(grouping, "group_a".into(), VarId(0))];
6624
6625        for expression in expressions {
6626            let mut refs = Vec::new();
6627            collect_grouping_refs(&expression, &mut refs);
6628            let alias_rewritten = rewrite_projection_alias_refs(
6629                expression.clone(),
6630                std::slice::from_ref(&alias_projection),
6631            );
6632            let grouping_rewritten = rewrite_grouping_refs(expression.clone(), &bindings);
6633            assert_eq!(alias_rewritten.span(), expression.span());
6634            assert_eq!(grouping_rewritten.span(), expression.span());
6635            let _ = expr_contains_pattern_comprehension(&expression);
6636            let _ = expr_contains_pattern_predicate(&expression);
6637            let _ = expr_contains_volatile_function(&expression);
6638            let _ = expr_contains_aggregate_inside_aggregate(&expression, false);
6639        }
6640    }
6641
6642    fn catalog_entry(catalog: &RuntimeCatalog, kind: &str, name: &str) -> (u32, u64) {
6643        let batch = catalog.to_record_batch();
6644        let kinds = batch
6645            .column(0)
6646            .as_any()
6647            .downcast_ref::<StringArray>()
6648            .unwrap();
6649        let names = batch
6650            .column(1)
6651            .as_any()
6652            .downcast_ref::<StringArray>()
6653            .unwrap();
6654        let ids = batch
6655            .column(2)
6656            .as_any()
6657            .downcast_ref::<UInt32Array>()
6658            .unwrap();
6659        let counts = batch
6660            .column(3)
6661            .as_any()
6662            .downcast_ref::<UInt64Array>()
6663            .unwrap();
6664        let row = (0..batch.num_rows())
6665            .find(|&row| kinds.value(row) == kind && names.value(row) == name)
6666            .unwrap_or_else(|| panic!("missing catalog entry {kind}:{name}"));
6667        (ids.value(row), counts.value(row))
6668    }
6669
6670    fn strict_property_binder(shadow_inherited: bool) -> (Binder, Arc<Mutex<RuntimeCatalog>>) {
6671        let shadow = if shadow_inherited {
6672            r#", {"owner":"Host","name":"inherited","type":"utf8"}"#
6673        } else {
6674            ""
6675        };
6676        let doc: OntologyDoc = serde_json::from_str(&format!(
6677            r#"{{"ontology_id":"strict-properties","version":"1","entity_types":[{{"name":"Asset"}},{{"name":"Host","parent":"Asset"}}],"relation_types":[{{"name":"R","src":"Host","dst":"Host"}},{{"name":"S","src":"Host","dst":"Host"}}],"properties":[{{"owner":"Asset","name":"inherited","type":"utf8"}},{{"owner":"Host","name":"direct","type":"utf8"}},{{"owner":"R","name":"weight","type":"int64"}},{{"owner":"S","name":"weight","type":"int64"}},{{"owner":"Asset","name":"shared","type":"utf8"}},{{"owner":"R","name":"shared","type":"utf8"}}{shadow}]}}"#
6678        ))
6679        .unwrap();
6680        let ontology = OntologyCompiler::compile(&doc).unwrap();
6681        let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
6682        catalog.lock().unwrap().intern_property("preexisting", None);
6683        (
6684            Binder::new(
6685                Some(OntologyHandle::new(ontology)),
6686                Arc::clone(&catalog),
6687                OntologyMode::Strict,
6688            ),
6689            catalog,
6690        )
6691    }
6692
6693    fn plan_property_ids(plan: &GraphPlan) -> Vec<PropId> {
6694        (0..u32::try_from(plan.exprs.len()).unwrap())
6695            .filter_map(|index| match plan.exprs.get(ExprId(index)) {
6696                IrExpr::PropertyAccess { prop, .. } => Some(*prop),
6697                _ => None,
6698            })
6699            .collect()
6700    }
6701
6702    fn property_error(binder: &Binder, query: &str, kind: BindErrorKind, span: &str) -> BindError {
6703        let errors = binder.bind(&parse(query).unwrap()).expect_err(query);
6704        assert_eq!(errors.len(), 1, "{query}: {errors:?}");
6705        let error = errors.into_iter().next().unwrap();
6706        assert_eq!(error.kind, kind);
6707        assert_eq!(&query[error.span.start..error.span.end], span);
6708        error
6709    }
6710
6711    #[test]
6712    fn exploratory_unknown_label_succeeds() {
6713        let (binder, catalog) = make_binder(OntologyMode::Exploratory);
6714        let ast = parse("MATCH (a:UnknownLabel)-[:UNKNOWN_REL]->(b) RETURN a").unwrap();
6715        let plan = binder.bind(&ast).expect("exploratory bind should succeed");
6716
6717        assert!(
6718            plan.ops
6719                .iter()
6720                .any(|op| matches!(op, GraphOp::NodeScan { .. }))
6721        );
6722        let cat = catalog.lock().unwrap();
6723        assert!(cat.contains_entity_type("UnknownLabel"));
6724        assert!(cat.relation_types().contains(&"UNKNOWN_REL"));
6725    }
6726
6727    #[test]
6728    fn bind_catalog_mutations_commit_only_after_success() {
6729        let (binder, catalog) = make_binder(OntologyMode::Exploratory);
6730        {
6731            let mut catalog = catalog.lock().unwrap();
6732            assert_eq!(catalog.intern_label("Seed").0, 0);
6733            assert_eq!(catalog.intern_relation_type("SEED_REL").0, 1);
6734            assert_eq!(catalog.intern_property("seed", None).0, 0);
6735        }
6736        let before = catalog.lock().unwrap().to_record_batch();
6737
6738        let errors = binder
6739            .bind(
6740                &parse(
6741                    "MATCH (n:Rejected)-[:REJECTED_REL]->() \
6742                     RETURN n.rejected, missing",
6743                )
6744                .unwrap(),
6745            )
6746            .expect_err("a later semantic error must reject every staged observation");
6747        assert!(
6748            errors
6749                .iter()
6750                .any(|error| error.kind == BindErrorKind::UndeclaredVariable)
6751        );
6752        assert_eq!(
6753            catalog.lock().unwrap().to_record_batch(),
6754            before,
6755            "failed binding must leave entries, observations, timestamps, and IDs unchanged"
6756        );
6757
6758        binder
6759            .bind(
6760                &parse(
6761                    "MATCH (n:Accepted)-[:ACCEPTED_REL]->() \
6762                     RETURN n.accepted",
6763                )
6764                .unwrap(),
6765            )
6766            .expect("successful binding must publish the staged catalog");
6767        let catalog = catalog.lock().unwrap();
6768        assert_eq!(catalog_entry(&catalog, "entity_type", "Accepted"), (2, 1));
6769        assert_eq!(
6770            catalog_entry(&catalog, "relation_type", "ACCEPTED_REL"),
6771            (3, 1)
6772        );
6773        assert_eq!(catalog_entry(&catalog, "property", "accepted"), (1, 1));
6774    }
6775
6776    #[test]
6777    fn fixed_pattern_predicate_lowers_to_exists() {
6778        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6779        let ast = parse("MATCH (n) WHERE (n)-[:REL]->() RETURN n").unwrap();
6780        let plan = binder.bind(&ast).expect("pattern predicate binds");
6781
6782        let exists = plan
6783            .ops
6784            .iter()
6785            .find_map(|op| match op {
6786                GraphOp::Exists { child, negated } => Some((child, negated)),
6787                _ => None,
6788            })
6789            .expect("pattern predicate should lower to Exists");
6790        assert!(!*exists.1);
6791        assert!(
6792            exists
6793                .0
6794                .ops
6795                .iter()
6796                .any(|op| matches!(op, GraphOp::Expand { .. })),
6797            "child plan must match the relationship pattern"
6798        );
6799    }
6800
6801    #[test]
6802    fn relationship_uniqueness_is_scoped_to_each_path_pattern() {
6803        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6804        let ast = parse("MATCH (a)-[r1]->(b)-[r2]->(c), (x)-[r3]->(y) RETURN a").unwrap();
6805        let plan = binder.bind(&ast).expect("pattern binds");
6806
6807        let constraints: Vec<_> = plan
6808            .ops
6809            .iter()
6810            .filter_map(|op| match op {
6811                GraphOp::RelationshipUnique { edge, prior_edges } => {
6812                    Some((*edge, prior_edges.clone()))
6813                }
6814                _ => None,
6815            })
6816            .collect();
6817        assert_eq!(constraints.len(), 1);
6818        assert_eq!(constraints[0].1.len(), 1);
6819    }
6820
6821    #[test]
6822    fn simple_existential_subquery_allows_child_local_variables() {
6823        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6824        let ast = parse("MATCH (n) WHERE exists { (n)-[r]->(m) WHERE type(r) = 'REL' } RETURN n")
6825            .unwrap();
6826        let plan = binder.bind(&ast).expect("existential subquery binds");
6827
6828        let child = plan
6829            .ops
6830            .iter()
6831            .find_map(|op| match op {
6832                GraphOp::Exists { child, negated } => {
6833                    assert!(!negated);
6834                    Some(child)
6835                }
6836                _ => None,
6837            })
6838            .expect("expected Exists op");
6839        assert!(
6840            child
6841                .ops
6842                .iter()
6843                .any(|op| matches!(op, GraphOp::Expand { .. }))
6844        );
6845        assert!(
6846            child
6847                .ops
6848                .iter()
6849                .any(|op| matches!(op, GraphOp::Filter { .. }))
6850        );
6851    }
6852
6853    #[test]
6854    fn full_existential_correlation_is_scope_aware() {
6855        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6856        let correlated = parse(
6857            "MATCH (n) WHERE exists { MATCH (m) WHERE m.prop = n.prop RETURN true } RETURN n",
6858        )
6859        .unwrap();
6860        binder
6861            .bind(&correlated)
6862            .expect("an outer variable used only in a child expression must bind");
6863
6864        let shadowed = parse("MATCH (n) WHERE exists { WITH 1 AS n RETURN n } RETURN n").unwrap();
6865        let errors = binder
6866            .bind(&shadowed)
6867            .expect_err("a child-local alias must not count as outer correlation");
6868        assert!(
6869            errors.iter().any(|error| {
6870                error.kind == BindErrorKind::UndeclaredVariable
6871                    && error
6872                        .message
6873                        .contains("must reference at least one outer variable")
6874            }),
6875            "expected uncorrelated-subquery error, got {errors:?}"
6876        );
6877    }
6878
6879    #[test]
6880    fn with_nested_aggregate_lowers_to_aggregate_then_scope_reset() {
6881        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6882        let ast =
6883            parse("MATCH (me)--(you) WITH me.age AS age, me.age + count(you.age) AS agg RETURN *")
6884                .unwrap();
6885        let plan = binder.bind(&ast).expect("nested WITH aggregate binds");
6886        let aggregate = plan
6887            .ops
6888            .iter()
6889            .position(|op| matches!(op, GraphOp::Aggregate { .. }))
6890            .expect("aggregate op");
6891        let with = plan
6892            .ops
6893            .iter()
6894            .skip(aggregate + 1)
6895            .position(|op| matches!(op, GraphOp::With { .. }))
6896            .expect("post-aggregate scope reset");
6897        assert_eq!(with, 0);
6898    }
6899
6900    #[test]
6901    fn with_rejects_ambiguous_and_nested_aggregation() {
6902        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6903        let ambiguous =
6904            parse("MATCH (me)--(you) WITH me.age + count(you.age) AS agg RETURN agg").unwrap();
6905        let error = binder
6906            .bind(&ambiguous)
6907            .expect_err("ambiguous grouping must fail");
6908        assert!(
6909            error
6910                .iter()
6911                .any(|error| error.message.contains("ambiguous aggregation expression"))
6912        );
6913
6914        let nested = parse("MATCH (n) WITH count(count(*)) AS c RETURN c").unwrap();
6915        let error = binder
6916            .bind(&nested)
6917            .expect_err("nested aggregates must fail");
6918        assert!(
6919            error
6920                .iter()
6921                .any(|error| { error.message.contains("may not contain another aggregate") })
6922        );
6923    }
6924
6925    #[test]
6926    fn return_rejects_nested_and_volatile_aggregation() {
6927        for (query, message) in [
6928            (
6929                "RETURN count(count(*))",
6930                "may not contain another aggregate",
6931            ),
6932            (
6933                "RETURN count(rand())",
6934                "non-deterministic functions are not allowed",
6935            ),
6936        ] {
6937            let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6938            let ast = parse(query).unwrap();
6939            let errors = binder.bind(&ast).expect_err("query must fail at bind");
6940            assert!(
6941                errors.iter().any(|error| error.message.contains(message)),
6942                "missing {message:?} for {query}: {errors:?}"
6943            );
6944        }
6945    }
6946
6947    #[test]
6948    fn return_rejects_unknown_function_at_bind() {
6949        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6950        let ast = parse("MATCH (a) RETURN foo(a)").unwrap();
6951        let errors = binder.bind(&ast).expect_err("unknown function must fail");
6952        assert!(
6953            errors
6954                .iter()
6955                .any(|error| error.message.contains("unknown function `foo`"))
6956        );
6957    }
6958
6959    #[test]
6960    fn negated_pattern_predicate_lowers_to_anti_exists() {
6961        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6962        let ast = parse("MATCH (n) WHERE NOT (n)-[:REL]-() RETURN n").unwrap();
6963        let plan = binder.bind(&ast).expect("negated pattern predicate binds");
6964
6965        assert!(
6966            plan.ops
6967                .iter()
6968                .any(|op| { matches!(op, GraphOp::Exists { negated: true, .. }) })
6969        );
6970    }
6971
6972    #[test]
6973    fn multi_type_pattern_predicate_lowers_to_union_exists() {
6974        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6975        let ast = parse("MATCH (n), (m) WHERE (n)-[:REL1|REL2]-(m) RETURN n").unwrap();
6976        let plan = binder.bind(&ast).expect("multi-type predicate binds");
6977
6978        let inputs = plan
6979            .ops
6980            .iter()
6981            .find_map(|op| match op {
6982                GraphOp::Exists { child, .. } => match child.ops.as_slice() {
6983                    [GraphOp::Union { inputs, .. }] => Some(inputs),
6984                    _ => None,
6985                },
6986                _ => None,
6987            })
6988            .expect("multi-type predicate should lower to union-backed Exists");
6989        assert_eq!(inputs.len(), 2);
6990    }
6991
6992    #[test]
6993    fn or_pattern_predicate_lowers_to_union_exists() {
6994        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6995        let ast = parse("MATCH (n) WHERE (n)-[:REL1]-() OR (n)-[:REL2]-() RETURN n").unwrap();
6996        let plan = binder.bind(&ast).expect("OR predicate binds");
6997
6998        let inputs = plan
6999            .ops
7000            .iter()
7001            .find_map(|op| match op {
7002                GraphOp::Exists { child, .. } => match child.ops.as_slice() {
7003                    [GraphOp::Union { inputs, .. }] => Some(inputs),
7004                    _ => None,
7005                },
7006                _ => None,
7007            })
7008            .expect("OR predicate should lower to union-backed Exists");
7009        assert_eq!(inputs.len(), 2);
7010    }
7011
7012    #[test]
7013    fn pattern_predicate_rejects_new_named_variables() {
7014        expect_bind_error(
7015            "MATCH (n) WHERE (n)-[r]->() RETURN n",
7016            BindErrorKind::UndeclaredVariable,
7017        );
7018        expect_bind_error(
7019            "MATCH (n) WHERE (n)-->(m) RETURN n",
7020            BindErrorKind::UndeclaredVariable,
7021        );
7022    }
7023
7024    #[test]
7025    fn pattern_predicate_rejects_named_path_binding() {
7026        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7027        let mut ast = parse("MATCH (n) WHERE (n)-[:REL]->() RETURN n").unwrap();
7028        let AstClause::Match(m) = &mut ast.clauses[0] else {
7029            panic!("expected MATCH clause");
7030        };
7031        let where_clause = m.where_clause.as_mut().expect("expected WHERE");
7032        let Expr::PatternPredicate(pp) = &mut where_clause.predicate else {
7033            panic!("expected pattern predicate");
7034        };
7035        pp.pattern.var = Some("p".into());
7036
7037        let errs = binder
7038            .bind(&ast)
7039            .expect_err("predicate-local path binding should fail");
7040        assert!(
7041            errs.iter()
7042                .any(|err| err.kind == BindErrorKind::UndeclaredVariable),
7043            "expected undeclared path binding error, got {errs:?}"
7044        );
7045    }
7046
7047    #[test]
7048    fn var_length_pattern_predicate_rejects_relationship_properties() {
7049        expect_bind_error(
7050            "MATCH (n) WHERE (n)-[:REL* {k: 1}]->() RETURN n",
7051            BindErrorKind::InvalidArgument,
7052        );
7053    }
7054
7055    #[test]
7056    fn pattern_predicate_rejects_uncorrelated_patterns() {
7057        expect_bind_error(
7058            "MATCH (n) WHERE ()-[:REL]->() RETURN n",
7059            BindErrorKind::UndeclaredVariable,
7060        );
7061    }
7062
7063    #[test]
7064    fn named_pattern_comprehension_binds_correlated_child_projection() {
7065        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7066        let ast = parse("MATCH (n) RETURN [p = (n)-[:REL]->() | p] AS paths").unwrap();
7067        let plan = binder.bind(&ast).expect("pattern comprehension binds");
7068
7069        let (child, output) = plan
7070            .ops
7071            .iter()
7072            .find_map(|op| match op {
7073                GraphOp::PatternComprehension { child, output } => Some((child, output)),
7074                _ => None,
7075            })
7076            .expect("expected a PatternComprehension op");
7077        assert!(
7078            child
7079                .ops
7080                .iter()
7081                .any(|op| matches!(op, GraphOp::Expand { .. }))
7082        );
7083        let projection = child
7084            .ops
7085            .last()
7086            .and_then(|op| match op {
7087                GraphOp::Project { items, .. } => items.first(),
7088                _ => None,
7089            })
7090            .expect("child must end with one value projection");
7091        assert_eq!(
7092            projection.alias.as_deref(),
7093            Some(PATTERN_COMPREHENSION_VALUE_ALIAS)
7094        );
7095        assert!(matches!(
7096            child.exprs.get(projection.expr),
7097            IrExpr::FunctionCall { name, .. } if name == "_path_struct"
7098        ));
7099        let outer_projection = plan
7100            .ops
7101            .iter()
7102            .rev()
7103            .find_map(|op| match op {
7104                GraphOp::Project { items, .. } => items.first(),
7105                _ => None,
7106            })
7107            .expect("outer RETURN must project the collected result");
7108        assert!(matches!(
7109            plan.exprs.get(outer_projection.expr),
7110            IrExpr::VarRef(var) if var == output
7111        ));
7112    }
7113
7114    #[test]
7115    fn pattern_comprehension_binds_local_node_and_relationship_properties() {
7116        for query in [
7117            "MATCH (n) RETURN [(n)-[:REL]->(b) | b.name] AS names",
7118            "MATCH (n) RETURN [(n)-[r:REL]->() | r.name] AS names",
7119        ] {
7120            let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7121            let ast = parse(query).unwrap();
7122            let plan = binder.bind(&ast).expect("local projection binds");
7123            let child = plan
7124                .ops
7125                .iter()
7126                .find_map(|op| match op {
7127                    GraphOp::PatternComprehension { child, .. } => Some(child),
7128                    _ => None,
7129                })
7130                .expect("expected a PatternComprehension op");
7131            let projection = child
7132                .ops
7133                .last()
7134                .and_then(|op| match op {
7135                    GraphOp::Project { items, .. } => items.first(),
7136                    _ => None,
7137                })
7138                .expect("child must end with a projection");
7139            assert!(matches!(
7140                child.exprs.get(projection.expr),
7141                IrExpr::PropertyAccess { .. }
7142            ));
7143        }
7144    }
7145
7146    #[test]
7147    fn pattern_comprehension_binds_filter_and_variable_length_match() {
7148        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7149        let ast =
7150            parse("MATCH (n) RETURN [(n)-[r:REL*]->(b) WHERE b.ok = true | b] AS matches").unwrap();
7151        let plan = binder
7152            .bind(&ast)
7153            .expect("filtered var-length comprehension binds");
7154        let child = plan
7155            .ops
7156            .iter()
7157            .find_map(|op| match op {
7158                GraphOp::PatternComprehension { child, .. } => Some(child),
7159                _ => None,
7160            })
7161            .expect("expected a PatternComprehension op");
7162
7163        assert!(child.ops.iter().any(|op| {
7164            matches!(
7165                op,
7166                GraphOp::Expand {
7167                    min_hops: 1,
7168                    max_hops: None,
7169                    ..
7170                }
7171            )
7172        }));
7173        assert!(
7174            child
7175                .ops
7176                .iter()
7177                .any(|op| matches!(op, GraphOp::Filter { .. }))
7178        );
7179        assert!(matches!(child.ops.last(), Some(GraphOp::Project { .. })));
7180    }
7181
7182    #[test]
7183    fn pattern_comprehension_local_variables_do_not_leak() {
7184        expect_bind_error(
7185            "MATCH (n) RETURN [(n)-->(b) | b] AS matches, b",
7186            BindErrorKind::UndeclaredVariable,
7187        );
7188    }
7189
7190    #[test]
7191    fn pattern_comprehension_in_list_element_scope_lifts_to_graph_op() {
7192        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7193        let ast = parse("MATCH (n) RETURN [x IN [n] | [(x)-->() | x]] AS nested").unwrap();
7194        let plan = binder
7195            .bind(&ast)
7196            .expect("bind nested pattern comprehension");
7197        let lifted = plan.ops.iter().find_map(|op| match op {
7198            GraphOp::ListElementPatternComprehension { child, .. } => Some(child),
7199            _ => None,
7200        });
7201        let child = lifted.expect("list-element graph operation");
7202        assert!(matches!(child.ops.last(), Some(GraphOp::Project { .. })));
7203    }
7204
7205    #[test]
7206    fn bare_graph_value_where_predicate_is_rejected() {
7207        expect_bind_error(
7208            "MATCH (n) WHERE (n) RETURN n",
7209            BindErrorKind::InvalidArgument,
7210        );
7211    }
7212
7213    #[test]
7214    fn bare_path_value_where_predicate_is_rejected() {
7215        expect_bind_error(
7216            "MATCH p = (n)-[:REL]->() WHERE p RETURN p",
7217            BindErrorKind::InvalidArgument,
7218        );
7219    }
7220
7221    #[test]
7222    fn with_where_pattern_predicate_lowers_after_with() {
7223        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7224        let ast = parse("MATCH (n) WITH n WHERE (n)-[:REL]->() RETURN n").unwrap();
7225        let plan = binder.bind(&ast).expect("WITH pattern predicate binds");
7226
7227        let with_idx = plan
7228            .ops
7229            .iter()
7230            .position(|op| matches!(op, GraphOp::With { .. }))
7231            .expect("WITH should lower to a With op");
7232        let exists_idx = plan
7233            .ops
7234            .iter()
7235            .position(|op| matches!(op, GraphOp::Exists { .. }))
7236            .expect("WITH WHERE pattern predicate should lower to Exists");
7237        assert!(
7238            with_idx < exists_idx,
7239            "WITH projection must run before its pattern predicate"
7240        );
7241    }
7242
7243    #[test]
7244    fn create_single_node_populates_pattern() {
7245        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7246        let ast = parse("CREATE (:Person {name: 'Alice', age: 30})").unwrap();
7247        let plan = binder.bind(&ast).expect("create bind should succeed");
7248
7249        let create = plan
7250            .ops
7251            .iter()
7252            .find_map(|op| match op {
7253                GraphOp::Create { pattern } => Some(pattern),
7254                _ => None,
7255            })
7256            .expect("expected a Create op");
7257        assert_eq!(create.nodes.len(), 1);
7258        assert!(create.edges.is_empty());
7259        let node = &create.nodes[0];
7260        assert_eq!(node.labels.len(), 1, "Person label should resolve");
7261        let props = node.properties.expect("node should have a property map");
7262        // The property expr should be a MapLiteral in the arena.
7263        assert!(matches!(plan.exprs.get(props), IrExpr::MapLiteral(_)));
7264    }
7265
7266    #[test]
7267    fn create_edge_threads_src_and_dst_vars() {
7268        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7269        let ast = parse("CREATE (a:Person)-[:KNOWS]->(b:Person)").unwrap();
7270        let plan = binder.bind(&ast).expect("create bind should succeed");
7271
7272        let create = plan
7273            .ops
7274            .iter()
7275            .find_map(|op| match op {
7276                GraphOp::Create { pattern } => Some(pattern),
7277                _ => None,
7278            })
7279            .expect("expected a Create op");
7280        assert_eq!(create.nodes.len(), 2);
7281        assert_eq!(create.edges.len(), 1);
7282        let edge = &create.edges[0];
7283        // The edge's src/dst must match the two node vars, in order.
7284        assert_eq!(edge.src, create.nodes[0].var);
7285        assert_eq!(edge.dst, create.nodes[1].var);
7286        assert_eq!(edge.direction, Direction::Out);
7287        assert!(edge.rel_type.is_some());
7288    }
7289
7290    #[test]
7291    fn standalone_create_node_is_not_a_reference() {
7292        // No preceding clause → the CREATE introduces the var → mint, not ref.
7293        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7294        let ast = parse("CREATE (a:Person)").unwrap();
7295        let plan = binder.bind(&ast).expect("bind");
7296        let create = plan
7297            .ops
7298            .iter()
7299            .find_map(|op| match op {
7300                GraphOp::Create { pattern } => Some(pattern),
7301                _ => None,
7302            })
7303            .expect("Create op");
7304        assert_eq!(create.nodes.len(), 1);
7305        assert!(
7306            !create.nodes[0].is_reference,
7307            "a CREATE-introduced var must be a mint, not a reference"
7308        );
7309    }
7310
7311    // -----------------------------------------------------------------------
7312    // Variable-kind conflict validation (#956, VariableTypeConflict)
7313    // -----------------------------------------------------------------------
7314
7315    /// Bind and assert the query is REJECTED with a `VariableKindConflict`.
7316    fn expect_kind_conflict(query: &str) {
7317        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7318        let ast = parse(query).expect("parse");
7319        let errs = binder
7320            .bind(&ast)
7321            .expect_err(&format!("expected a kind conflict for: {query}"));
7322        assert!(
7323            errs.iter()
7324                .any(|e| e.kind == BindErrorKind::VariableKindConflict),
7325            "expected VariableKindConflict for {query}, got {errs:?}"
7326        );
7327    }
7328
7329    #[test]
7330    fn relationship_var_reused_as_node_conflicts() {
7331        // A relationship variable used as a node pattern is a VariableTypeConflict.
7332        for q in [
7333            "MATCH ()-[r]-() MATCH (r) RETURN r",
7334            "MATCH ()-[r]->() MATCH (r) RETURN r",
7335            "MATCH (), ()-[r]-() MATCH (r) RETURN r",
7336        ] {
7337            expect_kind_conflict(q);
7338        }
7339    }
7340
7341    #[test]
7342    fn path_var_reused_as_node_conflicts() {
7343        // A (single-segment) path variable reused as a node pattern conflicts.
7344        expect_kind_conflict("MATCH r = ()-[]-() MATCH (r) RETURN r");
7345    }
7346
7347    #[test]
7348    fn scalar_aliases_reused_as_pattern_entities_conflict() {
7349        expect_kind_conflict("WITH 42 AS n MATCH (n) RETURN n");
7350        expect_kind_conflict("WITH true AS r MATCH ()-[r]->() RETURN r");
7351        expect_kind_conflict(
7352            "MATCH (n) WITH collect(n) AS users MATCH (users)-[:R]->() RETURN users",
7353        );
7354    }
7355
7356    #[test]
7357    fn runtime_polymorphic_pattern_values_remain_bindable() {
7358        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7359        for query in [
7360            "WITH null AS a OPTIONAL MATCH p = (a)-[r]->() RETURN relationships(p)",
7361            "MATCH (a) WITH collect(a) AS nodes UNWIND nodes AS n MATCH (n) RETURN n",
7362        ] {
7363            let ast = parse(query).expect("parse");
7364            binder
7365                .bind(&ast)
7366                .unwrap_or_else(|errors| panic!("expected clean bind for {query}: {errors:?}"));
7367        }
7368    }
7369
7370    #[test]
7371    fn direct_graph_function_kind_mismatches_are_rejected() {
7372        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7373        for query in [
7374            "MATCH (n) RETURN type(n)",
7375            "MATCH ()-[r]->() RETURN labels(r)",
7376            "MATCH (n) RETURN length(n)",
7377            "MATCH ()-[r]->() RETURN length(r)",
7378            "MATCH p = (n) RETURN labels(p)",
7379        ] {
7380            let ast = parse(query).expect("parse");
7381            let errors = binder.bind(&ast).expect_err("expected invalid argument");
7382            assert!(
7383                errors
7384                    .iter()
7385                    .any(|error| error.kind == BindErrorKind::InvalidArgument),
7386                "expected InvalidArgument for {query}, got {errors:?}"
7387            );
7388        }
7389    }
7390
7391    #[test]
7392    fn compatible_variable_reuse_is_accepted() {
7393        // Guardrail: re-using a variable with the SAME kind, distinct variables,
7394        // and WITH-rescoping must all still bind cleanly (no false conflict).
7395        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7396        for q in [
7397            "MATCH (a) MATCH (a) RETURN a",              // node reused as node
7398            "MATCH (a), (b) RETURN a, b",                // distinct node vars
7399            "MATCH (a)-[r]->(b), (b)-[s]->(c) RETURN a", // b: dst then src, both nodes
7400            "MATCH (a) WITH a MATCH (b) RETURN a, b",    // WITH-rescoped
7401            "MATCH (a) CREATE (a)-[:R]->(b)",            // matched node referenced in CREATE
7402        ] {
7403            let ast = parse(q).expect("parse");
7404            binder
7405                .bind(&ast)
7406                .unwrap_or_else(|e| panic!("expected clean bind for {q}, got {e:?}"));
7407        }
7408    }
7409
7410    #[test]
7411    fn relationship_var_reused_with_different_type_adds_false_filter() {
7412        // A relationship variable forwarded through WITH keeps its known type.
7413        // Reusing it with a different known type is a valid match that returns no
7414        // rows; encode that independently of whether the edge scan schema carries
7415        // a `rel_type_name` column.
7416        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7417        let ast = parse("MATCH ()-[r:T]->() WITH r MATCH ()-[r:Y]->() RETURN r").expect("parse");
7418        let plan = binder.bind(&ast).expect("bind");
7419
7420        let has_false_filter = plan.ops.iter().any(|op| match op {
7421            GraphOp::Filter { predicate } => {
7422                matches!(
7423                    plan.exprs.get(*predicate),
7424                    IrExpr::Literal(IrLiteral::Bool(false))
7425                )
7426            }
7427            _ => false,
7428        });
7429
7430        assert!(
7431            has_false_filter,
7432            "reusing a known relationship variable with a different type should filter to no rows"
7433        );
7434    }
7435
7436    // -----------------------------------------------------------------------
7437    // Validator tail (#956): CREATE-pattern, rebind, aggregate-in-WHERE, alias
7438    // -----------------------------------------------------------------------
7439
7440    /// Bind and assert the query is REJECTED with any bind error of `kind`.
7441    fn expect_bind_error(query: &str, kind: BindErrorKind) {
7442        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7443        let ast = parse(query).expect("parse");
7444        let errs = binder
7445            .bind(&ast)
7446            .expect_err(&format!("expected a bind error for: {query}"));
7447        assert!(
7448            errs.iter().any(|e| e.kind == kind),
7449            "expected {kind:?} for {query}, got {errs:?}"
7450        );
7451    }
7452
7453    #[test]
7454    fn create_pattern_validation() {
7455        // A created relationship must have exactly one type, be fixed-length,
7456        // and be directed (#956).
7457        for q in [
7458            "CREATE ()-->()",         // no type
7459            "CREATE ()-[:FOO*2]->()", // variable-length
7460            "CREATE (a)-[:FOO]-(b)",  // undirected
7461        ] {
7462            expect_bind_error(q, BindErrorKind::InvalidArgument);
7463        }
7464    }
7465
7466    #[test]
7467    fn rebinding_a_bound_variable_in_create_is_rejected() {
7468        for q in [
7469            "MATCH (a) CREATE (a)",                          // bare re-create
7470            "MATCH (a) CREATE (a {name: 'x'})",              // re-declared with props
7471            "CREATE (n:Foo) CREATE (n:Bar)-[:OWNS]->(:Dog)", // re-declared with a label
7472            "MATCH ()-[r]->() CREATE ()-[r]->()",            // reused relationship var
7473        ] {
7474            expect_bind_error(q, BindErrorKind::VariableAlreadyBound);
7475        }
7476    }
7477
7478    #[test]
7479    fn create_reference_without_new_shape_is_accepted() {
7480        // Guardrail: a bound node referenced as an edge endpoint (no new labels
7481        // or properties) is a valid reference, not a rebind.
7482        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7483        let ast = parse("MATCH (a) CREATE (a)-[:R]->(b)").expect("parse");
7484        binder.bind(&ast).expect("valid reference must bind");
7485    }
7486
7487    #[test]
7488    fn aggregate_in_where_and_duplicate_alias_are_rejected() {
7489        expect_bind_error(
7490            "MATCH (a) WHERE count(a) > 10 RETURN a",
7491            BindErrorKind::InvalidArgument,
7492        );
7493        expect_bind_error("RETURN 1 AS a, 2 AS a", BindErrorKind::InvalidArgument);
7494        expect_bind_error(
7495            "WITH 1 AS a, 2 AS a RETURN a",
7496            BindErrorKind::InvalidArgument,
7497        );
7498    }
7499
7500    #[test]
7501    fn skip_limit_reject_negative_and_non_parameter_arguments() {
7502        for q in [
7503            "RETURN 1 SKIP -1",
7504            "RETURN 1 LIMIT -1",
7505            "RETURN 1 SKIP (-1)",
7506            "WITH 1 AS x SKIP -1 RETURN x",
7507            "WITH 1 AS x, count(*) AS c SKIP -1 RETURN x, c",
7508            "MATCH (n) RETURN n SKIP n.count",
7509            "MATCH (n) RETURN n LIMIT rand()",
7510            "MATCH (n) WITH n SKIP n.count RETURN n",
7511            "MATCH (n) WITH n, count(*) AS c LIMIT rand() RETURN n, c",
7512        ] {
7513            expect_bind_error(q, BindErrorKind::InvalidArgument);
7514        }
7515    }
7516
7517    #[test]
7518    fn skip_limit_accept_non_negative_integer_constants_and_parameters() {
7519        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7520        for q in [
7521            "RETURN 1 SKIP 0 LIMIT 1",
7522            "WITH 1 AS x SKIP (1) LIMIT 2 RETURN x",
7523            "WITH 1 AS x, count(*) AS c SKIP 0 LIMIT 1 RETURN x, c",
7524            "RETURN 1 SKIP $s LIMIT $l",
7525            "WITH 1 AS x SKIP ($s) LIMIT ($l) RETURN x",
7526        ] {
7527            let ast = parse(q).expect("parse");
7528            binder
7529                .bind(&ast)
7530                .unwrap_or_else(|e| panic!("expected clean bind for {q}, got {e:?}"));
7531        }
7532    }
7533
7534    #[test]
7535    fn overflowing_float_literal_is_rejected_at_bind() {
7536        expect_bind_error("RETURN 1.34E999", BindErrorKind::InvalidArgument);
7537    }
7538
7539    #[test]
7540    fn matched_var_in_create_is_a_reference_not_a_duplicate_mint() {
7541        // #703: `MATCH (a) CREATE (a)-[:KNOWS]->(b)` — `a` was bound by the
7542        // MATCH, so its CREATE node spec must be a REFERENCE (resolve the matched
7543        // node), and there must be exactly ONE spec for `a` (not a second mint).
7544        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7545        let ast = parse("MATCH (a:Person) CREATE (a)-[:KNOWS]->(b:Person)").expect("parse");
7546        let plan = binder.bind(&ast).expect("bind");
7547        let create = plan
7548            .ops
7549            .iter()
7550            .find_map(|op| match op {
7551                GraphOp::Create { pattern } => Some(pattern),
7552                _ => None,
7553            })
7554            .expect("Create op");
7555
7556        // The matched `a` is the edge src; the new `b` is the dst.
7557        let a_var = create.edges[0].src;
7558        let b_var = create.edges[0].dst;
7559        let a_specs: Vec<_> = create.nodes.iter().filter(|n| n.var == a_var).collect();
7560        assert_eq!(a_specs.len(), 1, "exactly one spec for the matched var `a`");
7561        assert!(a_specs[0].is_reference, "matched `a` must be a reference");
7562        let b_spec = create
7563            .nodes
7564            .iter()
7565            .find(|n| n.var == b_var)
7566            .expect("spec for new `b`");
7567        assert!(!b_spec.is_reference, "CREATE-introduced `b` must be a mint");
7568    }
7569
7570    #[test]
7571    fn delete_clause_lowers_to_delete_op() {
7572        // #740: DELETE now lowers to GraphOp::Delete (no longer rejected).
7573        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7574        let ast = parse("MATCH (p:Person) DELETE p").unwrap();
7575        let plan = binder.bind(&ast).expect("DELETE binds");
7576        let delete = plan
7577            .ops
7578            .iter()
7579            .find_map(|op| match op {
7580                GraphOp::Delete { vars, detach, .. } => Some((vars.clone(), *detach)),
7581                _ => None,
7582            })
7583            .expect("a GraphOp::Delete op");
7584        assert_eq!(delete.0.len(), 1, "one target var");
7585        assert!(!delete.1, "plain DELETE is not DETACH");
7586    }
7587
7588    #[test]
7589    fn detach_delete_sets_detach_flag() {
7590        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7591        let ast = parse("MATCH (p:Person) DETACH DELETE p").unwrap();
7592        let plan = binder.bind(&ast).expect("DETACH DELETE binds");
7593        assert!(
7594            plan.ops
7595                .iter()
7596                .any(|op| matches!(op, GraphOp::Delete { detach: true, .. })),
7597            "DETACH DELETE must set detach=true, got {:?}",
7598            plan.ops
7599        );
7600    }
7601
7602    #[test]
7603    fn delete_property_target_lowers_to_runtime_expression() {
7604        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7605        let ast = parse("MATCH (p:Person) DELETE p.name").unwrap();
7606        let plan = binder
7607            .bind(&ast)
7608            .expect("property value binds for runtime typing");
7609        assert!(matches!(
7610            plan.ops.last(),
7611            Some(GraphOp::Delete { vars, exprs, .. }) if vars.is_empty() && exprs.len() == 1
7612        ));
7613    }
7614
7615    #[test]
7616    fn delete_scalar_expression_is_rejected() {
7617        expect_bind_error("MATCH () DELETE 1 + 1", BindErrorKind::InvalidDeleteTarget);
7618    }
7619
7620    #[test]
7621    fn delete_undeclared_variable_is_rejected() {
7622        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7623        let ast = parse("MATCH (p:Person) DELETE q").unwrap();
7624        let errors = binder
7625            .bind(&ast)
7626            .expect_err("DELETE of an unbound var must be rejected");
7627        assert!(
7628            errors
7629                .iter()
7630                .any(|e| e.kind == BindErrorKind::UndeclaredVariable),
7631            "expected UndeclaredVariable, got {errors:?}"
7632        );
7633    }
7634
7635    #[test]
7636    fn set_property_clause_lowers_to_set_op() {
7637        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7638        let ast = parse("MATCH (p:Person) SET p.age = 30").unwrap();
7639        let plan = binder.bind(&ast).expect("SET p.age = 30 must lower");
7640        let set = plan
7641            .ops
7642            .iter()
7643            .find_map(|op| match op {
7644                GraphOp::Set { items, .. } => Some(items),
7645                _ => None,
7646            })
7647            .expect("expected a GraphOp::Set");
7648        assert_eq!(set.len(), 1);
7649        assert_eq!(set[0].prop_name, "age");
7650    }
7651
7652    #[test]
7653    fn set_runtime_expr_value_lowers_to_non_literal() {
7654        // The value `p.age + 1` is a runtime expression, not a literal — it must
7655        // survive lowering as a compound `IrExpr`, not be collapsed to a literal.
7656        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7657        let ast = parse("MATCH (p:Person) SET p.age = p.age + 1").unwrap();
7658        let plan = binder.bind(&ast).expect("SET p.age = p.age + 1 must lower");
7659        let items = plan
7660            .ops
7661            .iter()
7662            .find_map(|op| match op {
7663                GraphOp::Set { items, .. } => Some(items),
7664                _ => None,
7665            })
7666            .expect("expected a GraphOp::Set");
7667        let value = plan.exprs.get(items[0].value);
7668        assert!(
7669            matches!(value, IrExpr::BinaryOp { .. }),
7670            "expected a BinaryOp value expr, got {value:?}"
7671        );
7672    }
7673
7674    #[test]
7675    fn set_multiple_items_lower_to_one_op() {
7676        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7677        let ast = parse("MATCH (p:Person) SET p.age = 30, p.name = 'Al'").unwrap();
7678        let plan = binder.bind(&ast).expect("multi-item SET must lower");
7679        let count = plan
7680            .ops
7681            .iter()
7682            .filter(|op| matches!(op, GraphOp::Set { .. }))
7683            .count();
7684        assert_eq!(count, 1, "expected exactly one GraphOp::Set");
7685    }
7686
7687    #[test]
7688    fn set_labels_lower_to_resolved_label_item() {
7689        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7690        let ast = parse("MATCH (p:Person) SET p:Admin:Staff").unwrap();
7691        let plan = binder.bind(&ast).expect("SET labels must lower");
7692        let labels = plan.ops.iter().find_map(|op| match op {
7693            GraphOp::Set { label_items, .. } => Some(label_items),
7694            _ => None,
7695        });
7696        let labels = labels.expect("SET label items");
7697        assert_eq!(labels.len(), 1);
7698        assert_eq!(labels[0].labels.len(), 2);
7699    }
7700
7701    #[test]
7702    fn set_property_merge_lowers_to_map_item() {
7703        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7704        let ast = parse("MATCH (p:Person) SET p += {age: 30}").unwrap();
7705        let plan = binder.bind(&ast).expect("SET += must lower");
7706        let map_items = plan
7707            .ops
7708            .iter()
7709            .find_map(|op| match op {
7710                GraphOp::Set { map_items, .. } => Some(map_items),
7711                _ => None,
7712            })
7713            .expect("expected SET op");
7714        assert_eq!(map_items.len(), 1);
7715        assert!(!map_items[0].replace);
7716    }
7717
7718    #[test]
7719    fn merge_lowers_real_pattern_specs() {
7720        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7721        let ast = parse(
7722            "MERGE (p:Person {name:'Alice'}) \
7723             ON CREATE SET p.created = 1, p:New \
7724             ON MATCH SET p += {seen:true}",
7725        )
7726        .unwrap();
7727        let plan = binder.bind(&ast).expect("MERGE binds");
7728        let (pattern, on_create, on_match) = plan
7729            .ops
7730            .iter()
7731            .find_map(|op| match op {
7732                GraphOp::Merge {
7733                    pattern,
7734                    on_create,
7735                    on_match,
7736                } => Some((pattern, on_create, on_match)),
7737                _ => None,
7738            })
7739            .expect("MERGE op");
7740        assert_eq!(pattern.nodes.len(), 1);
7741        assert!(pattern.nodes[0].properties.is_some());
7742        assert_eq!(on_create.len(), 2);
7743        assert_eq!(on_match.len(), 1);
7744    }
7745
7746    #[test]
7747    fn set_undeclared_variable_rejected() {
7748        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7749        let ast = parse("MATCH (p:Person) SET q.age = 30").unwrap();
7750        let errors = binder.bind(&ast).expect_err("SET on unbound var must fail");
7751        assert!(
7752            errors
7753                .iter()
7754                .any(|e| e.kind == BindErrorKind::UndeclaredVariable),
7755            "expected UndeclaredVariable, got {errors:?}"
7756        );
7757    }
7758
7759    #[test]
7760    fn remove_property_clause_lowers_to_remove_op() {
7761        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7762        let ast = parse("MATCH (p:Person) REMOVE p.age").unwrap();
7763        let plan = binder.bind(&ast).expect("REMOVE p.age must lower");
7764        let items = plan
7765            .ops
7766            .iter()
7767            .find_map(|op| match op {
7768                GraphOp::Remove { items, .. } => Some(items),
7769                _ => None,
7770            })
7771            .expect("expected a GraphOp::Remove");
7772        assert_eq!(items.len(), 1);
7773        assert_eq!(items[0].prop_name, "age");
7774    }
7775
7776    #[test]
7777    fn remove_labels_lower_to_resolved_label_item() {
7778        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7779        let ast = parse("MATCH (p:Person) REMOVE p:Admin:Staff").unwrap();
7780        let plan = binder.bind(&ast).expect("REMOVE labels must lower");
7781        let labels = plan.ops.iter().find_map(|op| match op {
7782            GraphOp::Remove { label_items, .. } => Some(label_items),
7783            _ => None,
7784        });
7785        let labels = labels.expect("REMOVE label items");
7786        assert_eq!(labels.len(), 1);
7787        assert_eq!(labels[0].labels.len(), 2);
7788    }
7789
7790    #[test]
7791    fn advisory_unknown_label_produces_warnings() {
7792        let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
7793        let binder = Binder::new(None, Arc::clone(&catalog), OntologyMode::Advisory);
7794        let ast = parse("MATCH (a:UnknownLabel) RETURN a").unwrap();
7795
7796        let mut state = BinderState {
7797            vars: HashMap::new(),
7798            path_vars: HashMap::new(),
7799            node_vars: HashMap::new(),
7800            edge_vars: HashMap::new(),
7801            edge_rel_names: HashMap::new(),
7802            scalar_list_edges: HashSet::new(),
7803            var_kinds: HashMap::new(),
7804            next_var: 0,
7805            builder: GraphPlan::builder("openCypher").ontology_mode(OntologyMode::Advisory),
7806            errors: Vec::new(),
7807            warnings: Vec::new(),
7808            captured_pattern_comprehensions: None,
7809            existential_depth: 0,
7810            standalone_call: false,
7811        };
7812        for clause in &ast.clauses {
7813            binder.lower_clause(clause, &mut state);
7814        }
7815
7816        assert!(
7817            !state.warnings.is_empty(),
7818            "advisory mode should produce warnings"
7819        );
7820        assert!(
7821            state.errors.is_empty(),
7822            "advisory mode should not produce errors"
7823        );
7824        assert!(
7825            state
7826                .warnings
7827                .iter()
7828                .any(|w| w.kind == BindErrorKind::UnknownLabel)
7829        );
7830    }
7831
7832    #[test]
7833    fn strict_unknown_label_produces_error() {
7834        let (binder, _) = make_binder(OntologyMode::Strict);
7835        let ast = parse("MATCH (a:UnknownLabel) RETURN a").unwrap();
7836        let result = binder.bind(&ast);
7837        assert!(result.is_err());
7838        let errors = result.unwrap_err();
7839        assert!(errors.iter().any(|e| e.kind == BindErrorKind::UnknownLabel));
7840    }
7841
7842    #[test]
7843    fn strict_mode_allows_durable_identity_fields() {
7844        let (binder, _) = make_binder(OntologyMode::Strict);
7845        let ast = parse(
7846            "MATCH (source)-[relationship]->(target) RETURN source.node_uuid, relationship.edge_uuid, target.node_uuid",
7847        )
7848        .unwrap();
7849
7850        binder
7851            .bind(&ast)
7852            .expect("structural identity fields are not ontology properties");
7853    }
7854
7855    #[test]
7856    fn strict_properties_emit_owner_scoped_runtime_ids() {
7857        let (binder, catalog) = strict_property_binder(false);
7858        let ast = parse("MATCH (host:Host)-[connection:R]->() RETURN host.direct, host.inherited, host.shared, connection.weight, connection.shared").unwrap();
7859        let plan = binder.bind(&ast).unwrap();
7860        let ids = plan_property_ids(&plan);
7861        let catalog = catalog.lock().unwrap();
7862        let names = ids
7863            .iter()
7864            .map(|id| catalog.property_name(crate::RuntimePropId(id.0)).unwrap())
7865            .collect::<Vec<_>>();
7866        assert_eq!(names, ["direct", "inherited", "shared", "weight", "shared"]);
7867        assert!(ids.iter().all(|id| id.0 > 0));
7868        assert_ne!(ids[2], ids[4]);
7869    }
7870
7871    #[test]
7872    fn strict_property_writes_and_inline_filters_share_owner_rules() {
7873        let (binder, catalog) = strict_property_binder(false);
7874        let ast = parse("MATCH (host:Host)-[connection:R]->() SET host.direct = 'ready', host.inherited = 'asset', connection.weight = 7 REMOVE host.direct, connection.weight").unwrap();
7875        let plan = binder.bind(&ast).unwrap();
7876        let catalog = catalog.lock().unwrap();
7877        let write_ids = plan
7878            .ops
7879            .iter()
7880            .flat_map(|op| match op {
7881                GraphOp::Set { items, .. } => items.iter().map(|item| item.prop).collect(),
7882                GraphOp::Remove { items, .. } => items.iter().map(|item| item.prop).collect(),
7883                _ => Vec::new(),
7884            })
7885            .collect::<Vec<_>>();
7886        assert_eq!(write_ids.len(), 5);
7887        assert!(
7888            write_ids
7889                .iter()
7890                .all(|id| catalog.property_name(crate::RuntimePropId(id.0)).is_some())
7891        );
7892        drop(catalog);
7893
7894        for query in [
7895            "MATCH (:Host {direct: 'ready'})-[:R {weight: 7}]->() RETURN 1",
7896            "MATCH (:Host {inherited: 'asset'})-[:R*1..2 {weight: 7}]->() RETURN 1",
7897        ] {
7898            binder
7899                .bind(&parse(query).unwrap())
7900                .expect("anonymous fixed and variable-length owners should bind");
7901        }
7902    }
7903
7904    #[test]
7905    fn strict_properties_reject_wrong_owner_and_ambiguity_with_exact_spans() {
7906        let (binder, _) = strict_property_binder(false);
7907        for (query, span) in [
7908            ("MATCH (host:Host) RETURN host.weight", "host.weight"),
7909            ("MATCH ()-[r:R]->() RETURN r.direct", "r.direct"),
7910            ("MATCH (host:Host) RETURN host.missing", "host.missing"),
7911            (
7912                "MATCH (host:Host) WITH host AS forwarded RETURN forwarded.weight",
7913                "forwarded.weight",
7914            ),
7915            (
7916                "MATCH ()-[r:R]->() WITH r AS forwarded RETURN forwarded.direct",
7917                "forwarded.direct",
7918            ),
7919        ] {
7920            property_error(&binder, query, BindErrorKind::UnknownProperty, span);
7921        }
7922        for (query, span) in [
7923            ("MATCH (:Host {weight: 7}) RETURN 1", "weight"),
7924            ("MATCH ()-[:R {missing: 7}]->() RETURN 1", "missing"),
7925        ] {
7926            property_error(&binder, query, BindErrorKind::UnknownProperty, span);
7927        }
7928        property_error(
7929            &binder,
7930            "MATCH ()-[*1..2 {weight: 7}]->() RETURN 1",
7931            BindErrorKind::AmbiguousProperty,
7932            "weight",
7933        );
7934
7935        let (binder, _) = strict_property_binder(true);
7936        let query = "MATCH (host:Host) RETURN host.inherited";
7937        let error = property_error(
7938            &binder,
7939            query,
7940            BindErrorKind::AmbiguousProperty,
7941            "host.inherited",
7942        );
7943        assert!(error.message.contains("Asset, Host"));
7944        property_error(
7945            &binder,
7946            "MATCH (:Host {inherited: 7}) RETURN 1",
7947            BindErrorKind::AmbiguousProperty,
7948            "inherited",
7949        );
7950    }
7951
7952    #[test]
7953    fn strict_mode_rejects_mismatched_durable_identity_fields() {
7954        for query in [
7955            "MATCH (node) RETURN node.edge_uuid",
7956            "MATCH ()-[relationship]->() RETURN relationship.node_uuid",
7957        ] {
7958            let (binder, _) = make_binder(OntologyMode::Strict);
7959            let errors = binder
7960                .bind(&parse(query).unwrap())
7961                .expect_err("identity fields must match the bound entity kind");
7962            assert!(errors.iter().any(|error| {
7963                error.kind == BindErrorKind::InvalidArgument
7964                    && error.message.contains("valid only on")
7965            }));
7966        }
7967    }
7968
7969    #[test]
7970    fn structural_identity_fields_are_read_only() {
7971        for query in [
7972            "MATCH (node) SET node.node_uuid = 'replacement'",
7973            "MATCH (node) REMOVE node.node_uuid",
7974            "MATCH ()-[relationship]->() SET relationship.edge_uuid = 'replacement'",
7975            "MATCH ()-[relationship]->() REMOVE relationship.edge_uuid",
7976        ] {
7977            let (binder, _) = make_binder(OntologyMode::Strict);
7978            let errors = binder
7979                .bind(&parse(query).unwrap())
7980                .expect_err("identity fields must not be mutable");
7981            assert!(errors.iter().any(|error| {
7982                error.kind == BindErrorKind::InvalidArgument
7983                    && error.message.contains("is read-only")
7984            }));
7985        }
7986    }
7987
7988    #[test]
7989    fn fixed_hop_with_known_relation_emits_expand() {
7990        use graphforge_ontology::{
7991            EntityTypeDef, OntologyCompiler, OntologyDoc, OntologyHandle, RelationTypeDef,
7992            SemanticFlags,
7993        };
7994
7995        let doc = OntologyDoc {
7996            ontology_id: "test".into(),
7997            version: "1.0".into(),
7998            entity_types: vec![
7999                EntityTypeDef {
8000                    name: "Person".into(),
8001                    r#abstract: false,
8002                    parent: None,
8003                },
8004                EntityTypeDef {
8005                    name: "Organization".into(),
8006                    r#abstract: false,
8007                    parent: None,
8008                },
8009            ],
8010            relation_types: vec![RelationTypeDef {
8011                name: "WORKS_AT".into(),
8012                src: "Person".into(),
8013                dst: "Organization".into(),
8014                inverse: None,
8015                semantic: SemanticFlags::default(),
8016            }],
8017            properties: vec![],
8018            constraints: vec![],
8019            migrations: vec![],
8020        };
8021        let runtime = OntologyCompiler::compile(&doc).unwrap();
8022        let handle = OntologyHandle::new(runtime);
8023        let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
8024        let binder = Binder::new(Some(handle), catalog, OntologyMode::Strict);
8025
8026        let ast = parse("MATCH (a:Person)-[:WORKS_AT]->(b:Organization) RETURN a").unwrap();
8027        let plan = binder.bind(&ast).expect("known ontology should succeed");
8028
8029        // A fixed single hop lowers to a single `Expand` carrying both node vars
8030        // and the resolved relation type — never a bare edge scan (#718).
8031        let expand = plan.ops.iter().find_map(|op| match op {
8032            GraphOp::Expand {
8033                rel_ty,
8034                min_hops,
8035                max_hops,
8036                ..
8037            } => Some((rel_ty, *min_hops, *max_hops)),
8038            _ => None,
8039        });
8040        let (rel_ty, min_hops, max_hops) = expand.expect("fixed hop should emit Expand");
8041        assert!(
8042            rel_ty.is_some(),
8043            "WORKS_AT should resolve to a relation type"
8044        );
8045        assert_eq!((min_hops, max_hops), (1, Some(1)), "fixed hop is 1..1");
8046        assert!(
8047            !plan
8048                .ops
8049                .iter()
8050                .any(|op| matches!(op, GraphOp::TypedEdgeScan { .. } | GraphOp::EdgeScan { .. }))
8051        );
8052    }
8053
8054    #[test]
8055    fn wildcard_fixed_hop_emits_untyped_expand() {
8056        let (binder, _) = make_binder(OntologyMode::Exploratory);
8057        let ast = parse("MATCH (a)-[r]->(b) RETURN r").unwrap();
8058        let plan = binder.bind(&ast).expect("wildcard bind should succeed");
8059
8060        // An untyped fixed hop is still an `Expand` (rel_ty None), not an
8061        // EdgeScan, so the source/destination nodes stay connected (#718).
8062        let expand = plan.ops.iter().find_map(|op| match op {
8063            GraphOp::Expand {
8064                rel_ty,
8065                min_hops,
8066                max_hops,
8067                ..
8068            } => Some((rel_ty, *min_hops, *max_hops)),
8069            _ => None,
8070        });
8071        let (rel_ty, min_hops, max_hops) = expand.expect("wildcard hop should emit Expand");
8072        assert!(rel_ty.is_none(), "wildcard hop has no relation type");
8073        assert_eq!((min_hops, max_hops), (1, Some(1)));
8074        assert!(
8075            !plan
8076                .ops
8077                .iter()
8078                .any(|op| matches!(op, GraphOp::TypedEdgeScan { .. } | GraphOp::EdgeScan { .. }))
8079        );
8080    }
8081
8082    #[test]
8083    fn undeclared_variable_in_return_is_error() {
8084        let (binder, _) = make_binder(OntologyMode::Exploratory);
8085        let ast = parse("MATCH (a:Person) RETURN x").unwrap();
8086        let result = binder.bind(&ast);
8087        assert!(result.is_err());
8088        let errors = result.unwrap_err();
8089        assert!(
8090            errors
8091                .iter()
8092                .any(|e| e.kind == BindErrorKind::UndeclaredVariable)
8093        );
8094    }
8095
8096    #[test]
8097    fn multi_error_collection_in_strict_mode() {
8098        let (binder, _) = make_binder(OntologyMode::Strict);
8099        let ast = parse("MATCH (a:LabelA)-[:REL_B]->(b:LabelC) RETURN a").unwrap();
8100        let result = binder.bind(&ast);
8101        assert!(result.is_err());
8102        let errors = result.unwrap_err();
8103        // LabelA, REL_B, LabelC → at least 3 errors
8104        assert!(
8105            errors.len() >= 3,
8106            "expected ≥3 errors, got {}",
8107            errors.len()
8108        );
8109    }
8110
8111    #[test]
8112    fn where_clause_emits_filter_op() {
8113        let (binder, _) = make_binder(OntologyMode::Exploratory);
8114        let ast = parse("MATCH (a:Person) WHERE a.age > 30 RETURN a").unwrap();
8115        let plan = binder.bind(&ast).unwrap();
8116        assert!(
8117            plan.ops
8118                .iter()
8119                .any(|op| matches!(op, GraphOp::Filter { .. }))
8120        );
8121    }
8122
8123    #[test]
8124    fn inline_node_property_emits_filter() {
8125        // #748: an inline property map becomes a Filter over the scanned node.
8126        let (binder, _) = make_binder(OntologyMode::Exploratory);
8127        let ast = parse("MATCH (a:Person {name:'Alice'}) RETURN a.name").unwrap();
8128        let plan = binder.bind(&ast).unwrap();
8129        assert!(
8130            plan.ops
8131                .iter()
8132                .any(|op| matches!(op, GraphOp::NodeScan { .. })),
8133            "scan present"
8134        );
8135        assert!(
8136            plan.ops
8137                .iter()
8138                .any(|op| matches!(op, GraphOp::Filter { .. })),
8139            "inline property must emit a Filter"
8140        );
8141    }
8142
8143    #[test]
8144    fn inline_multi_property_emits_single_filter() {
8145        // Multiple inline properties AND-combine into one Filter op.
8146        let (binder, _) = make_binder(OntologyMode::Exploratory);
8147        let ast = parse("MATCH (a:Person {name:'Alice', age:30}) RETURN a.name").unwrap();
8148        let plan = binder.bind(&ast).unwrap();
8149        let filters = plan
8150            .ops
8151            .iter()
8152            .filter(|op| matches!(op, GraphOp::Filter { .. }))
8153            .count();
8154        assert_eq!(filters, 1, "multi-property map → one AND-ed Filter");
8155    }
8156
8157    #[test]
8158    fn node_without_inline_properties_emits_no_filter() {
8159        // Regression: a property-free node pattern must not add a spurious Filter.
8160        let (binder, _) = make_binder(OntologyMode::Exploratory);
8161        let ast = parse("MATCH (a:Person) RETURN a.name").unwrap();
8162        let plan = binder.bind(&ast).unwrap();
8163        assert!(
8164            !plan
8165                .ops
8166                .iter()
8167                .any(|op| matches!(op, GraphOp::Filter { .. })),
8168            "no inline properties → no Filter"
8169        );
8170    }
8171
8172    #[test]
8173    fn inline_rel_property_emits_filter() {
8174        // #750: an inline relationship-property map becomes a Filter over the
8175        // just-expanded edge, mirroring the node case (#748).
8176        let (binder, _) = make_binder(OntologyMode::Exploratory);
8177        let ast =
8178            parse("MATCH (a:Person)-[r:KNOWS {since:2020}]->(b:Person) RETURN b.name").unwrap();
8179        let plan = binder.bind(&ast).unwrap();
8180        assert!(
8181            plan.ops
8182                .iter()
8183                .any(|op| matches!(op, GraphOp::Expand { .. })),
8184            "expand present"
8185        );
8186        assert!(
8187            plan.ops
8188                .iter()
8189                .any(|op| matches!(op, GraphOp::Filter { .. })),
8190            "inline relationship property must emit a Filter"
8191        );
8192    }
8193
8194    #[test]
8195    fn inline_multi_rel_property_emits_single_filter() {
8196        // Multiple inline rel properties AND-combine into one Filter op.
8197        let (binder, _) = make_binder(OntologyMode::Exploratory);
8198        let ast =
8199            parse("MATCH (a:Person)-[r:KNOWS {since:2020, weight:5}]->(b:Person) RETURN b.name")
8200                .unwrap();
8201        let plan = binder.bind(&ast).unwrap();
8202        let filters = plan
8203            .ops
8204            .iter()
8205            .filter(|op| matches!(op, GraphOp::Filter { .. }))
8206            .count();
8207        assert_eq!(filters, 1, "multi-property rel map → one AND-ed Filter");
8208    }
8209
8210    #[test]
8211    fn rel_without_inline_properties_emits_no_filter() {
8212        // Regression: a property-free relationship must not add a spurious Filter.
8213        let (binder, _) = make_binder(OntologyMode::Exploratory);
8214        let ast = parse("MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN b.name").unwrap();
8215        let plan = binder.bind(&ast).unwrap();
8216        assert!(
8217            !plan
8218                .ops
8219                .iter()
8220                .any(|op| matches!(op, GraphOp::Filter { .. })),
8221            "no inline rel properties → no Filter"
8222        );
8223    }
8224
8225    #[test]
8226    fn inline_property_on_var_length_rel_emits_all_filter() {
8227        let (binder, _) = make_binder(OntologyMode::Exploratory);
8228        let ast = parse("MATCH (a:Person)-[r:KNOWS*1..2 {since:2020}]->(b:Person) RETURN b.name")
8229            .unwrap();
8230        let plan = binder.bind(&ast).unwrap();
8231        let predicate = plan.ops.iter().find_map(|op| match op {
8232            GraphOp::Filter { predicate } => Some(*predicate),
8233            _ => None,
8234        });
8235        assert!(
8236            predicate.is_some_and(|predicate| matches!(
8237                plan.exprs.get(predicate),
8238                IrExpr::Quantifier {
8239                    kind: graphforge_ast::QuantifierKind::All,
8240                    ..
8241                }
8242            )),
8243            "variable-length relationship properties require an all() filter"
8244        );
8245    }
8246
8247    #[test]
8248    fn return_clause_emits_project_op() {
8249        let (binder, _) = make_binder(OntologyMode::Exploratory);
8250        let ast = parse("MATCH (a:Person) RETURN a.name").unwrap();
8251        let plan = binder.bind(&ast).unwrap();
8252        assert!(
8253            plan.ops
8254                .iter()
8255                .any(|op| matches!(op, GraphOp::Project { .. }))
8256        );
8257    }
8258
8259    #[test]
8260    fn return_with_count_emits_aggregate() {
8261        // `RETURN count(n)` lowers to an Aggregate (one Count agg, no group keys),
8262        // not a Project (#729).
8263        let (binder, _) = make_binder(OntologyMode::Exploratory);
8264        let ast = parse("MATCH (n:Person) RETURN count(n) AS total").unwrap();
8265        let plan = binder.bind(&ast).unwrap();
8266        let agg = plan.ops.iter().find_map(|op| match op {
8267            GraphOp::Aggregate { group_by, aggs, .. } => Some((group_by, aggs)),
8268            _ => None,
8269        });
8270        let (group_by, aggs) = agg.expect("count should emit an Aggregate");
8271        assert!(
8272            group_by.is_empty(),
8273            "no non-aggregate items → no group keys"
8274        );
8275        assert_eq!(aggs.len(), 1);
8276        assert_eq!(aggs[0].func, AggFunc::Count);
8277        assert_eq!(aggs[0].alias, "total");
8278        assert!(
8279            !plan
8280                .ops
8281                .iter()
8282                .any(|op| matches!(op, GraphOp::Project { .. })),
8283            "an aggregate RETURN must not also emit a Project"
8284        );
8285    }
8286
8287    #[test]
8288    fn return_with_grouping_keys_and_aggregate() {
8289        // `RETURN n.name, count(n)` groups by the non-aggregate item.
8290        let (binder, _) = make_binder(OntologyMode::Exploratory);
8291        let ast = parse("MATCH (n:Person) RETURN n.name AS name, count(n) AS total").unwrap();
8292        let plan = binder.bind(&ast).unwrap();
8293        let (group_by, aggs) = plan
8294            .ops
8295            .iter()
8296            .find_map(|op| match op {
8297                GraphOp::Aggregate { group_by, aggs, .. } => Some((group_by, aggs)),
8298                _ => None,
8299            })
8300            .expect("Aggregate");
8301        assert_eq!(group_by.len(), 1, "n.name is the group key");
8302        assert_eq!(aggs.len(), 1);
8303    }
8304
8305    // -----------------------------------------------------------------------
8306    // Named path variables (#754)
8307    // -----------------------------------------------------------------------
8308
8309    /// Bind `query` and return the errors (panics if the bind succeeds).
8310    fn bind_errors(query: &str) -> Vec<BindError> {
8311        let (binder, _) = make_binder(OntologyMode::Exploratory);
8312        let ast = parse(query).unwrap();
8313        binder
8314            .bind(&ast)
8315            .expect_err("bind should fail for this query")
8316    }
8317
8318    #[test]
8319    fn path_var_functions_bind_with_anonymous_edge() {
8320        // The binder allocates an anon VarId for `[*1..2]`, so the rewrites
8321        // have an edge var to target even without `[r:...]`.
8322        let (binder, _) = make_binder(OntologyMode::Exploratory);
8323        let ast = parse(
8324            "MATCH p = (a:Person)-[*1..2]->(b) \
8325             RETURN nodes(p) AS ns, relationships(p) AS rs, length(p) AS l",
8326        )
8327        .unwrap();
8328        binder.bind(&ast).expect("path functions should bind");
8329    }
8330
8331    #[test]
8332    fn path_function_on_non_path_falls_through() {
8333        // `length(r)` on a var-length edge var keeps its generic lowering and
8334        // `nodes(a)` on a node var stays a generic function call — neither is
8335        // intercepted by the path rewrite.
8336        let (binder, _) = make_binder(OntologyMode::Exploratory);
8337        let ast =
8338            parse("MATCH (a)-[r:KNOWS*1..2]->(b) RETURN length(r) AS l, nodes(a) AS ns").unwrap();
8339        let plan = binder.bind(&ast).expect("bind succeeds");
8340        let names: Vec<&str> = (0..plan.exprs.len())
8341            .filter_map(
8342                |i| match plan.exprs.get(ExprId(u32::try_from(i).unwrap())) {
8343                    IrExpr::FunctionCall { name, .. } => Some(name.as_str()),
8344                    _ => None,
8345                },
8346            )
8347            .collect();
8348        assert!(names.contains(&"length"), "generic length kept: {names:?}");
8349        assert!(names.contains(&"nodes"), "generic nodes kept: {names:?}");
8350        assert!(
8351            !names.contains(&"_path_nodes"),
8352            "no path rewrite without a path var: {names:?}"
8353        );
8354    }
8355
8356    #[test]
8357    fn bare_path_var_binds_to_path_struct() {
8358        // `RETURN p` rewrites to `_path_struct(<nodes>, <relationships>)`.
8359        let (binder, _) = make_binder(OntologyMode::Exploratory);
8360        let ast = parse("MATCH p = (a)-[:KNOWS*1..2]->(b) RETURN p").unwrap();
8361        let plan = binder.bind(&ast).expect("bare path value binds");
8362        let has_struct = (0..plan.exprs.len()).any(|i| {
8363            matches!(
8364                plan.exprs.get(ExprId(u32::try_from(i).unwrap())),
8365                IrExpr::FunctionCall { name, .. } if name == "_path_struct"
8366            )
8367        });
8368        assert!(has_struct, "RETURN p must rewrite to _path_struct");
8369    }
8370
8371    #[test]
8372    fn multi_segment_path_var_composes_path_functions() {
8373        let (binder, _) = make_binder(OntologyMode::Exploratory);
8374        let ast = parse(
8375            "MATCH p = (a)-[:KNOWS]->(b)-[:KNOWS]->(c) \
8376             RETURN length(p), nodes(p), relationships(p)",
8377        )
8378        .unwrap();
8379        let plan = binder.bind(&ast).expect("multi-segment path binds");
8380        let add_count = (0..plan.exprs.len())
8381            .map(|index| plan.exprs.get(ExprId(index as u32)))
8382            .filter(|expr| {
8383                matches!(
8384                    expr,
8385                    IrExpr::BinaryOp {
8386                        op: BinaryOpKind::Add,
8387                        ..
8388                    }
8389                )
8390            })
8391            .count();
8392        assert!(add_count >= 3, "each path function composes its segments");
8393    }
8394
8395    #[test]
8396    fn fixed_segment_path_functions_bind() {
8397        // A fixed single hop composes from scalar edge/node columns:
8398        // length → _path_fixed_length, nodes → _node_struct_list,
8399        // relationships → _rel_struct_list.
8400        let (binder, _) = make_binder(OntologyMode::Exploratory);
8401        let ast = parse(
8402            "MATCH p = (a)-[:KNOWS]->(b) \
8403             RETURN length(p) AS l, nodes(p) AS ns, relationships(p) AS rs",
8404        )
8405        .unwrap();
8406        let plan = binder.bind(&ast).expect("fixed path functions bind");
8407        let names: Vec<&str> = (0..plan.exprs.len())
8408            .filter_map(
8409                |i| match plan.exprs.get(ExprId(u32::try_from(i).unwrap())) {
8410                    IrExpr::FunctionCall { name, .. } => Some(name.as_str()),
8411                    _ => None,
8412                },
8413            )
8414            .collect();
8415        for expected in [
8416            "_path_fixed_length",
8417            "_node_struct_list",
8418            "_rel_struct_list",
8419        ] {
8420            assert!(names.contains(&expected), "missing {expected}: {names:?}");
8421        }
8422    }
8423
8424    #[test]
8425    fn explicit_one_hop_routes_as_fixed_segment() {
8426        // `*1..1` goes to the relational join (no list column), so the path
8427        // rewrite must treat it as a fixed segment too.
8428        let (binder, _) = make_binder(OntologyMode::Exploratory);
8429        let ast = parse("MATCH p = (a)-[:KNOWS*1..1]->(b) RETURN length(p) AS l").unwrap();
8430        let plan = binder.bind(&ast).expect("explicit 1..1 binds as fixed");
8431        let has_fixed = (0..plan.exprs.len()).any(|i| {
8432            matches!(
8433                plan.exprs.get(ExprId(u32::try_from(i).unwrap())),
8434                IrExpr::FunctionCall { name, .. } if name == "_path_fixed_length"
8435            )
8436        });
8437        assert!(
8438            has_fixed,
8439            "explicit *1..1 must use the fixed-segment rewrite"
8440        );
8441    }
8442
8443    #[test]
8444    fn path_var_name_conflict_is_rejected() {
8445        let errors = bind_errors("MATCH p = (p)-[:KNOWS*1..2]->(b) RETURN length(p)");
8446        assert!(
8447            errors
8448                .iter()
8449                .any(|e| matches!(e.kind, BindErrorKind::DuplicateVariable)),
8450            "expected DuplicateVariable, got {errors:?}"
8451        );
8452    }
8453
8454    #[test]
8455    fn optional_match_path_var_functions_bind() {
8456        // path_vars introduced inside OPTIONAL MATCH must propagate to the
8457        // outer scope so a later RETURN can rewrite against them.
8458        let (binder, _) = make_binder(OntologyMode::Exploratory);
8459        let ast = parse(
8460            "MATCH (a:Person) \
8461             OPTIONAL MATCH p = (a)-[:KNOWS*1..2]->(b) \
8462             RETURN nodes(p) AS ns, length(p) AS l",
8463        )
8464        .unwrap();
8465        binder.bind(&ast).expect("optional path functions bind");
8466    }
8467
8468    fn procedure_binder() -> Binder {
8469        let (binder, _) = make_binder(OntologyMode::Exploratory);
8470        let procedure = ProcedureDefinition {
8471            name: "test.proc".into(),
8472            inputs: vec![ProcedureField {
8473                name: "in".into(),
8474                type_name: "INTEGER".into(),
8475                nullable: true,
8476            }],
8477            outputs: vec![ProcedureField {
8478                name: "out".into(),
8479                type_name: "INTEGER".into(),
8480                nullable: true,
8481            }],
8482            rows: vec![vec![IrLiteral::Int(1), IrLiteral::Int(2)]],
8483        };
8484        binder.with_procedures(Arc::new(ProcedureRegistry::from([(
8485            procedure.name.clone(),
8486            procedure,
8487        )])))
8488    }
8489
8490    #[test]
8491    fn call_binds_explicit_args_and_yield_alias() {
8492        let plan = procedure_binder()
8493            .bind(&parse("CALL test.proc(1) YIELD out AS value RETURN value").unwrap())
8494            .expect("CALL should bind");
8495        let GraphOp::Call { args, yields, .. } = &plan.ops[0] else {
8496            panic!("expected CALL op")
8497        };
8498        assert_eq!(args.len(), 1);
8499        assert_eq!(yields[0].field, "out");
8500        assert_eq!(yields[0].alias, "value");
8501    }
8502
8503    #[test]
8504    fn call_without_parentheses_uses_implicit_parameters() {
8505        let plan = procedure_binder()
8506            .bind(&parse("CALL test.proc YIELD out").unwrap())
8507            .expect("implicit CALL should bind");
8508        let GraphOp::Call { args, .. } = &plan.ops[0] else {
8509            panic!("expected CALL op")
8510        };
8511        assert!(matches!(plan.exprs.get(args[0]), IrExpr::Parameter(name) if name == "in"));
8512    }
8513
8514    #[test]
8515    fn call_rejects_unknown_procedure_argument_count_and_yield() {
8516        for (query, message) in [
8517            ("CALL missing.proc()", "ProcedureNotFound"),
8518            ("CALL test.proc()", "InvalidNumberOfArguments"),
8519            ("CALL test.proc(1) YIELD missing", "ProcedureOutputNotFound"),
8520        ] {
8521            let errors = procedure_binder()
8522                .bind(&parse(query).unwrap())
8523                .expect_err("CALL should fail");
8524            assert!(
8525                errors.iter().any(|error| error.message.contains(message)),
8526                "expected {message}, got {errors:?}"
8527            );
8528        }
8529    }
8530
8531    #[test]
8532    fn union_binds_branch_plans_and_mode() {
8533        let (binder, _) = make_binder(OntologyMode::Exploratory);
8534        let plan = binder
8535            .bind(&parse("RETURN 1 AS x UNION ALL RETURN 2 AS x").unwrap())
8536            .expect("UNION ALL binds");
8537        let [GraphOp::Union { all, inputs }] = plan.ops.as_slice() else {
8538            panic!("expected one UNION op")
8539        };
8540        assert!(*all);
8541        assert_eq!(inputs.len(), 2);
8542        assert!(inputs.iter().all(|branch| !branch.ops.is_empty()));
8543    }
8544
8545    #[test]
8546    fn union_rejects_mixed_modes_and_different_columns() {
8547        let (binder, _) = make_binder(OntologyMode::Exploratory);
8548        for (query, message) in [
8549            (
8550                "RETURN 1 AS x UNION RETURN 2 AS x UNION ALL RETURN 3 AS x",
8551                "InvalidCombinationOfUnion",
8552            ),
8553            (
8554                "RETURN 1 AS x UNION RETURN 2 AS y",
8555                "DifferentColumnsInUnion",
8556            ),
8557            ("CREATE (:A) UNION CREATE (:B)", "DifferentColumnsInUnion"),
8558        ] {
8559            let errors = binder
8560                .bind(&parse(query).unwrap())
8561                .expect_err("UNION should fail");
8562            assert!(errors.iter().any(|error| error.message.contains(message)));
8563        }
8564    }
8565
8566    #[test]
8567    fn return_wildcard_requires_a_variable_in_scope() {
8568        let (binder, _) = make_binder(OntologyMode::Exploratory);
8569        let errors = binder
8570            .bind(&parse("RETURN *").unwrap())
8571            .expect_err("empty-scope RETURN wildcard must fail");
8572        assert!(errors.iter().any(|error| {
8573            error
8574                .message
8575                .contains("wildcard requires at least one variable")
8576        }));
8577        assert_ne!(errors[0].span, Span::default());
8578    }
8579
8580    #[test]
8581    fn with_wildcard_preserves_an_empty_named_scope() {
8582        let (binder, _) = make_binder(OntologyMode::Exploratory);
8583        let plan = binder
8584            .bind(&parse("CREATE () WITH * CREATE ()").unwrap())
8585            .expect("empty-scope WITH wildcard should preserve pipeline rows");
8586        assert_eq!(plan.ops.len(), 3);
8587        assert!(matches!(&plan.ops[1], GraphOp::With { items, .. } if items.is_empty()));
8588    }
8589
8590    #[test]
8591    fn typed_uuid_parameters_are_identity_only_across_expression_surfaces() {
8592        let params = HashMap::from([("id".into(), IrLiteral::Uuid([0x55; 16]))]);
8593        for query in [
8594            "MATCH (n:Person) WHERE NOT (n.node_uuid = $id) RETURN n",
8595            "MATCH (n:Person) WHERE n.node_uuid <> $id RETURN n",
8596            "MATCH (n:Person) WHERE n.node_uuid > $id RETURN n",
8597            "MATCH (n:Person) WHERE n.node_uuid IN [$id] RETURN n",
8598            "RETURN $id",
8599            "RETURN toString($id)",
8600            "RETURN size([$id])",
8601            "RETURN [$id]",
8602            "RETURN 1 AS value SKIP $id",
8603            "RETURN 1 AS value LIMIT $id",
8604            "MATCH (n:Person) WHERE (($id = n.name) OR false) RETURN n",
8605            "MATCH (n:Person) WHERE n.name IN [$id] RETURN n",
8606            "MATCH (n:Person) RETURN n.name = $id AS bad",
8607            "MATCH (n:Person) WITH n, n.name = $id AS bad RETURN bad",
8608            "MATCH (n:Person) RETURN n.name AS name ORDER BY n.name = $id",
8609            "MATCH (n:Person) UNWIND [n.name = $id] AS bad RETURN bad",
8610            "MATCH (n:Person {probe: n.name = $id}) RETURN n",
8611            "MATCH (n:Person) SET n.probe = (n.name = $id) RETURN n",
8612            "MATCH (n:Person) MERGE (m:Other {probe: n.name = $id}) RETURN m",
8613            "MATCH (n:Person) DELETE (n.name = $id)",
8614            "MATCH (n:Person)-[r:KNOWS]->() WHERE r.node_uuid = $id RETURN r",
8615            "MATCH (n:Person)-[r:KNOWS]->() WHERE n.edge_uuid = $id RETURN n",
8616        ] {
8617            let (binder, _) = make_binder(OntologyMode::Exploratory);
8618            let errors = binder
8619                .with_parameter_literals(&params)
8620                .bind(&parse(query).unwrap())
8621                .expect_err("typed UUID must not reach a non-identity expression");
8622            assert!(
8623                errors.iter().any(|error| {
8624                    error.kind == BindErrorKind::InvalidArgument
8625                        && error.message
8626                            == "typed UUID parameter `$id` is only supported as a direct node_uuid or edge_uuid identity equality predicate"
8627                }),
8628                "query={query} errors={errors:?}"
8629            );
8630        }
8631
8632        let errors = procedure_binder()
8633            .with_parameter_literals(&params)
8634            .bind(
8635                &parse("MATCH (n:Person) CALL test.proc(n.name = $id) YIELD out RETURN out")
8636                    .unwrap(),
8637            )
8638            .expect_err("CALL argument must enforce typed UUID identity semantics");
8639        assert!(
8640            errors.iter().any(|error| {
8641                error.kind == BindErrorKind::InvalidArgument
8642                    && error.message.starts_with("typed UUID parameter `$id`")
8643            }),
8644            "errors={errors:?}"
8645        );
8646    }
8647
8648    #[test]
8649    fn typed_uuid_parameters_allow_only_kind_correct_identity_fields() {
8650        let params = HashMap::from([("id".into(), IrLiteral::Uuid([0x55; 16]))]);
8651        for query in [
8652            "MATCH (n:Person) WHERE n.node_uuid = $id RETURN n.node_uuid",
8653            "MATCH (n:Person) WHERE $id = n.node_uuid RETURN n.node_uuid",
8654            "MATCH ()-[r:KNOWS]->() WHERE r.edge_uuid = $id RETURN r.edge_uuid",
8655            "MATCH ()-[r:KNOWS]->() WHERE $id = r.edge_uuid RETURN r.edge_uuid",
8656        ] {
8657            let (binder, _) = make_binder(OntologyMode::Exploratory);
8658            binder
8659                .with_parameter_literals(&params)
8660                .bind(&parse(query).unwrap())
8661                .unwrap_or_else(|errors| panic!("query={query} errors={errors:?}"));
8662        }
8663
8664        for nested in [
8665            IrLiteral::List(vec![IrLiteral::Uuid([0x55; 16])]),
8666            IrLiteral::Map(vec![(
8667                "nested".into(),
8668                IrLiteral::List(vec![IrLiteral::Uuid([0x55; 16])]),
8669            )]),
8670        ] {
8671            let (binder, _) = make_binder(OntologyMode::Exploratory);
8672            let errors = binder
8673                .with_parameter_literals(&HashMap::from([("id".into(), nested)]))
8674                .bind(
8675                    &parse("MATCH (n:Person) WHERE n.node_uuid = $id RETURN n.node_uuid").unwrap(),
8676                )
8677                .expect_err("containers containing UUID values are never identity scalars");
8678            assert!(errors.iter().any(|error| {
8679                error.kind == BindErrorKind::InvalidArgument
8680                    && error.message.starts_with("typed UUID parameter `$id`")
8681            }));
8682        }
8683    }
8684
8685    #[test]
8686    fn nested_aggregate_rewrite_covers_scalar_container_shapes() {
8687        let cases = [
8688            "MATCH (n:Person) RETURN count(*) + 1 AS value",
8689            "MATCH (n:Person) RETURN -count(*) AS value",
8690            "MATCH (n:Person) RETURN (count(*)) AS value",
8691            "MATCH (n:Person) RETURN coalesce(count(*), 0) AS value",
8692            "MATCH (n:Person) RETURN [count(*), 1] AS value",
8693            "MATCH (n:Person) RETURN {total: count(*), fallback: 0} AS value",
8694            "MATCH (n:Person) RETURN CASE count(*) WHEN 0 THEN 1 ELSE count(*) END AS value",
8695            "MATCH (n:Person) RETURN count(*) IS NULL AS value",
8696            "MATCH (n:Person) RETURN count(*) IN [0, 1] AS value",
8697            "MATCH (n:Person) RETURN toString(count(*)) STARTS WITH '1' AS value",
8698            "MATCH (n:Person) RETURN toString(count(*)) =~ '.*' AS value",
8699            "MATCH (n:Person) RETURN [x IN collect(n.name) | x] AS value",
8700            "MATCH (n:Person) RETURN all(x IN collect(n.name) WHERE x IS NOT NULL) AS value",
8701        ];
8702
8703        for query in cases {
8704            let (binder, _) = make_binder(OntologyMode::Exploratory);
8705            let plan = binder
8706                .bind(&parse(query).unwrap())
8707                .unwrap_or_else(|errors| panic!("query={query} errors={errors:?}"));
8708            let aggregate_count = plan
8709                .ops
8710                .iter()
8711                .filter(|op| matches!(op, GraphOp::Aggregate { .. }))
8712                .count();
8713            assert_eq!(aggregate_count, 1, "query={query} ops={:?}", plan.ops);
8714            assert!(
8715                plan.ops
8716                    .iter()
8717                    .any(|op| matches!(op, GraphOp::Project { .. })),
8718                "nested aggregate needs a final projection: query={query} ops={:?}",
8719                plan.ops
8720            );
8721        }
8722    }
8723
8724    #[test]
8725    fn typed_uuid_discovery_traverses_every_expression_container() {
8726        let params = HashMap::from([("id".into(), IrLiteral::Uuid([0x55; 16]))]);
8727        let (binder, _) = make_binder(OntologyMode::Exploratory);
8728        let binder = binder.with_parameter_literals(&params);
8729        let cases = [
8730            "$id",
8731            "($id)",
8732            "-$id",
8733            "[$id]",
8734            "{value: $id}",
8735            "coalesce(null, $id)",
8736            "1 + $id",
8737            "CASE $id WHEN null THEN 0 ELSE 1 END",
8738            "CASE 1 WHEN $id THEN 0 ELSE 1 END",
8739            "CASE 1 WHEN 0 THEN $id ELSE 1 END",
8740            "CASE 1 WHEN 0 THEN 1 ELSE $id END",
8741            "[x IN [$id] WHERE x IS NOT NULL | x]",
8742            "[x IN [1] WHERE $id IS NOT NULL | x]",
8743            "[x IN [1] | $id]",
8744            "all(x IN [$id] WHERE x IS NOT NULL)",
8745            "all(x IN [1] WHERE $id IS NOT NULL)",
8746            "[(a)-->(b) WHERE $id IS NOT NULL | a]",
8747            "[(a)-->(b) | $id]",
8748            "$id IS NULL",
8749            "$id IN [1]",
8750            "1 IN [$id]",
8751            "$id STARTS WITH 'x'",
8752            "'x' STARTS WITH $id",
8753            "$id =~ 'x'",
8754            "'x' =~ $id",
8755        ];
8756        for source in cases {
8757            let expression = parsed_return_expr(&format!("RETURN {source}"));
8758            assert_eq!(
8759                binder.typed_uuid_param_in(&expression),
8760                Some("id"),
8761                "{source}"
8762            );
8763        }
8764        assert_eq!(
8765            binder.typed_uuid_param_in(&parsed_return_expr("RETURN [1, 2, 3]")),
8766            None
8767        );
8768    }
8769
8770    #[test]
8771    fn expression_shape_comparison_covers_scalar_and_container_variants() {
8772        let equal = [
8773            ("1", "1"),
8774            ("1.5", "1.5"),
8775            ("'x'", "'x'"),
8776            ("true", "true"),
8777            ("null", "null"),
8778            ("$value", "$value"),
8779            ("a", "a"),
8780            ("a.name", "a.name"),
8781            ("a + 1", "a + 1"),
8782            ("NOT a", "NOT a"),
8783            ("coalesce(a, 1)", "COALESCE(a, 1)"),
8784        ];
8785        for (left, right) in equal {
8786            assert!(
8787                same_expr_shape(
8788                    &parsed_return_expr(&format!("RETURN {left}")),
8789                    &parsed_return_expr(&format!("RETURN {right}")),
8790                ),
8791                "{left} should have the same shape as {right}"
8792            );
8793        }
8794
8795        let unequal = [
8796            ("1", "2"),
8797            ("1.5", "2.5"),
8798            ("'x'", "'y'"),
8799            ("true", "false"),
8800            ("$left", "$right"),
8801            ("a", "b"),
8802            ("a.name", "a.age"),
8803            ("a + 1", "a - 1"),
8804            ("NOT a", "-a"),
8805            ("coalesce(a, 1)", "coalesce(a, 2)"),
8806            ("[a, 1]", "[a]"),
8807            ("{a: 1}", "{a: 2}"),
8808            ("CASE WHEN true THEN 1 END", "1"),
8809        ];
8810        for (left, right) in unequal {
8811            assert!(
8812                !same_expr_shape(
8813                    &parsed_return_expr(&format!("RETURN {left}")),
8814                    &parsed_return_expr(&format!("RETURN {right}")),
8815                ),
8816                "{left} should differ from {right}"
8817            );
8818        }
8819
8820        assert!(same_grouping_expr(
8821            &parsed_return_expr("RETURN [a, 1]"),
8822            &parsed_return_expr("RETURN [a, 1]"),
8823        ));
8824        assert!(!same_grouping_expr(
8825            &parsed_return_expr("RETURN [a, 1]"),
8826            &parsed_return_expr("RETURN [a]"),
8827        ));
8828        assert!(same_grouping_expr(
8829            &parsed_return_expr("RETURN {a: 1, b: true}"),
8830            &parsed_return_expr("RETURN {b: true, a: 1}"),
8831        ));
8832        assert!(!same_grouping_expr(
8833            &parsed_return_expr("RETURN {a: 1}"),
8834            &parsed_return_expr("RETURN {a: 2}"),
8835        ));
8836    }
8837
8838    #[test]
8839    fn row_count_constant_classification_covers_every_ast_shape() {
8840        for source in ["1", "(1)", "-1", "$n", "1 + 2", "toInteger(1.5)"] {
8841            assert!(
8842                row_count_expr_is_integer(&parsed_return_expr(&format!("RETURN {source}"))),
8843                "{source}"
8844            );
8845        }
8846        assert!(!row_count_expr_is_integer(&parsed_return_expr(
8847            "RETURN 1.5"
8848        )));
8849
8850        for source in ["1.5", "(1.5)", "-1.5"] {
8851            assert!(
8852                is_float_constant(&parsed_return_expr(&format!("RETURN {source}"))),
8853                "{source}"
8854            );
8855        }
8856        assert!(!is_float_constant(&parsed_return_expr("RETURN 1")));
8857
8858        assert_eq!(
8859            extract_parameter_name(&parsed_return_expr("RETURN ($rows)")),
8860            Some("rows".into())
8861        );
8862        assert_eq!(
8863            extract_parameter_name(&parsed_return_expr("RETURN 1")),
8864            None
8865        );
8866        assert_eq!(
8867            extract_int_constant(&parsed_return_expr("RETURN -(-1)")),
8868            Some(1)
8869        );
8870        assert_eq!(
8871            extract_int_constant(&parsed_return_expr("RETURN true")),
8872            None
8873        );
8874        assert_eq!(
8875            extract_non_negative_int_constant(&parsed_return_expr("RETURN -1")),
8876            None
8877        );
8878    }
8879
8880    #[test]
8881    fn procedure_literal_type_checks_cover_nullability_and_scalar_domains() {
8882        let field = |type_name: &str, nullable| ProcedureField {
8883            name: "arg".into(),
8884            type_name: type_name.into(),
8885            nullable,
8886        };
8887        assert!(procedure_argument_type_matches(
8888            &parsed_return_expr("RETURN null"),
8889            &field("STRING", true)
8890        ));
8891        assert!(!procedure_argument_type_matches(
8892            &parsed_return_expr("RETURN null"),
8893            &field("STRING", false)
8894        ));
8895        assert!(procedure_argument_type_matches(
8896            &parsed_return_expr("RETURN 1"),
8897            &field("INTEGER", false)
8898        ));
8899        assert!(procedure_argument_type_matches(
8900            &parsed_return_expr("RETURN 1"),
8901            &field("NUMBER", false)
8902        ));
8903        assert!(!procedure_argument_type_matches(
8904            &parsed_return_expr("RETURN 1"),
8905            &field("STRING", false)
8906        ));
8907        assert!(procedure_argument_type_matches(
8908            &parsed_return_expr("RETURN 1.5"),
8909            &field("FLOAT", false)
8910        ));
8911        assert!(!procedure_argument_type_matches(
8912            &parsed_return_expr("RETURN 1.5"),
8913            &field("INTEGER", false)
8914        ));
8915        assert!(procedure_argument_type_matches(
8916            &parsed_return_expr("RETURN 'x'"),
8917            &field("STRING", false)
8918        ));
8919        assert!(!procedure_argument_type_matches(
8920            &parsed_return_expr("RETURN 'x'"),
8921            &field("BOOLEAN", false)
8922        ));
8923        assert!(procedure_argument_type_matches(
8924            &parsed_return_expr("RETURN true"),
8925            &field("BOOLEAN", false)
8926        ));
8927        assert!(!procedure_argument_type_matches(
8928            &parsed_return_expr("RETURN true"),
8929            &field("STRING", false)
8930        ));
8931        assert!(procedure_argument_type_matches(
8932            &parsed_return_expr("RETURN (1)"),
8933            &field("INTEGER", false)
8934        ));
8935        assert!(procedure_argument_type_matches(
8936            &parsed_return_expr("RETURN a"),
8937            &field("ANY", false)
8938        ));
8939    }
8940
8941    #[test]
8942    fn binder_exercises_fragmented_clause_and_expression_error_paths() {
8943        let cases = [
8944            ("MATCH (n) WHERE n.active = true RETURN n", true),
8945            ("MATCH (n) SET missing.value = 1 RETURN n", false),
8946            ("MATCH (n) REMOVE missing.value RETURN n", false),
8947            ("MATCH (n) DELETE n.name", true),
8948            ("MATCH (n) WITH n.name RETURN n", false),
8949            ("MATCH (n) WITH count(*) AS total RETURN total", true),
8950            (
8951                "MATCH (n) RETURN n ORDER BY n.name DESC SKIP (1 + 2) LIMIT toInteger(3.5)",
8952                true,
8953            ),
8954            ("MATCH (n) RETURN percentileCont(n.value)", false),
8955            (
8956                "MATCH (n) RETURN percentileCont(DISTINCT n.value, 0.5)",
8957                false,
8958            ),
8959            ("MATCH (n) RETURN 'a' + 'b'", true),
8960            ("MATCH (n) RETURN NOT (n.value IN [1, 2])", true),
8961            (
8962                "MATCH (n) WHERE exists { (n)-->(m) WHERE count(*) > 0 } RETURN n",
8963                false,
8964            ),
8965            ("MATCH (n) RETURN [(n)-->(m) WHERE count(*) > 0 | m]", false),
8966            ("MATCH (n) WHERE (n)-->(m) OR (n)-->(x) RETURN n", false),
8967            ("MATCH (n) WHERE n.active OR (n)-->(m) RETURN n", false),
8968            ("UNWIND [1, 2] AS x RETURN x", true),
8969        ];
8970        for (query, should_bind) in cases {
8971            let ast = parse(query).unwrap_or_else(|error| panic!("query={query}: {error}"));
8972            let (binder, _) = make_binder(OntologyMode::Exploratory);
8973            let result = binder.bind(&ast);
8974            assert_eq!(
8975                result.is_ok(),
8976                should_bind,
8977                "query={query}, result={result:?}"
8978            );
8979        }
8980    }
8981
8982    #[test]
8983    fn binder_query_matrix_freezes_union_write_and_predicate_boundaries() {
8984        let cases = [
8985            ("RETURN 1 AS x UNION RETURN 2 AS x", true),
8986            ("RETURN 1 AS x UNION ALL RETURN 2 AS x", true),
8987            (
8988                "RETURN 1 AS x UNION RETURN 2 AS x UNION ALL RETURN 3 AS x",
8989                false,
8990            ),
8991            ("RETURN 1 AS x UNION RETURN 2 AS y", false),
8992            (
8993                "MERGE (n:Person {id: 1}) ON CREATE SET n.name = 'Ada' ON MATCH SET n.name = 'Grace' RETURN n",
8994                true,
8995            ),
8996            (
8997                "MATCH (n:Person) SET n += {score: 1}, n:Employee RETURN n",
8998                true,
8999            ),
9000            ("MATCH (n:Person) SET n = {score: 1} RETURN n", true),
9001            ("MATCH (n:Person) REMOVE n.score, n:Employee RETURN n", true),
9002            ("MATCH (n:Person) WHERE n:Employee RETURN n", true),
9003            ("MATCH (a)-[r:KNOWS]->(b) WHERE r:LIKES RETURN r", true),
9004            ("UNWIND [1, 2] AS x RETURN all(y IN [x] WHERE y > 0)", true),
9005            (
9006                "MATCH (n) RETURN [x IN [1, 2] WHERE x > 1 | x + 1] AS values",
9007                true,
9008            ),
9009            ("MATCH (n) RETURN n:Person", true),
9010            ("WITH 1 AS x RETURN x:Person", false),
9011            (
9012                "MATCH (a)-[r:KNOWS*1..2 {since: 2020, active: true}]->(b) RETURN r",
9013                true,
9014            ),
9015            (
9016                "MERGE (n:Person) ON CREATE SET missing += {score: 1} RETURN n",
9017                false,
9018            ),
9019            (
9020                "MERGE (n:Person) ON MATCH SET missing:Employee RETURN n",
9021                false,
9022            ),
9023            ("MATCH (n) SET missing += {score: 1} RETURN n", false),
9024            ("MATCH (n) SET missing:Person RETURN n", false),
9025            ("MATCH (n) REMOVE missing:Person RETURN n", false),
9026            ("MATCH (n) DELETE [n]", false),
9027            ("MATCH (n) RETURN NOT (n.score IN [1, 2])", true),
9028            ("MATCH (n) RETURN missing:Person", false),
9029            ("MATCH (n) CALL missing.procedure() RETURN n", false),
9030            ("MATCH (n) RETURN exists { (n)-->(m) }", false),
9031        ];
9032
9033        for (query, should_bind) in cases {
9034            let ast = parse(query).unwrap_or_else(|error| panic!("query={query}: {error}"));
9035            let (binder, _) = make_binder(OntologyMode::Exploratory);
9036            let result = binder.bind(&ast);
9037            assert_eq!(
9038                result.is_ok(),
9039                should_bind,
9040                "query={query}, result={result:?}"
9041            );
9042        }
9043    }
9044
9045    #[test]
9046    fn strict_without_ontology_rejects_property_resolution() {
9047        let (binder, _) = make_binder(OntologyMode::Strict);
9048        let ast = parse("MATCH (n:Person) RETURN n.unknown").unwrap();
9049        let errors = binder.bind(&ast).unwrap_err();
9050        assert!(errors.iter().any(|error| {
9051            error.kind == BindErrorKind::UnknownProperty
9052                && error.message == "unknown property `unknown` (strict mode has no ontology)"
9053        }));
9054    }
9055
9056    #[test]
9057    fn exact_zero_binder_semantic_branches_have_public_query_oracles() {
9058        let cases = [
9059            ("RETURN 1 + 2 AS value", true),
9060            ("RETURN count(*) AS x UNION RETURN count(*) AS x", true),
9061            (
9062                "MATCH p=(a)-[r]->(b) WITH p, count(*) AS total RETURN p, total",
9063                true,
9064            ),
9065            (
9066                "MATCH p=(a)-[r]->(b) WITH p, count(*) + 1 AS total RETURN p, total",
9067                true,
9068            ),
9069            (
9070                "MATCH (a)-[r]->(b) WITH r, count(*) + 1 AS total RETURN r, total",
9071                true,
9072            ),
9073            (
9074                "WITH 7 AS x RETURN any(x IN [1, 2] WHERE x > 1) AS found, x",
9075                true,
9076            ),
9077        ];
9078        for (query, should_bind) in cases {
9079            let (binder, _) = make_binder(OntologyMode::Exploratory);
9080            let result = binder.bind(&parse(query).unwrap());
9081            assert_eq!(
9082                result.is_ok(),
9083                should_bind,
9084                "query={query}, result={result:?}"
9085            );
9086        }
9087    }
9088
9089    #[test]
9090    fn exact_zero_concat_ast_lowers_to_string_function() {
9091        let mut expression = parsed_return_expr("RETURN 'left' + 'right'");
9092        let Expr::BinaryOp(binary) = &mut expression else {
9093            panic!("expected binary expression")
9094        };
9095        binary.op = AstBinOp::Concat;
9096        let (binder, _) = make_binder(OntologyMode::Exploratory);
9097        let mut state = empty_state(OntologyMode::Exploratory);
9098        let id = binder.lower_expr(&expression, expression.span(), &mut state);
9099        assert!(state.errors.is_empty());
9100        let plan = state.builder.build();
9101        assert!(matches!(
9102            plan.exprs.get(id),
9103            IrExpr::FunctionCall { name, args } if name == "string.concat" && args.len() == 2
9104        ));
9105    }
9106
9107    #[test]
9108    fn exact_zero_union_empty_branches_are_reported_without_panicking() {
9109        let parsed = parse("RETURN 1 AS x").unwrap();
9110        for clauses in [
9111            vec![
9112                AstClause::Union(graphforge_ast::UnionClause {
9113                    all: false,
9114                    span: Span::new(0, 5),
9115                }),
9116                parsed.clauses[0].clone(),
9117            ],
9118            vec![
9119                parsed.clauses[0].clone(),
9120                AstClause::Union(graphforge_ast::UnionClause {
9121                    all: false,
9122                    span: Span::new(10, 15),
9123                }),
9124            ],
9125        ] {
9126            let query = AstQuery {
9127                dialect: parsed.dialect,
9128                clauses,
9129                span: Span::new(0, 15),
9130            };
9131            let (binder, _) = make_binder(OntologyMode::Exploratory);
9132            let errors = binder
9133                .bind(&query)
9134                .expect_err("empty UNION branch must fail");
9135            assert!(errors.iter().any(|error| {
9136                error.kind == BindErrorKind::InvalidArgument
9137                    && error.message == "UNION requires a query on both sides"
9138            }));
9139        }
9140    }
9141
9142    #[test]
9143    fn exact_zero_direct_property_guards_cover_path_identity_and_write_targets() {
9144        let (binder, _) = make_binder(OntologyMode::Exploratory);
9145        let mut state = empty_state(OntologyMode::Exploratory);
9146        state.path_vars.insert(
9147            "p".into(),
9148            PathBinding {
9149                nodes: vec![VarId(0)],
9150                segments: Vec::new(),
9151            },
9152        );
9153        let path_property = parsed_return_expr("RETURN p.name");
9154        binder.lower_expr(&path_property, path_property.span(), &mut state);
9155        assert!(state.errors.iter().any(|error| {
9156            error.kind == BindErrorKind::InvalidArgument
9157                && error.message.contains("not valid on a path")
9158        }));
9159
9160        state.vars.insert("r".into(), VarId(1));
9161        state.var_kinds.insert(VarId(1), VarKind::Relationship);
9162        let wrong_identity = parsed_return_expr("RETURN r.node_uuid");
9163        binder.lower_expr(&wrong_identity, wrong_identity.span(), &mut state);
9164        assert!(state.errors.iter().any(|error| {
9165            error.kind == BindErrorKind::InvalidArgument
9166                && error.message.contains("valid only on a node")
9167        }));
9168
9169        let malformed = PropertyAccess {
9170            object: Box::new(Expr::Literal(Literal::Int(1, Span::new(0, 1)))),
9171            key: "name".into(),
9172            span: Span::new(0, 6),
9173        };
9174        assert!(
9175            binder
9176                .resolve_write_target(&malformed, &mut state)
9177                .is_none()
9178        );
9179        assert!(state.errors.iter().any(|error| {
9180            error.kind == BindErrorKind::InvalidDeleteTarget
9181                && error
9182                    .message
9183                    .contains("write target must be a bound variable")
9184        }));
9185    }
9186
9187    #[test]
9188    fn exact_zero_embedded_pattern_predicate_shape_is_rejected() {
9189        let ast = parse("MATCH (n) WHERE (n)-->(m) RETURN n").unwrap();
9190        let AstClause::Match(match_clause) = &ast.clauses[0] else {
9191            panic!("expected MATCH clause")
9192        };
9193        let where_clause = match_clause.where_clause.as_ref().expect("inline WHERE");
9194        let predicate = Expr::BinaryOp(graphforge_ast::BinaryOp {
9195            op: AstBinOp::Eq,
9196            left: Box::new(where_clause.predicate.clone()),
9197            right: Box::new(Expr::Literal(Literal::Bool(true, where_clause.span))),
9198            span: where_clause.span,
9199        });
9200        let (binder, _) = make_binder(OntologyMode::Exploratory);
9201        let mut state = empty_state(OntologyMode::Exploratory);
9202        state.vars.insert("n".into(), VarId(0));
9203        state.node_vars.insert(VarId(0), None);
9204        state.var_kinds.insert(VarId(0), VarKind::Node);
9205        binder.lower_where_predicate(&predicate, predicate.span(), &mut state);
9206        assert!(state.errors.iter().any(|error| {
9207            error.kind == BindErrorKind::InvalidArgument
9208                && error
9209                    .message
9210                    .contains("supported only as single-relationship")
9211        }));
9212    }
9213
9214    #[test]
9215    fn exact_zero_nested_pattern_comprehension_cardinality_is_rejected() {
9216        let query = "MATCH (a) RETURN [x IN nodes([(a)-->(b) | a]) | [(x)-->(c) | c] + [(x)-->(d) | d]] AS values";
9217        let (binder, _) = make_binder(OntologyMode::Exploratory);
9218        let errors = binder
9219            .bind(&parse(query).unwrap())
9220            .expect_err("one list comprehension cannot capture multiple child patterns");
9221        assert!(
9222            errors.iter().any(|error| {
9223                error.kind == BindErrorKind::InvalidArgument
9224                    && error
9225                        .message
9226                        .contains("exactly one nested pattern comprehension")
9227            }),
9228            "errors={errors:?}"
9229        );
9230    }
9231
9232    #[test]
9233    fn exact_zero_strict_property_owner_matrix_resolves_declared_properties() {
9234        use graphforge_ontology::{
9235            EntityTypeDef, OntologyCompiler, OntologyDoc, OntologyHandle, PropertyDef,
9236            PropertyValueType, RelationTypeDef, SemanticFlags,
9237        };
9238
9239        let doc = OntologyDoc {
9240            ontology_id: "property-owner-test".into(),
9241            version: "1.0".into(),
9242            entity_types: vec![EntityTypeDef {
9243                name: "Person".into(),
9244                r#abstract: false,
9245                parent: None,
9246            }],
9247            relation_types: vec![RelationTypeDef {
9248                name: "KNOWS".into(),
9249                src: "Person".into(),
9250                dst: "Person".into(),
9251                inverse: None,
9252                semantic: SemanticFlags::default(),
9253            }],
9254            properties: vec![
9255                PropertyDef {
9256                    owner: "Person".into(),
9257                    name: "name".into(),
9258                    value_type: PropertyValueType::Utf8,
9259                    nullable: true,
9260                    multivalued: false,
9261                    default_json: None,
9262                },
9263                PropertyDef {
9264                    owner: "KNOWS".into(),
9265                    name: "since".into(),
9266                    value_type: PropertyValueType::Int64,
9267                    nullable: true,
9268                    multivalued: false,
9269                    default_json: None,
9270                },
9271            ],
9272            constraints: vec![],
9273            migrations: vec![],
9274        };
9275        let handle = OntologyHandle::new(OntologyCompiler::compile(&doc).unwrap());
9276        for query in [
9277            "MATCH (n:Person) RETURN n.name",
9278            "MATCH (n) RETURN n.name",
9279            "MATCH ()-[r:KNOWS]->() RETURN r.since",
9280            "MATCH ()-[r]->() RETURN r.since",
9281        ] {
9282            let binder = Binder::new(
9283                Some(handle.clone()),
9284                Arc::new(Mutex::new(RuntimeCatalog::new())),
9285                OntologyMode::Strict,
9286            );
9287            binder
9288                .bind(&parse(query).unwrap())
9289                .unwrap_or_else(|errors| panic!("query={query}, errors={errors:?}"));
9290        }
9291    }
9292}