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 catalog_entry(catalog: &RuntimeCatalog, kind: &str, name: &str) -> (u32, u64) {
6567        let batch = catalog.to_record_batch();
6568        let kinds = batch
6569            .column(0)
6570            .as_any()
6571            .downcast_ref::<StringArray>()
6572            .unwrap();
6573        let names = batch
6574            .column(1)
6575            .as_any()
6576            .downcast_ref::<StringArray>()
6577            .unwrap();
6578        let ids = batch
6579            .column(2)
6580            .as_any()
6581            .downcast_ref::<UInt32Array>()
6582            .unwrap();
6583        let counts = batch
6584            .column(3)
6585            .as_any()
6586            .downcast_ref::<UInt64Array>()
6587            .unwrap();
6588        let row = (0..batch.num_rows())
6589            .find(|&row| kinds.value(row) == kind && names.value(row) == name)
6590            .unwrap_or_else(|| panic!("missing catalog entry {kind}:{name}"));
6591        (ids.value(row), counts.value(row))
6592    }
6593
6594    fn strict_property_binder(shadow_inherited: bool) -> (Binder, Arc<Mutex<RuntimeCatalog>>) {
6595        let shadow = if shadow_inherited {
6596            r#", {"owner":"Host","name":"inherited","type":"utf8"}"#
6597        } else {
6598            ""
6599        };
6600        let doc: OntologyDoc = serde_json::from_str(&format!(
6601            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}]}}"#
6602        ))
6603        .unwrap();
6604        let ontology = OntologyCompiler::compile(&doc).unwrap();
6605        let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
6606        catalog.lock().unwrap().intern_property("preexisting", None);
6607        (
6608            Binder::new(
6609                Some(OntologyHandle::new(ontology)),
6610                Arc::clone(&catalog),
6611                OntologyMode::Strict,
6612            ),
6613            catalog,
6614        )
6615    }
6616
6617    fn plan_property_ids(plan: &GraphPlan) -> Vec<PropId> {
6618        (0..u32::try_from(plan.exprs.len()).unwrap())
6619            .filter_map(|index| match plan.exprs.get(ExprId(index)) {
6620                IrExpr::PropertyAccess { prop, .. } => Some(*prop),
6621                _ => None,
6622            })
6623            .collect()
6624    }
6625
6626    fn property_error(binder: &Binder, query: &str, kind: BindErrorKind, span: &str) -> BindError {
6627        let errors = binder.bind(&parse(query).unwrap()).expect_err(query);
6628        assert_eq!(errors.len(), 1, "{query}: {errors:?}");
6629        let error = errors.into_iter().next().unwrap();
6630        assert_eq!(error.kind, kind);
6631        assert_eq!(&query[error.span.start..error.span.end], span);
6632        error
6633    }
6634
6635    #[test]
6636    fn exploratory_unknown_label_succeeds() {
6637        let (binder, catalog) = make_binder(OntologyMode::Exploratory);
6638        let ast = parse("MATCH (a:UnknownLabel)-[:UNKNOWN_REL]->(b) RETURN a").unwrap();
6639        let plan = binder.bind(&ast).expect("exploratory bind should succeed");
6640
6641        assert!(
6642            plan.ops
6643                .iter()
6644                .any(|op| matches!(op, GraphOp::NodeScan { .. }))
6645        );
6646        let cat = catalog.lock().unwrap();
6647        assert!(cat.contains_entity_type("UnknownLabel"));
6648        assert!(cat.relation_types().contains(&"UNKNOWN_REL"));
6649    }
6650
6651    #[test]
6652    fn bind_catalog_mutations_commit_only_after_success() {
6653        let (binder, catalog) = make_binder(OntologyMode::Exploratory);
6654        {
6655            let mut catalog = catalog.lock().unwrap();
6656            assert_eq!(catalog.intern_label("Seed").0, 0);
6657            assert_eq!(catalog.intern_relation_type("SEED_REL").0, 1);
6658            assert_eq!(catalog.intern_property("seed", None).0, 0);
6659        }
6660        let before = catalog.lock().unwrap().to_record_batch();
6661
6662        let errors = binder
6663            .bind(
6664                &parse(
6665                    "MATCH (n:Rejected)-[:REJECTED_REL]->() \
6666                     RETURN n.rejected, missing",
6667                )
6668                .unwrap(),
6669            )
6670            .expect_err("a later semantic error must reject every staged observation");
6671        assert!(
6672            errors
6673                .iter()
6674                .any(|error| error.kind == BindErrorKind::UndeclaredVariable)
6675        );
6676        assert_eq!(
6677            catalog.lock().unwrap().to_record_batch(),
6678            before,
6679            "failed binding must leave entries, observations, timestamps, and IDs unchanged"
6680        );
6681
6682        binder
6683            .bind(
6684                &parse(
6685                    "MATCH (n:Accepted)-[:ACCEPTED_REL]->() \
6686                     RETURN n.accepted",
6687                )
6688                .unwrap(),
6689            )
6690            .expect("successful binding must publish the staged catalog");
6691        let catalog = catalog.lock().unwrap();
6692        assert_eq!(catalog_entry(&catalog, "entity_type", "Accepted"), (2, 1));
6693        assert_eq!(
6694            catalog_entry(&catalog, "relation_type", "ACCEPTED_REL"),
6695            (3, 1)
6696        );
6697        assert_eq!(catalog_entry(&catalog, "property", "accepted"), (1, 1));
6698    }
6699
6700    #[test]
6701    fn fixed_pattern_predicate_lowers_to_exists() {
6702        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6703        let ast = parse("MATCH (n) WHERE (n)-[:REL]->() RETURN n").unwrap();
6704        let plan = binder.bind(&ast).expect("pattern predicate binds");
6705
6706        let exists = plan
6707            .ops
6708            .iter()
6709            .find_map(|op| match op {
6710                GraphOp::Exists { child, negated } => Some((child, negated)),
6711                _ => None,
6712            })
6713            .expect("pattern predicate should lower to Exists");
6714        assert!(!*exists.1);
6715        assert!(
6716            exists
6717                .0
6718                .ops
6719                .iter()
6720                .any(|op| matches!(op, GraphOp::Expand { .. })),
6721            "child plan must match the relationship pattern"
6722        );
6723    }
6724
6725    #[test]
6726    fn relationship_uniqueness_is_scoped_to_each_path_pattern() {
6727        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6728        let ast = parse("MATCH (a)-[r1]->(b)-[r2]->(c), (x)-[r3]->(y) RETURN a").unwrap();
6729        let plan = binder.bind(&ast).expect("pattern binds");
6730
6731        let constraints: Vec<_> = plan
6732            .ops
6733            .iter()
6734            .filter_map(|op| match op {
6735                GraphOp::RelationshipUnique { edge, prior_edges } => {
6736                    Some((*edge, prior_edges.clone()))
6737                }
6738                _ => None,
6739            })
6740            .collect();
6741        assert_eq!(constraints.len(), 1);
6742        assert_eq!(constraints[0].1.len(), 1);
6743    }
6744
6745    #[test]
6746    fn simple_existential_subquery_allows_child_local_variables() {
6747        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6748        let ast = parse("MATCH (n) WHERE exists { (n)-[r]->(m) WHERE type(r) = 'REL' } RETURN n")
6749            .unwrap();
6750        let plan = binder.bind(&ast).expect("existential subquery binds");
6751
6752        let child = plan
6753            .ops
6754            .iter()
6755            .find_map(|op| match op {
6756                GraphOp::Exists { child, negated } => {
6757                    assert!(!negated);
6758                    Some(child)
6759                }
6760                _ => None,
6761            })
6762            .expect("expected Exists op");
6763        assert!(
6764            child
6765                .ops
6766                .iter()
6767                .any(|op| matches!(op, GraphOp::Expand { .. }))
6768        );
6769        assert!(
6770            child
6771                .ops
6772                .iter()
6773                .any(|op| matches!(op, GraphOp::Filter { .. }))
6774        );
6775    }
6776
6777    #[test]
6778    fn full_existential_correlation_is_scope_aware() {
6779        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6780        let correlated = parse(
6781            "MATCH (n) WHERE exists { MATCH (m) WHERE m.prop = n.prop RETURN true } RETURN n",
6782        )
6783        .unwrap();
6784        binder
6785            .bind(&correlated)
6786            .expect("an outer variable used only in a child expression must bind");
6787
6788        let shadowed = parse("MATCH (n) WHERE exists { WITH 1 AS n RETURN n } RETURN n").unwrap();
6789        let errors = binder
6790            .bind(&shadowed)
6791            .expect_err("a child-local alias must not count as outer correlation");
6792        assert!(
6793            errors.iter().any(|error| {
6794                error.kind == BindErrorKind::UndeclaredVariable
6795                    && error
6796                        .message
6797                        .contains("must reference at least one outer variable")
6798            }),
6799            "expected uncorrelated-subquery error, got {errors:?}"
6800        );
6801    }
6802
6803    #[test]
6804    fn with_nested_aggregate_lowers_to_aggregate_then_scope_reset() {
6805        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6806        let ast =
6807            parse("MATCH (me)--(you) WITH me.age AS age, me.age + count(you.age) AS agg RETURN *")
6808                .unwrap();
6809        let plan = binder.bind(&ast).expect("nested WITH aggregate binds");
6810        let aggregate = plan
6811            .ops
6812            .iter()
6813            .position(|op| matches!(op, GraphOp::Aggregate { .. }))
6814            .expect("aggregate op");
6815        let with = plan
6816            .ops
6817            .iter()
6818            .skip(aggregate + 1)
6819            .position(|op| matches!(op, GraphOp::With { .. }))
6820            .expect("post-aggregate scope reset");
6821        assert_eq!(with, 0);
6822    }
6823
6824    #[test]
6825    fn with_rejects_ambiguous_and_nested_aggregation() {
6826        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6827        let ambiguous =
6828            parse("MATCH (me)--(you) WITH me.age + count(you.age) AS agg RETURN agg").unwrap();
6829        let error = binder
6830            .bind(&ambiguous)
6831            .expect_err("ambiguous grouping must fail");
6832        assert!(
6833            error
6834                .iter()
6835                .any(|error| error.message.contains("ambiguous aggregation expression"))
6836        );
6837
6838        let nested = parse("MATCH (n) WITH count(count(*)) AS c RETURN c").unwrap();
6839        let error = binder
6840            .bind(&nested)
6841            .expect_err("nested aggregates must fail");
6842        assert!(
6843            error
6844                .iter()
6845                .any(|error| { error.message.contains("may not contain another aggregate") })
6846        );
6847    }
6848
6849    #[test]
6850    fn return_rejects_nested_and_volatile_aggregation() {
6851        for (query, message) in [
6852            (
6853                "RETURN count(count(*))",
6854                "may not contain another aggregate",
6855            ),
6856            (
6857                "RETURN count(rand())",
6858                "non-deterministic functions are not allowed",
6859            ),
6860        ] {
6861            let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6862            let ast = parse(query).unwrap();
6863            let errors = binder.bind(&ast).expect_err("query must fail at bind");
6864            assert!(
6865                errors.iter().any(|error| error.message.contains(message)),
6866                "missing {message:?} for {query}: {errors:?}"
6867            );
6868        }
6869    }
6870
6871    #[test]
6872    fn return_rejects_unknown_function_at_bind() {
6873        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6874        let ast = parse("MATCH (a) RETURN foo(a)").unwrap();
6875        let errors = binder.bind(&ast).expect_err("unknown function must fail");
6876        assert!(
6877            errors
6878                .iter()
6879                .any(|error| error.message.contains("unknown function `foo`"))
6880        );
6881    }
6882
6883    #[test]
6884    fn negated_pattern_predicate_lowers_to_anti_exists() {
6885        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6886        let ast = parse("MATCH (n) WHERE NOT (n)-[:REL]-() RETURN n").unwrap();
6887        let plan = binder.bind(&ast).expect("negated pattern predicate binds");
6888
6889        assert!(
6890            plan.ops
6891                .iter()
6892                .any(|op| { matches!(op, GraphOp::Exists { negated: true, .. }) })
6893        );
6894    }
6895
6896    #[test]
6897    fn multi_type_pattern_predicate_lowers_to_union_exists() {
6898        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6899        let ast = parse("MATCH (n), (m) WHERE (n)-[:REL1|REL2]-(m) RETURN n").unwrap();
6900        let plan = binder.bind(&ast).expect("multi-type predicate binds");
6901
6902        let inputs = plan
6903            .ops
6904            .iter()
6905            .find_map(|op| match op {
6906                GraphOp::Exists { child, .. } => match child.ops.as_slice() {
6907                    [GraphOp::Union { inputs, .. }] => Some(inputs),
6908                    _ => None,
6909                },
6910                _ => None,
6911            })
6912            .expect("multi-type predicate should lower to union-backed Exists");
6913        assert_eq!(inputs.len(), 2);
6914    }
6915
6916    #[test]
6917    fn or_pattern_predicate_lowers_to_union_exists() {
6918        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6919        let ast = parse("MATCH (n) WHERE (n)-[:REL1]-() OR (n)-[:REL2]-() RETURN n").unwrap();
6920        let plan = binder.bind(&ast).expect("OR predicate binds");
6921
6922        let inputs = plan
6923            .ops
6924            .iter()
6925            .find_map(|op| match op {
6926                GraphOp::Exists { child, .. } => match child.ops.as_slice() {
6927                    [GraphOp::Union { inputs, .. }] => Some(inputs),
6928                    _ => None,
6929                },
6930                _ => None,
6931            })
6932            .expect("OR predicate should lower to union-backed Exists");
6933        assert_eq!(inputs.len(), 2);
6934    }
6935
6936    #[test]
6937    fn pattern_predicate_rejects_new_named_variables() {
6938        expect_bind_error(
6939            "MATCH (n) WHERE (n)-[r]->() RETURN n",
6940            BindErrorKind::UndeclaredVariable,
6941        );
6942        expect_bind_error(
6943            "MATCH (n) WHERE (n)-->(m) RETURN n",
6944            BindErrorKind::UndeclaredVariable,
6945        );
6946    }
6947
6948    #[test]
6949    fn pattern_predicate_rejects_named_path_binding() {
6950        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6951        let mut ast = parse("MATCH (n) WHERE (n)-[:REL]->() RETURN n").unwrap();
6952        let AstClause::Match(m) = &mut ast.clauses[0] else {
6953            panic!("expected MATCH clause");
6954        };
6955        let where_clause = m.where_clause.as_mut().expect("expected WHERE");
6956        let Expr::PatternPredicate(pp) = &mut where_clause.predicate else {
6957            panic!("expected pattern predicate");
6958        };
6959        pp.pattern.var = Some("p".into());
6960
6961        let errs = binder
6962            .bind(&ast)
6963            .expect_err("predicate-local path binding should fail");
6964        assert!(
6965            errs.iter()
6966                .any(|err| err.kind == BindErrorKind::UndeclaredVariable),
6967            "expected undeclared path binding error, got {errs:?}"
6968        );
6969    }
6970
6971    #[test]
6972    fn var_length_pattern_predicate_rejects_relationship_properties() {
6973        expect_bind_error(
6974            "MATCH (n) WHERE (n)-[:REL* {k: 1}]->() RETURN n",
6975            BindErrorKind::InvalidArgument,
6976        );
6977    }
6978
6979    #[test]
6980    fn pattern_predicate_rejects_uncorrelated_patterns() {
6981        expect_bind_error(
6982            "MATCH (n) WHERE ()-[:REL]->() RETURN n",
6983            BindErrorKind::UndeclaredVariable,
6984        );
6985    }
6986
6987    #[test]
6988    fn named_pattern_comprehension_binds_correlated_child_projection() {
6989        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
6990        let ast = parse("MATCH (n) RETURN [p = (n)-[:REL]->() | p] AS paths").unwrap();
6991        let plan = binder.bind(&ast).expect("pattern comprehension binds");
6992
6993        let (child, output) = plan
6994            .ops
6995            .iter()
6996            .find_map(|op| match op {
6997                GraphOp::PatternComprehension { child, output } => Some((child, output)),
6998                _ => None,
6999            })
7000            .expect("expected a PatternComprehension op");
7001        assert!(
7002            child
7003                .ops
7004                .iter()
7005                .any(|op| matches!(op, GraphOp::Expand { .. }))
7006        );
7007        let projection = child
7008            .ops
7009            .last()
7010            .and_then(|op| match op {
7011                GraphOp::Project { items, .. } => items.first(),
7012                _ => None,
7013            })
7014            .expect("child must end with one value projection");
7015        assert_eq!(
7016            projection.alias.as_deref(),
7017            Some(PATTERN_COMPREHENSION_VALUE_ALIAS)
7018        );
7019        assert!(matches!(
7020            child.exprs.get(projection.expr),
7021            IrExpr::FunctionCall { name, .. } if name == "_path_struct"
7022        ));
7023        let outer_projection = plan
7024            .ops
7025            .iter()
7026            .rev()
7027            .find_map(|op| match op {
7028                GraphOp::Project { items, .. } => items.first(),
7029                _ => None,
7030            })
7031            .expect("outer RETURN must project the collected result");
7032        assert!(matches!(
7033            plan.exprs.get(outer_projection.expr),
7034            IrExpr::VarRef(var) if var == output
7035        ));
7036    }
7037
7038    #[test]
7039    fn pattern_comprehension_binds_local_node_and_relationship_properties() {
7040        for query in [
7041            "MATCH (n) RETURN [(n)-[:REL]->(b) | b.name] AS names",
7042            "MATCH (n) RETURN [(n)-[r:REL]->() | r.name] AS names",
7043        ] {
7044            let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7045            let ast = parse(query).unwrap();
7046            let plan = binder.bind(&ast).expect("local projection binds");
7047            let child = plan
7048                .ops
7049                .iter()
7050                .find_map(|op| match op {
7051                    GraphOp::PatternComprehension { child, .. } => Some(child),
7052                    _ => None,
7053                })
7054                .expect("expected a PatternComprehension op");
7055            let projection = child
7056                .ops
7057                .last()
7058                .and_then(|op| match op {
7059                    GraphOp::Project { items, .. } => items.first(),
7060                    _ => None,
7061                })
7062                .expect("child must end with a projection");
7063            assert!(matches!(
7064                child.exprs.get(projection.expr),
7065                IrExpr::PropertyAccess { .. }
7066            ));
7067        }
7068    }
7069
7070    #[test]
7071    fn pattern_comprehension_binds_filter_and_variable_length_match() {
7072        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7073        let ast =
7074            parse("MATCH (n) RETURN [(n)-[r:REL*]->(b) WHERE b.ok = true | b] AS matches").unwrap();
7075        let plan = binder
7076            .bind(&ast)
7077            .expect("filtered var-length comprehension binds");
7078        let child = plan
7079            .ops
7080            .iter()
7081            .find_map(|op| match op {
7082                GraphOp::PatternComprehension { child, .. } => Some(child),
7083                _ => None,
7084            })
7085            .expect("expected a PatternComprehension op");
7086
7087        assert!(child.ops.iter().any(|op| {
7088            matches!(
7089                op,
7090                GraphOp::Expand {
7091                    min_hops: 1,
7092                    max_hops: None,
7093                    ..
7094                }
7095            )
7096        }));
7097        assert!(
7098            child
7099                .ops
7100                .iter()
7101                .any(|op| matches!(op, GraphOp::Filter { .. }))
7102        );
7103        assert!(matches!(child.ops.last(), Some(GraphOp::Project { .. })));
7104    }
7105
7106    #[test]
7107    fn pattern_comprehension_local_variables_do_not_leak() {
7108        expect_bind_error(
7109            "MATCH (n) RETURN [(n)-->(b) | b] AS matches, b",
7110            BindErrorKind::UndeclaredVariable,
7111        );
7112    }
7113
7114    #[test]
7115    fn pattern_comprehension_in_list_element_scope_lifts_to_graph_op() {
7116        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7117        let ast = parse("MATCH (n) RETURN [x IN [n] | [(x)-->() | x]] AS nested").unwrap();
7118        let plan = binder
7119            .bind(&ast)
7120            .expect("bind nested pattern comprehension");
7121        let lifted = plan.ops.iter().find_map(|op| match op {
7122            GraphOp::ListElementPatternComprehension { child, .. } => Some(child),
7123            _ => None,
7124        });
7125        let child = lifted.expect("list-element graph operation");
7126        assert!(matches!(child.ops.last(), Some(GraphOp::Project { .. })));
7127    }
7128
7129    #[test]
7130    fn bare_graph_value_where_predicate_is_rejected() {
7131        expect_bind_error(
7132            "MATCH (n) WHERE (n) RETURN n",
7133            BindErrorKind::InvalidArgument,
7134        );
7135    }
7136
7137    #[test]
7138    fn bare_path_value_where_predicate_is_rejected() {
7139        expect_bind_error(
7140            "MATCH p = (n)-[:REL]->() WHERE p RETURN p",
7141            BindErrorKind::InvalidArgument,
7142        );
7143    }
7144
7145    #[test]
7146    fn with_where_pattern_predicate_lowers_after_with() {
7147        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7148        let ast = parse("MATCH (n) WITH n WHERE (n)-[:REL]->() RETURN n").unwrap();
7149        let plan = binder.bind(&ast).expect("WITH pattern predicate binds");
7150
7151        let with_idx = plan
7152            .ops
7153            .iter()
7154            .position(|op| matches!(op, GraphOp::With { .. }))
7155            .expect("WITH should lower to a With op");
7156        let exists_idx = plan
7157            .ops
7158            .iter()
7159            .position(|op| matches!(op, GraphOp::Exists { .. }))
7160            .expect("WITH WHERE pattern predicate should lower to Exists");
7161        assert!(
7162            with_idx < exists_idx,
7163            "WITH projection must run before its pattern predicate"
7164        );
7165    }
7166
7167    #[test]
7168    fn create_single_node_populates_pattern() {
7169        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7170        let ast = parse("CREATE (:Person {name: 'Alice', age: 30})").unwrap();
7171        let plan = binder.bind(&ast).expect("create bind should succeed");
7172
7173        let create = plan
7174            .ops
7175            .iter()
7176            .find_map(|op| match op {
7177                GraphOp::Create { pattern } => Some(pattern),
7178                _ => None,
7179            })
7180            .expect("expected a Create op");
7181        assert_eq!(create.nodes.len(), 1);
7182        assert!(create.edges.is_empty());
7183        let node = &create.nodes[0];
7184        assert_eq!(node.labels.len(), 1, "Person label should resolve");
7185        let props = node.properties.expect("node should have a property map");
7186        // The property expr should be a MapLiteral in the arena.
7187        assert!(matches!(plan.exprs.get(props), IrExpr::MapLiteral(_)));
7188    }
7189
7190    #[test]
7191    fn create_edge_threads_src_and_dst_vars() {
7192        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7193        let ast = parse("CREATE (a:Person)-[:KNOWS]->(b:Person)").unwrap();
7194        let plan = binder.bind(&ast).expect("create bind should succeed");
7195
7196        let create = plan
7197            .ops
7198            .iter()
7199            .find_map(|op| match op {
7200                GraphOp::Create { pattern } => Some(pattern),
7201                _ => None,
7202            })
7203            .expect("expected a Create op");
7204        assert_eq!(create.nodes.len(), 2);
7205        assert_eq!(create.edges.len(), 1);
7206        let edge = &create.edges[0];
7207        // The edge's src/dst must match the two node vars, in order.
7208        assert_eq!(edge.src, create.nodes[0].var);
7209        assert_eq!(edge.dst, create.nodes[1].var);
7210        assert_eq!(edge.direction, Direction::Out);
7211        assert!(edge.rel_type.is_some());
7212    }
7213
7214    #[test]
7215    fn standalone_create_node_is_not_a_reference() {
7216        // No preceding clause → the CREATE introduces the var → mint, not ref.
7217        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7218        let ast = parse("CREATE (a:Person)").unwrap();
7219        let plan = binder.bind(&ast).expect("bind");
7220        let create = plan
7221            .ops
7222            .iter()
7223            .find_map(|op| match op {
7224                GraphOp::Create { pattern } => Some(pattern),
7225                _ => None,
7226            })
7227            .expect("Create op");
7228        assert_eq!(create.nodes.len(), 1);
7229        assert!(
7230            !create.nodes[0].is_reference,
7231            "a CREATE-introduced var must be a mint, not a reference"
7232        );
7233    }
7234
7235    // -----------------------------------------------------------------------
7236    // Variable-kind conflict validation (#956, VariableTypeConflict)
7237    // -----------------------------------------------------------------------
7238
7239    /// Bind and assert the query is REJECTED with a `VariableKindConflict`.
7240    fn expect_kind_conflict(query: &str) {
7241        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7242        let ast = parse(query).expect("parse");
7243        let errs = binder
7244            .bind(&ast)
7245            .expect_err(&format!("expected a kind conflict for: {query}"));
7246        assert!(
7247            errs.iter()
7248                .any(|e| e.kind == BindErrorKind::VariableKindConflict),
7249            "expected VariableKindConflict for {query}, got {errs:?}"
7250        );
7251    }
7252
7253    #[test]
7254    fn relationship_var_reused_as_node_conflicts() {
7255        // A relationship variable used as a node pattern is a VariableTypeConflict.
7256        for q in [
7257            "MATCH ()-[r]-() MATCH (r) RETURN r",
7258            "MATCH ()-[r]->() MATCH (r) RETURN r",
7259            "MATCH (), ()-[r]-() MATCH (r) RETURN r",
7260        ] {
7261            expect_kind_conflict(q);
7262        }
7263    }
7264
7265    #[test]
7266    fn path_var_reused_as_node_conflicts() {
7267        // A (single-segment) path variable reused as a node pattern conflicts.
7268        expect_kind_conflict("MATCH r = ()-[]-() MATCH (r) RETURN r");
7269    }
7270
7271    #[test]
7272    fn scalar_aliases_reused_as_pattern_entities_conflict() {
7273        expect_kind_conflict("WITH 42 AS n MATCH (n) RETURN n");
7274        expect_kind_conflict("WITH true AS r MATCH ()-[r]->() RETURN r");
7275        expect_kind_conflict(
7276            "MATCH (n) WITH collect(n) AS users MATCH (users)-[:R]->() RETURN users",
7277        );
7278    }
7279
7280    #[test]
7281    fn runtime_polymorphic_pattern_values_remain_bindable() {
7282        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7283        for query in [
7284            "WITH null AS a OPTIONAL MATCH p = (a)-[r]->() RETURN relationships(p)",
7285            "MATCH (a) WITH collect(a) AS nodes UNWIND nodes AS n MATCH (n) RETURN n",
7286        ] {
7287            let ast = parse(query).expect("parse");
7288            binder
7289                .bind(&ast)
7290                .unwrap_or_else(|errors| panic!("expected clean bind for {query}: {errors:?}"));
7291        }
7292    }
7293
7294    #[test]
7295    fn direct_graph_function_kind_mismatches_are_rejected() {
7296        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7297        for query in [
7298            "MATCH (n) RETURN type(n)",
7299            "MATCH ()-[r]->() RETURN labels(r)",
7300            "MATCH (n) RETURN length(n)",
7301            "MATCH ()-[r]->() RETURN length(r)",
7302            "MATCH p = (n) RETURN labels(p)",
7303        ] {
7304            let ast = parse(query).expect("parse");
7305            let errors = binder.bind(&ast).expect_err("expected invalid argument");
7306            assert!(
7307                errors
7308                    .iter()
7309                    .any(|error| error.kind == BindErrorKind::InvalidArgument),
7310                "expected InvalidArgument for {query}, got {errors:?}"
7311            );
7312        }
7313    }
7314
7315    #[test]
7316    fn compatible_variable_reuse_is_accepted() {
7317        // Guardrail: re-using a variable with the SAME kind, distinct variables,
7318        // and WITH-rescoping must all still bind cleanly (no false conflict).
7319        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7320        for q in [
7321            "MATCH (a) MATCH (a) RETURN a",              // node reused as node
7322            "MATCH (a), (b) RETURN a, b",                // distinct node vars
7323            "MATCH (a)-[r]->(b), (b)-[s]->(c) RETURN a", // b: dst then src, both nodes
7324            "MATCH (a) WITH a MATCH (b) RETURN a, b",    // WITH-rescoped
7325            "MATCH (a) CREATE (a)-[:R]->(b)",            // matched node referenced in CREATE
7326        ] {
7327            let ast = parse(q).expect("parse");
7328            binder
7329                .bind(&ast)
7330                .unwrap_or_else(|e| panic!("expected clean bind for {q}, got {e:?}"));
7331        }
7332    }
7333
7334    #[test]
7335    fn relationship_var_reused_with_different_type_adds_false_filter() {
7336        // A relationship variable forwarded through WITH keeps its known type.
7337        // Reusing it with a different known type is a valid match that returns no
7338        // rows; encode that independently of whether the edge scan schema carries
7339        // a `rel_type_name` column.
7340        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7341        let ast = parse("MATCH ()-[r:T]->() WITH r MATCH ()-[r:Y]->() RETURN r").expect("parse");
7342        let plan = binder.bind(&ast).expect("bind");
7343
7344        let has_false_filter = plan.ops.iter().any(|op| match op {
7345            GraphOp::Filter { predicate } => {
7346                matches!(
7347                    plan.exprs.get(*predicate),
7348                    IrExpr::Literal(IrLiteral::Bool(false))
7349                )
7350            }
7351            _ => false,
7352        });
7353
7354        assert!(
7355            has_false_filter,
7356            "reusing a known relationship variable with a different type should filter to no rows"
7357        );
7358    }
7359
7360    // -----------------------------------------------------------------------
7361    // Validator tail (#956): CREATE-pattern, rebind, aggregate-in-WHERE, alias
7362    // -----------------------------------------------------------------------
7363
7364    /// Bind and assert the query is REJECTED with any bind error of `kind`.
7365    fn expect_bind_error(query: &str, kind: BindErrorKind) {
7366        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7367        let ast = parse(query).expect("parse");
7368        let errs = binder
7369            .bind(&ast)
7370            .expect_err(&format!("expected a bind error for: {query}"));
7371        assert!(
7372            errs.iter().any(|e| e.kind == kind),
7373            "expected {kind:?} for {query}, got {errs:?}"
7374        );
7375    }
7376
7377    #[test]
7378    fn create_pattern_validation() {
7379        // A created relationship must have exactly one type, be fixed-length,
7380        // and be directed (#956).
7381        for q in [
7382            "CREATE ()-->()",         // no type
7383            "CREATE ()-[:FOO*2]->()", // variable-length
7384            "CREATE (a)-[:FOO]-(b)",  // undirected
7385        ] {
7386            expect_bind_error(q, BindErrorKind::InvalidArgument);
7387        }
7388    }
7389
7390    #[test]
7391    fn rebinding_a_bound_variable_in_create_is_rejected() {
7392        for q in [
7393            "MATCH (a) CREATE (a)",                          // bare re-create
7394            "MATCH (a) CREATE (a {name: 'x'})",              // re-declared with props
7395            "CREATE (n:Foo) CREATE (n:Bar)-[:OWNS]->(:Dog)", // re-declared with a label
7396            "MATCH ()-[r]->() CREATE ()-[r]->()",            // reused relationship var
7397        ] {
7398            expect_bind_error(q, BindErrorKind::VariableAlreadyBound);
7399        }
7400    }
7401
7402    #[test]
7403    fn create_reference_without_new_shape_is_accepted() {
7404        // Guardrail: a bound node referenced as an edge endpoint (no new labels
7405        // or properties) is a valid reference, not a rebind.
7406        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7407        let ast = parse("MATCH (a) CREATE (a)-[:R]->(b)").expect("parse");
7408        binder.bind(&ast).expect("valid reference must bind");
7409    }
7410
7411    #[test]
7412    fn aggregate_in_where_and_duplicate_alias_are_rejected() {
7413        expect_bind_error(
7414            "MATCH (a) WHERE count(a) > 10 RETURN a",
7415            BindErrorKind::InvalidArgument,
7416        );
7417        expect_bind_error("RETURN 1 AS a, 2 AS a", BindErrorKind::InvalidArgument);
7418        expect_bind_error(
7419            "WITH 1 AS a, 2 AS a RETURN a",
7420            BindErrorKind::InvalidArgument,
7421        );
7422    }
7423
7424    #[test]
7425    fn skip_limit_reject_negative_and_non_parameter_arguments() {
7426        for q in [
7427            "RETURN 1 SKIP -1",
7428            "RETURN 1 LIMIT -1",
7429            "RETURN 1 SKIP (-1)",
7430            "WITH 1 AS x SKIP -1 RETURN x",
7431            "WITH 1 AS x, count(*) AS c SKIP -1 RETURN x, c",
7432            "MATCH (n) RETURN n SKIP n.count",
7433            "MATCH (n) RETURN n LIMIT rand()",
7434            "MATCH (n) WITH n SKIP n.count RETURN n",
7435            "MATCH (n) WITH n, count(*) AS c LIMIT rand() RETURN n, c",
7436        ] {
7437            expect_bind_error(q, BindErrorKind::InvalidArgument);
7438        }
7439    }
7440
7441    #[test]
7442    fn skip_limit_accept_non_negative_integer_constants_and_parameters() {
7443        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7444        for q in [
7445            "RETURN 1 SKIP 0 LIMIT 1",
7446            "WITH 1 AS x SKIP (1) LIMIT 2 RETURN x",
7447            "WITH 1 AS x, count(*) AS c SKIP 0 LIMIT 1 RETURN x, c",
7448            "RETURN 1 SKIP $s LIMIT $l",
7449            "WITH 1 AS x SKIP ($s) LIMIT ($l) RETURN x",
7450        ] {
7451            let ast = parse(q).expect("parse");
7452            binder
7453                .bind(&ast)
7454                .unwrap_or_else(|e| panic!("expected clean bind for {q}, got {e:?}"));
7455        }
7456    }
7457
7458    #[test]
7459    fn overflowing_float_literal_is_rejected_at_bind() {
7460        expect_bind_error("RETURN 1.34E999", BindErrorKind::InvalidArgument);
7461    }
7462
7463    #[test]
7464    fn matched_var_in_create_is_a_reference_not_a_duplicate_mint() {
7465        // #703: `MATCH (a) CREATE (a)-[:KNOWS]->(b)` — `a` was bound by the
7466        // MATCH, so its CREATE node spec must be a REFERENCE (resolve the matched
7467        // node), and there must be exactly ONE spec for `a` (not a second mint).
7468        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7469        let ast = parse("MATCH (a:Person) CREATE (a)-[:KNOWS]->(b:Person)").expect("parse");
7470        let plan = binder.bind(&ast).expect("bind");
7471        let create = plan
7472            .ops
7473            .iter()
7474            .find_map(|op| match op {
7475                GraphOp::Create { pattern } => Some(pattern),
7476                _ => None,
7477            })
7478            .expect("Create op");
7479
7480        // The matched `a` is the edge src; the new `b` is the dst.
7481        let a_var = create.edges[0].src;
7482        let b_var = create.edges[0].dst;
7483        let a_specs: Vec<_> = create.nodes.iter().filter(|n| n.var == a_var).collect();
7484        assert_eq!(a_specs.len(), 1, "exactly one spec for the matched var `a`");
7485        assert!(a_specs[0].is_reference, "matched `a` must be a reference");
7486        let b_spec = create
7487            .nodes
7488            .iter()
7489            .find(|n| n.var == b_var)
7490            .expect("spec for new `b`");
7491        assert!(!b_spec.is_reference, "CREATE-introduced `b` must be a mint");
7492    }
7493
7494    #[test]
7495    fn delete_clause_lowers_to_delete_op() {
7496        // #740: DELETE now lowers to GraphOp::Delete (no longer rejected).
7497        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7498        let ast = parse("MATCH (p:Person) DELETE p").unwrap();
7499        let plan = binder.bind(&ast).expect("DELETE binds");
7500        let delete = plan
7501            .ops
7502            .iter()
7503            .find_map(|op| match op {
7504                GraphOp::Delete { vars, detach, .. } => Some((vars.clone(), *detach)),
7505                _ => None,
7506            })
7507            .expect("a GraphOp::Delete op");
7508        assert_eq!(delete.0.len(), 1, "one target var");
7509        assert!(!delete.1, "plain DELETE is not DETACH");
7510    }
7511
7512    #[test]
7513    fn detach_delete_sets_detach_flag() {
7514        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7515        let ast = parse("MATCH (p:Person) DETACH DELETE p").unwrap();
7516        let plan = binder.bind(&ast).expect("DETACH DELETE binds");
7517        assert!(
7518            plan.ops
7519                .iter()
7520                .any(|op| matches!(op, GraphOp::Delete { detach: true, .. })),
7521            "DETACH DELETE must set detach=true, got {:?}",
7522            plan.ops
7523        );
7524    }
7525
7526    #[test]
7527    fn delete_property_target_lowers_to_runtime_expression() {
7528        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7529        let ast = parse("MATCH (p:Person) DELETE p.name").unwrap();
7530        let plan = binder
7531            .bind(&ast)
7532            .expect("property value binds for runtime typing");
7533        assert!(matches!(
7534            plan.ops.last(),
7535            Some(GraphOp::Delete { vars, exprs, .. }) if vars.is_empty() && exprs.len() == 1
7536        ));
7537    }
7538
7539    #[test]
7540    fn delete_scalar_expression_is_rejected() {
7541        expect_bind_error("MATCH () DELETE 1 + 1", BindErrorKind::InvalidDeleteTarget);
7542    }
7543
7544    #[test]
7545    fn delete_undeclared_variable_is_rejected() {
7546        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7547        let ast = parse("MATCH (p:Person) DELETE q").unwrap();
7548        let errors = binder
7549            .bind(&ast)
7550            .expect_err("DELETE of an unbound var must be rejected");
7551        assert!(
7552            errors
7553                .iter()
7554                .any(|e| e.kind == BindErrorKind::UndeclaredVariable),
7555            "expected UndeclaredVariable, got {errors:?}"
7556        );
7557    }
7558
7559    #[test]
7560    fn set_property_clause_lowers_to_set_op() {
7561        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7562        let ast = parse("MATCH (p:Person) SET p.age = 30").unwrap();
7563        let plan = binder.bind(&ast).expect("SET p.age = 30 must lower");
7564        let set = plan
7565            .ops
7566            .iter()
7567            .find_map(|op| match op {
7568                GraphOp::Set { items, .. } => Some(items),
7569                _ => None,
7570            })
7571            .expect("expected a GraphOp::Set");
7572        assert_eq!(set.len(), 1);
7573        assert_eq!(set[0].prop_name, "age");
7574    }
7575
7576    #[test]
7577    fn set_runtime_expr_value_lowers_to_non_literal() {
7578        // The value `p.age + 1` is a runtime expression, not a literal — it must
7579        // survive lowering as a compound `IrExpr`, not be collapsed to a literal.
7580        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7581        let ast = parse("MATCH (p:Person) SET p.age = p.age + 1").unwrap();
7582        let plan = binder.bind(&ast).expect("SET p.age = p.age + 1 must lower");
7583        let items = plan
7584            .ops
7585            .iter()
7586            .find_map(|op| match op {
7587                GraphOp::Set { items, .. } => Some(items),
7588                _ => None,
7589            })
7590            .expect("expected a GraphOp::Set");
7591        let value = plan.exprs.get(items[0].value);
7592        assert!(
7593            matches!(value, IrExpr::BinaryOp { .. }),
7594            "expected a BinaryOp value expr, got {value:?}"
7595        );
7596    }
7597
7598    #[test]
7599    fn set_multiple_items_lower_to_one_op() {
7600        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7601        let ast = parse("MATCH (p:Person) SET p.age = 30, p.name = 'Al'").unwrap();
7602        let plan = binder.bind(&ast).expect("multi-item SET must lower");
7603        let count = plan
7604            .ops
7605            .iter()
7606            .filter(|op| matches!(op, GraphOp::Set { .. }))
7607            .count();
7608        assert_eq!(count, 1, "expected exactly one GraphOp::Set");
7609    }
7610
7611    #[test]
7612    fn set_labels_lower_to_resolved_label_item() {
7613        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7614        let ast = parse("MATCH (p:Person) SET p:Admin:Staff").unwrap();
7615        let plan = binder.bind(&ast).expect("SET labels must lower");
7616        let labels = plan.ops.iter().find_map(|op| match op {
7617            GraphOp::Set { label_items, .. } => Some(label_items),
7618            _ => None,
7619        });
7620        let labels = labels.expect("SET label items");
7621        assert_eq!(labels.len(), 1);
7622        assert_eq!(labels[0].labels.len(), 2);
7623    }
7624
7625    #[test]
7626    fn set_property_merge_lowers_to_map_item() {
7627        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7628        let ast = parse("MATCH (p:Person) SET p += {age: 30}").unwrap();
7629        let plan = binder.bind(&ast).expect("SET += must lower");
7630        let map_items = plan
7631            .ops
7632            .iter()
7633            .find_map(|op| match op {
7634                GraphOp::Set { map_items, .. } => Some(map_items),
7635                _ => None,
7636            })
7637            .expect("expected SET op");
7638        assert_eq!(map_items.len(), 1);
7639        assert!(!map_items[0].replace);
7640    }
7641
7642    #[test]
7643    fn merge_lowers_real_pattern_specs() {
7644        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7645        let ast = parse(
7646            "MERGE (p:Person {name:'Alice'}) \
7647             ON CREATE SET p.created = 1, p:New \
7648             ON MATCH SET p += {seen:true}",
7649        )
7650        .unwrap();
7651        let plan = binder.bind(&ast).expect("MERGE binds");
7652        let (pattern, on_create, on_match) = plan
7653            .ops
7654            .iter()
7655            .find_map(|op| match op {
7656                GraphOp::Merge {
7657                    pattern,
7658                    on_create,
7659                    on_match,
7660                } => Some((pattern, on_create, on_match)),
7661                _ => None,
7662            })
7663            .expect("MERGE op");
7664        assert_eq!(pattern.nodes.len(), 1);
7665        assert!(pattern.nodes[0].properties.is_some());
7666        assert_eq!(on_create.len(), 2);
7667        assert_eq!(on_match.len(), 1);
7668    }
7669
7670    #[test]
7671    fn set_undeclared_variable_rejected() {
7672        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7673        let ast = parse("MATCH (p:Person) SET q.age = 30").unwrap();
7674        let errors = binder.bind(&ast).expect_err("SET on unbound var must fail");
7675        assert!(
7676            errors
7677                .iter()
7678                .any(|e| e.kind == BindErrorKind::UndeclaredVariable),
7679            "expected UndeclaredVariable, got {errors:?}"
7680        );
7681    }
7682
7683    #[test]
7684    fn remove_property_clause_lowers_to_remove_op() {
7685        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7686        let ast = parse("MATCH (p:Person) REMOVE p.age").unwrap();
7687        let plan = binder.bind(&ast).expect("REMOVE p.age must lower");
7688        let items = plan
7689            .ops
7690            .iter()
7691            .find_map(|op| match op {
7692                GraphOp::Remove { items, .. } => Some(items),
7693                _ => None,
7694            })
7695            .expect("expected a GraphOp::Remove");
7696        assert_eq!(items.len(), 1);
7697        assert_eq!(items[0].prop_name, "age");
7698    }
7699
7700    #[test]
7701    fn remove_labels_lower_to_resolved_label_item() {
7702        let (binder, _catalog) = make_binder(OntologyMode::Exploratory);
7703        let ast = parse("MATCH (p:Person) REMOVE p:Admin:Staff").unwrap();
7704        let plan = binder.bind(&ast).expect("REMOVE labels must lower");
7705        let labels = plan.ops.iter().find_map(|op| match op {
7706            GraphOp::Remove { label_items, .. } => Some(label_items),
7707            _ => None,
7708        });
7709        let labels = labels.expect("REMOVE label items");
7710        assert_eq!(labels.len(), 1);
7711        assert_eq!(labels[0].labels.len(), 2);
7712    }
7713
7714    #[test]
7715    fn advisory_unknown_label_produces_warnings() {
7716        let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
7717        let binder = Binder::new(None, Arc::clone(&catalog), OntologyMode::Advisory);
7718        let ast = parse("MATCH (a:UnknownLabel) RETURN a").unwrap();
7719
7720        let mut state = BinderState {
7721            vars: HashMap::new(),
7722            path_vars: HashMap::new(),
7723            node_vars: HashMap::new(),
7724            edge_vars: HashMap::new(),
7725            edge_rel_names: HashMap::new(),
7726            scalar_list_edges: HashSet::new(),
7727            var_kinds: HashMap::new(),
7728            next_var: 0,
7729            builder: GraphPlan::builder("openCypher").ontology_mode(OntologyMode::Advisory),
7730            errors: Vec::new(),
7731            warnings: Vec::new(),
7732            captured_pattern_comprehensions: None,
7733            existential_depth: 0,
7734            standalone_call: false,
7735        };
7736        for clause in &ast.clauses {
7737            binder.lower_clause(clause, &mut state);
7738        }
7739
7740        assert!(
7741            !state.warnings.is_empty(),
7742            "advisory mode should produce warnings"
7743        );
7744        assert!(
7745            state.errors.is_empty(),
7746            "advisory mode should not produce errors"
7747        );
7748        assert!(
7749            state
7750                .warnings
7751                .iter()
7752                .any(|w| w.kind == BindErrorKind::UnknownLabel)
7753        );
7754    }
7755
7756    #[test]
7757    fn strict_unknown_label_produces_error() {
7758        let (binder, _) = make_binder(OntologyMode::Strict);
7759        let ast = parse("MATCH (a:UnknownLabel) RETURN a").unwrap();
7760        let result = binder.bind(&ast);
7761        assert!(result.is_err());
7762        let errors = result.unwrap_err();
7763        assert!(errors.iter().any(|e| e.kind == BindErrorKind::UnknownLabel));
7764    }
7765
7766    #[test]
7767    fn strict_mode_allows_durable_identity_fields() {
7768        let (binder, _) = make_binder(OntologyMode::Strict);
7769        let ast = parse(
7770            "MATCH (source)-[relationship]->(target) RETURN source.node_uuid, relationship.edge_uuid, target.node_uuid",
7771        )
7772        .unwrap();
7773
7774        binder
7775            .bind(&ast)
7776            .expect("structural identity fields are not ontology properties");
7777    }
7778
7779    #[test]
7780    fn strict_properties_emit_owner_scoped_runtime_ids() {
7781        let (binder, catalog) = strict_property_binder(false);
7782        let ast = parse("MATCH (host:Host)-[connection:R]->() RETURN host.direct, host.inherited, host.shared, connection.weight, connection.shared").unwrap();
7783        let plan = binder.bind(&ast).unwrap();
7784        let ids = plan_property_ids(&plan);
7785        let catalog = catalog.lock().unwrap();
7786        let names = ids
7787            .iter()
7788            .map(|id| catalog.property_name(crate::RuntimePropId(id.0)).unwrap())
7789            .collect::<Vec<_>>();
7790        assert_eq!(names, ["direct", "inherited", "shared", "weight", "shared"]);
7791        assert!(ids.iter().all(|id| id.0 > 0));
7792        assert_ne!(ids[2], ids[4]);
7793    }
7794
7795    #[test]
7796    fn strict_property_writes_and_inline_filters_share_owner_rules() {
7797        let (binder, catalog) = strict_property_binder(false);
7798        let ast = parse("MATCH (host:Host)-[connection:R]->() SET host.direct = 'ready', host.inherited = 'asset', connection.weight = 7 REMOVE host.direct, connection.weight").unwrap();
7799        let plan = binder.bind(&ast).unwrap();
7800        let catalog = catalog.lock().unwrap();
7801        let write_ids = plan
7802            .ops
7803            .iter()
7804            .flat_map(|op| match op {
7805                GraphOp::Set { items, .. } => items.iter().map(|item| item.prop).collect(),
7806                GraphOp::Remove { items, .. } => items.iter().map(|item| item.prop).collect(),
7807                _ => Vec::new(),
7808            })
7809            .collect::<Vec<_>>();
7810        assert_eq!(write_ids.len(), 5);
7811        assert!(
7812            write_ids
7813                .iter()
7814                .all(|id| catalog.property_name(crate::RuntimePropId(id.0)).is_some())
7815        );
7816        drop(catalog);
7817
7818        for query in [
7819            "MATCH (:Host {direct: 'ready'})-[:R {weight: 7}]->() RETURN 1",
7820            "MATCH (:Host {inherited: 'asset'})-[:R*1..2 {weight: 7}]->() RETURN 1",
7821        ] {
7822            binder
7823                .bind(&parse(query).unwrap())
7824                .expect("anonymous fixed and variable-length owners should bind");
7825        }
7826    }
7827
7828    #[test]
7829    fn strict_properties_reject_wrong_owner_and_ambiguity_with_exact_spans() {
7830        let (binder, _) = strict_property_binder(false);
7831        for (query, span) in [
7832            ("MATCH (host:Host) RETURN host.weight", "host.weight"),
7833            ("MATCH ()-[r:R]->() RETURN r.direct", "r.direct"),
7834            ("MATCH (host:Host) RETURN host.missing", "host.missing"),
7835            (
7836                "MATCH (host:Host) WITH host AS forwarded RETURN forwarded.weight",
7837                "forwarded.weight",
7838            ),
7839            (
7840                "MATCH ()-[r:R]->() WITH r AS forwarded RETURN forwarded.direct",
7841                "forwarded.direct",
7842            ),
7843        ] {
7844            property_error(&binder, query, BindErrorKind::UnknownProperty, span);
7845        }
7846        for (query, span) in [
7847            ("MATCH (:Host {weight: 7}) RETURN 1", "weight"),
7848            ("MATCH ()-[:R {missing: 7}]->() RETURN 1", "missing"),
7849        ] {
7850            property_error(&binder, query, BindErrorKind::UnknownProperty, span);
7851        }
7852        property_error(
7853            &binder,
7854            "MATCH ()-[*1..2 {weight: 7}]->() RETURN 1",
7855            BindErrorKind::AmbiguousProperty,
7856            "weight",
7857        );
7858
7859        let (binder, _) = strict_property_binder(true);
7860        let query = "MATCH (host:Host) RETURN host.inherited";
7861        let error = property_error(
7862            &binder,
7863            query,
7864            BindErrorKind::AmbiguousProperty,
7865            "host.inherited",
7866        );
7867        assert!(error.message.contains("Asset, Host"));
7868        property_error(
7869            &binder,
7870            "MATCH (:Host {inherited: 7}) RETURN 1",
7871            BindErrorKind::AmbiguousProperty,
7872            "inherited",
7873        );
7874    }
7875
7876    #[test]
7877    fn strict_mode_rejects_mismatched_durable_identity_fields() {
7878        for query in [
7879            "MATCH (node) RETURN node.edge_uuid",
7880            "MATCH ()-[relationship]->() RETURN relationship.node_uuid",
7881        ] {
7882            let (binder, _) = make_binder(OntologyMode::Strict);
7883            let errors = binder
7884                .bind(&parse(query).unwrap())
7885                .expect_err("identity fields must match the bound entity kind");
7886            assert!(errors.iter().any(|error| {
7887                error.kind == BindErrorKind::InvalidArgument
7888                    && error.message.contains("valid only on")
7889            }));
7890        }
7891    }
7892
7893    #[test]
7894    fn structural_identity_fields_are_read_only() {
7895        for query in [
7896            "MATCH (node) SET node.node_uuid = 'replacement'",
7897            "MATCH (node) REMOVE node.node_uuid",
7898            "MATCH ()-[relationship]->() SET relationship.edge_uuid = 'replacement'",
7899            "MATCH ()-[relationship]->() REMOVE relationship.edge_uuid",
7900        ] {
7901            let (binder, _) = make_binder(OntologyMode::Strict);
7902            let errors = binder
7903                .bind(&parse(query).unwrap())
7904                .expect_err("identity fields must not be mutable");
7905            assert!(errors.iter().any(|error| {
7906                error.kind == BindErrorKind::InvalidArgument
7907                    && error.message.contains("is read-only")
7908            }));
7909        }
7910    }
7911
7912    #[test]
7913    fn fixed_hop_with_known_relation_emits_expand() {
7914        use graphforge_ontology::{
7915            EntityTypeDef, OntologyCompiler, OntologyDoc, OntologyHandle, RelationTypeDef,
7916            SemanticFlags,
7917        };
7918
7919        let doc = OntologyDoc {
7920            ontology_id: "test".into(),
7921            version: "1.0".into(),
7922            entity_types: vec![
7923                EntityTypeDef {
7924                    name: "Person".into(),
7925                    r#abstract: false,
7926                    parent: None,
7927                },
7928                EntityTypeDef {
7929                    name: "Organization".into(),
7930                    r#abstract: false,
7931                    parent: None,
7932                },
7933            ],
7934            relation_types: vec![RelationTypeDef {
7935                name: "WORKS_AT".into(),
7936                src: "Person".into(),
7937                dst: "Organization".into(),
7938                inverse: None,
7939                semantic: SemanticFlags::default(),
7940            }],
7941            properties: vec![],
7942            constraints: vec![],
7943            migrations: vec![],
7944        };
7945        let runtime = OntologyCompiler::compile(&doc).unwrap();
7946        let handle = OntologyHandle::new(runtime);
7947        let catalog = Arc::new(Mutex::new(RuntimeCatalog::new()));
7948        let binder = Binder::new(Some(handle), catalog, OntologyMode::Strict);
7949
7950        let ast = parse("MATCH (a:Person)-[:WORKS_AT]->(b:Organization) RETURN a").unwrap();
7951        let plan = binder.bind(&ast).expect("known ontology should succeed");
7952
7953        // A fixed single hop lowers to a single `Expand` carrying both node vars
7954        // and the resolved relation type — never a bare edge scan (#718).
7955        let expand = plan.ops.iter().find_map(|op| match op {
7956            GraphOp::Expand {
7957                rel_ty,
7958                min_hops,
7959                max_hops,
7960                ..
7961            } => Some((rel_ty, *min_hops, *max_hops)),
7962            _ => None,
7963        });
7964        let (rel_ty, min_hops, max_hops) = expand.expect("fixed hop should emit Expand");
7965        assert!(
7966            rel_ty.is_some(),
7967            "WORKS_AT should resolve to a relation type"
7968        );
7969        assert_eq!((min_hops, max_hops), (1, Some(1)), "fixed hop is 1..1");
7970        assert!(
7971            !plan
7972                .ops
7973                .iter()
7974                .any(|op| matches!(op, GraphOp::TypedEdgeScan { .. } | GraphOp::EdgeScan { .. }))
7975        );
7976    }
7977
7978    #[test]
7979    fn wildcard_fixed_hop_emits_untyped_expand() {
7980        let (binder, _) = make_binder(OntologyMode::Exploratory);
7981        let ast = parse("MATCH (a)-[r]->(b) RETURN r").unwrap();
7982        let plan = binder.bind(&ast).expect("wildcard bind should succeed");
7983
7984        // An untyped fixed hop is still an `Expand` (rel_ty None), not an
7985        // EdgeScan, so the source/destination nodes stay connected (#718).
7986        let expand = plan.ops.iter().find_map(|op| match op {
7987            GraphOp::Expand {
7988                rel_ty,
7989                min_hops,
7990                max_hops,
7991                ..
7992            } => Some((rel_ty, *min_hops, *max_hops)),
7993            _ => None,
7994        });
7995        let (rel_ty, min_hops, max_hops) = expand.expect("wildcard hop should emit Expand");
7996        assert!(rel_ty.is_none(), "wildcard hop has no relation type");
7997        assert_eq!((min_hops, max_hops), (1, Some(1)));
7998        assert!(
7999            !plan
8000                .ops
8001                .iter()
8002                .any(|op| matches!(op, GraphOp::TypedEdgeScan { .. } | GraphOp::EdgeScan { .. }))
8003        );
8004    }
8005
8006    #[test]
8007    fn undeclared_variable_in_return_is_error() {
8008        let (binder, _) = make_binder(OntologyMode::Exploratory);
8009        let ast = parse("MATCH (a:Person) RETURN x").unwrap();
8010        let result = binder.bind(&ast);
8011        assert!(result.is_err());
8012        let errors = result.unwrap_err();
8013        assert!(
8014            errors
8015                .iter()
8016                .any(|e| e.kind == BindErrorKind::UndeclaredVariable)
8017        );
8018    }
8019
8020    #[test]
8021    fn multi_error_collection_in_strict_mode() {
8022        let (binder, _) = make_binder(OntologyMode::Strict);
8023        let ast = parse("MATCH (a:LabelA)-[:REL_B]->(b:LabelC) RETURN a").unwrap();
8024        let result = binder.bind(&ast);
8025        assert!(result.is_err());
8026        let errors = result.unwrap_err();
8027        // LabelA, REL_B, LabelC → at least 3 errors
8028        assert!(
8029            errors.len() >= 3,
8030            "expected ≥3 errors, got {}",
8031            errors.len()
8032        );
8033    }
8034
8035    #[test]
8036    fn where_clause_emits_filter_op() {
8037        let (binder, _) = make_binder(OntologyMode::Exploratory);
8038        let ast = parse("MATCH (a:Person) WHERE a.age > 30 RETURN a").unwrap();
8039        let plan = binder.bind(&ast).unwrap();
8040        assert!(
8041            plan.ops
8042                .iter()
8043                .any(|op| matches!(op, GraphOp::Filter { .. }))
8044        );
8045    }
8046
8047    #[test]
8048    fn inline_node_property_emits_filter() {
8049        // #748: an inline property map becomes a Filter over the scanned node.
8050        let (binder, _) = make_binder(OntologyMode::Exploratory);
8051        let ast = parse("MATCH (a:Person {name:'Alice'}) RETURN a.name").unwrap();
8052        let plan = binder.bind(&ast).unwrap();
8053        assert!(
8054            plan.ops
8055                .iter()
8056                .any(|op| matches!(op, GraphOp::NodeScan { .. })),
8057            "scan present"
8058        );
8059        assert!(
8060            plan.ops
8061                .iter()
8062                .any(|op| matches!(op, GraphOp::Filter { .. })),
8063            "inline property must emit a Filter"
8064        );
8065    }
8066
8067    #[test]
8068    fn inline_multi_property_emits_single_filter() {
8069        // Multiple inline properties AND-combine into one Filter op.
8070        let (binder, _) = make_binder(OntologyMode::Exploratory);
8071        let ast = parse("MATCH (a:Person {name:'Alice', age:30}) RETURN a.name").unwrap();
8072        let plan = binder.bind(&ast).unwrap();
8073        let filters = plan
8074            .ops
8075            .iter()
8076            .filter(|op| matches!(op, GraphOp::Filter { .. }))
8077            .count();
8078        assert_eq!(filters, 1, "multi-property map → one AND-ed Filter");
8079    }
8080
8081    #[test]
8082    fn node_without_inline_properties_emits_no_filter() {
8083        // Regression: a property-free node pattern must not add a spurious Filter.
8084        let (binder, _) = make_binder(OntologyMode::Exploratory);
8085        let ast = parse("MATCH (a:Person) RETURN a.name").unwrap();
8086        let plan = binder.bind(&ast).unwrap();
8087        assert!(
8088            !plan
8089                .ops
8090                .iter()
8091                .any(|op| matches!(op, GraphOp::Filter { .. })),
8092            "no inline properties → no Filter"
8093        );
8094    }
8095
8096    #[test]
8097    fn inline_rel_property_emits_filter() {
8098        // #750: an inline relationship-property map becomes a Filter over the
8099        // just-expanded edge, mirroring the node case (#748).
8100        let (binder, _) = make_binder(OntologyMode::Exploratory);
8101        let ast =
8102            parse("MATCH (a:Person)-[r:KNOWS {since:2020}]->(b:Person) RETURN b.name").unwrap();
8103        let plan = binder.bind(&ast).unwrap();
8104        assert!(
8105            plan.ops
8106                .iter()
8107                .any(|op| matches!(op, GraphOp::Expand { .. })),
8108            "expand present"
8109        );
8110        assert!(
8111            plan.ops
8112                .iter()
8113                .any(|op| matches!(op, GraphOp::Filter { .. })),
8114            "inline relationship property must emit a Filter"
8115        );
8116    }
8117
8118    #[test]
8119    fn inline_multi_rel_property_emits_single_filter() {
8120        // Multiple inline rel properties AND-combine into one Filter op.
8121        let (binder, _) = make_binder(OntologyMode::Exploratory);
8122        let ast =
8123            parse("MATCH (a:Person)-[r:KNOWS {since:2020, weight:5}]->(b:Person) RETURN b.name")
8124                .unwrap();
8125        let plan = binder.bind(&ast).unwrap();
8126        let filters = plan
8127            .ops
8128            .iter()
8129            .filter(|op| matches!(op, GraphOp::Filter { .. }))
8130            .count();
8131        assert_eq!(filters, 1, "multi-property rel map → one AND-ed Filter");
8132    }
8133
8134    #[test]
8135    fn rel_without_inline_properties_emits_no_filter() {
8136        // Regression: a property-free relationship must not add a spurious Filter.
8137        let (binder, _) = make_binder(OntologyMode::Exploratory);
8138        let ast = parse("MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN b.name").unwrap();
8139        let plan = binder.bind(&ast).unwrap();
8140        assert!(
8141            !plan
8142                .ops
8143                .iter()
8144                .any(|op| matches!(op, GraphOp::Filter { .. })),
8145            "no inline rel properties → no Filter"
8146        );
8147    }
8148
8149    #[test]
8150    fn inline_property_on_var_length_rel_emits_all_filter() {
8151        let (binder, _) = make_binder(OntologyMode::Exploratory);
8152        let ast = parse("MATCH (a:Person)-[r:KNOWS*1..2 {since:2020}]->(b:Person) RETURN b.name")
8153            .unwrap();
8154        let plan = binder.bind(&ast).unwrap();
8155        let predicate = plan.ops.iter().find_map(|op| match op {
8156            GraphOp::Filter { predicate } => Some(*predicate),
8157            _ => None,
8158        });
8159        assert!(
8160            predicate.is_some_and(|predicate| matches!(
8161                plan.exprs.get(predicate),
8162                IrExpr::Quantifier {
8163                    kind: graphforge_ast::QuantifierKind::All,
8164                    ..
8165                }
8166            )),
8167            "variable-length relationship properties require an all() filter"
8168        );
8169    }
8170
8171    #[test]
8172    fn return_clause_emits_project_op() {
8173        let (binder, _) = make_binder(OntologyMode::Exploratory);
8174        let ast = parse("MATCH (a:Person) RETURN a.name").unwrap();
8175        let plan = binder.bind(&ast).unwrap();
8176        assert!(
8177            plan.ops
8178                .iter()
8179                .any(|op| matches!(op, GraphOp::Project { .. }))
8180        );
8181    }
8182
8183    #[test]
8184    fn return_with_count_emits_aggregate() {
8185        // `RETURN count(n)` lowers to an Aggregate (one Count agg, no group keys),
8186        // not a Project (#729).
8187        let (binder, _) = make_binder(OntologyMode::Exploratory);
8188        let ast = parse("MATCH (n:Person) RETURN count(n) AS total").unwrap();
8189        let plan = binder.bind(&ast).unwrap();
8190        let agg = plan.ops.iter().find_map(|op| match op {
8191            GraphOp::Aggregate { group_by, aggs, .. } => Some((group_by, aggs)),
8192            _ => None,
8193        });
8194        let (group_by, aggs) = agg.expect("count should emit an Aggregate");
8195        assert!(
8196            group_by.is_empty(),
8197            "no non-aggregate items → no group keys"
8198        );
8199        assert_eq!(aggs.len(), 1);
8200        assert_eq!(aggs[0].func, AggFunc::Count);
8201        assert_eq!(aggs[0].alias, "total");
8202        assert!(
8203            !plan
8204                .ops
8205                .iter()
8206                .any(|op| matches!(op, GraphOp::Project { .. })),
8207            "an aggregate RETURN must not also emit a Project"
8208        );
8209    }
8210
8211    #[test]
8212    fn return_with_grouping_keys_and_aggregate() {
8213        // `RETURN n.name, count(n)` groups by the non-aggregate item.
8214        let (binder, _) = make_binder(OntologyMode::Exploratory);
8215        let ast = parse("MATCH (n:Person) RETURN n.name AS name, count(n) AS total").unwrap();
8216        let plan = binder.bind(&ast).unwrap();
8217        let (group_by, aggs) = plan
8218            .ops
8219            .iter()
8220            .find_map(|op| match op {
8221                GraphOp::Aggregate { group_by, aggs, .. } => Some((group_by, aggs)),
8222                _ => None,
8223            })
8224            .expect("Aggregate");
8225        assert_eq!(group_by.len(), 1, "n.name is the group key");
8226        assert_eq!(aggs.len(), 1);
8227    }
8228
8229    // -----------------------------------------------------------------------
8230    // Named path variables (#754)
8231    // -----------------------------------------------------------------------
8232
8233    /// Bind `query` and return the errors (panics if the bind succeeds).
8234    fn bind_errors(query: &str) -> Vec<BindError> {
8235        let (binder, _) = make_binder(OntologyMode::Exploratory);
8236        let ast = parse(query).unwrap();
8237        binder
8238            .bind(&ast)
8239            .expect_err("bind should fail for this query")
8240    }
8241
8242    #[test]
8243    fn path_var_functions_bind_with_anonymous_edge() {
8244        // The binder allocates an anon VarId for `[*1..2]`, so the rewrites
8245        // have an edge var to target even without `[r:...]`.
8246        let (binder, _) = make_binder(OntologyMode::Exploratory);
8247        let ast = parse(
8248            "MATCH p = (a:Person)-[*1..2]->(b) \
8249             RETURN nodes(p) AS ns, relationships(p) AS rs, length(p) AS l",
8250        )
8251        .unwrap();
8252        binder.bind(&ast).expect("path functions should bind");
8253    }
8254
8255    #[test]
8256    fn path_function_on_non_path_falls_through() {
8257        // `length(r)` on a var-length edge var keeps its generic lowering and
8258        // `nodes(a)` on a node var stays a generic function call — neither is
8259        // intercepted by the path rewrite.
8260        let (binder, _) = make_binder(OntologyMode::Exploratory);
8261        let ast =
8262            parse("MATCH (a)-[r:KNOWS*1..2]->(b) RETURN length(r) AS l, nodes(a) AS ns").unwrap();
8263        let plan = binder.bind(&ast).expect("bind succeeds");
8264        let names: Vec<&str> = (0..plan.exprs.len())
8265            .filter_map(
8266                |i| match plan.exprs.get(ExprId(u32::try_from(i).unwrap())) {
8267                    IrExpr::FunctionCall { name, .. } => Some(name.as_str()),
8268                    _ => None,
8269                },
8270            )
8271            .collect();
8272        assert!(names.contains(&"length"), "generic length kept: {names:?}");
8273        assert!(names.contains(&"nodes"), "generic nodes kept: {names:?}");
8274        assert!(
8275            !names.contains(&"_path_nodes"),
8276            "no path rewrite without a path var: {names:?}"
8277        );
8278    }
8279
8280    #[test]
8281    fn bare_path_var_binds_to_path_struct() {
8282        // `RETURN p` rewrites to `_path_struct(<nodes>, <relationships>)`.
8283        let (binder, _) = make_binder(OntologyMode::Exploratory);
8284        let ast = parse("MATCH p = (a)-[:KNOWS*1..2]->(b) RETURN p").unwrap();
8285        let plan = binder.bind(&ast).expect("bare path value binds");
8286        let has_struct = (0..plan.exprs.len()).any(|i| {
8287            matches!(
8288                plan.exprs.get(ExprId(u32::try_from(i).unwrap())),
8289                IrExpr::FunctionCall { name, .. } if name == "_path_struct"
8290            )
8291        });
8292        assert!(has_struct, "RETURN p must rewrite to _path_struct");
8293    }
8294
8295    #[test]
8296    fn multi_segment_path_var_composes_path_functions() {
8297        let (binder, _) = make_binder(OntologyMode::Exploratory);
8298        let ast = parse(
8299            "MATCH p = (a)-[:KNOWS]->(b)-[:KNOWS]->(c) \
8300             RETURN length(p), nodes(p), relationships(p)",
8301        )
8302        .unwrap();
8303        let plan = binder.bind(&ast).expect("multi-segment path binds");
8304        let add_count = (0..plan.exprs.len())
8305            .map(|index| plan.exprs.get(ExprId(index as u32)))
8306            .filter(|expr| {
8307                matches!(
8308                    expr,
8309                    IrExpr::BinaryOp {
8310                        op: BinaryOpKind::Add,
8311                        ..
8312                    }
8313                )
8314            })
8315            .count();
8316        assert!(add_count >= 3, "each path function composes its segments");
8317    }
8318
8319    #[test]
8320    fn fixed_segment_path_functions_bind() {
8321        // A fixed single hop composes from scalar edge/node columns:
8322        // length → _path_fixed_length, nodes → _node_struct_list,
8323        // relationships → _rel_struct_list.
8324        let (binder, _) = make_binder(OntologyMode::Exploratory);
8325        let ast = parse(
8326            "MATCH p = (a)-[:KNOWS]->(b) \
8327             RETURN length(p) AS l, nodes(p) AS ns, relationships(p) AS rs",
8328        )
8329        .unwrap();
8330        let plan = binder.bind(&ast).expect("fixed path functions bind");
8331        let names: Vec<&str> = (0..plan.exprs.len())
8332            .filter_map(
8333                |i| match plan.exprs.get(ExprId(u32::try_from(i).unwrap())) {
8334                    IrExpr::FunctionCall { name, .. } => Some(name.as_str()),
8335                    _ => None,
8336                },
8337            )
8338            .collect();
8339        for expected in [
8340            "_path_fixed_length",
8341            "_node_struct_list",
8342            "_rel_struct_list",
8343        ] {
8344            assert!(names.contains(&expected), "missing {expected}: {names:?}");
8345        }
8346    }
8347
8348    #[test]
8349    fn explicit_one_hop_routes_as_fixed_segment() {
8350        // `*1..1` goes to the relational join (no list column), so the path
8351        // rewrite must treat it as a fixed segment too.
8352        let (binder, _) = make_binder(OntologyMode::Exploratory);
8353        let ast = parse("MATCH p = (a)-[:KNOWS*1..1]->(b) RETURN length(p) AS l").unwrap();
8354        let plan = binder.bind(&ast).expect("explicit 1..1 binds as fixed");
8355        let has_fixed = (0..plan.exprs.len()).any(|i| {
8356            matches!(
8357                plan.exprs.get(ExprId(u32::try_from(i).unwrap())),
8358                IrExpr::FunctionCall { name, .. } if name == "_path_fixed_length"
8359            )
8360        });
8361        assert!(
8362            has_fixed,
8363            "explicit *1..1 must use the fixed-segment rewrite"
8364        );
8365    }
8366
8367    #[test]
8368    fn path_var_name_conflict_is_rejected() {
8369        let errors = bind_errors("MATCH p = (p)-[:KNOWS*1..2]->(b) RETURN length(p)");
8370        assert!(
8371            errors
8372                .iter()
8373                .any(|e| matches!(e.kind, BindErrorKind::DuplicateVariable)),
8374            "expected DuplicateVariable, got {errors:?}"
8375        );
8376    }
8377
8378    #[test]
8379    fn optional_match_path_var_functions_bind() {
8380        // path_vars introduced inside OPTIONAL MATCH must propagate to the
8381        // outer scope so a later RETURN can rewrite against them.
8382        let (binder, _) = make_binder(OntologyMode::Exploratory);
8383        let ast = parse(
8384            "MATCH (a:Person) \
8385             OPTIONAL MATCH p = (a)-[:KNOWS*1..2]->(b) \
8386             RETURN nodes(p) AS ns, length(p) AS l",
8387        )
8388        .unwrap();
8389        binder.bind(&ast).expect("optional path functions bind");
8390    }
8391
8392    fn procedure_binder() -> Binder {
8393        let (binder, _) = make_binder(OntologyMode::Exploratory);
8394        let procedure = ProcedureDefinition {
8395            name: "test.proc".into(),
8396            inputs: vec![ProcedureField {
8397                name: "in".into(),
8398                type_name: "INTEGER".into(),
8399                nullable: true,
8400            }],
8401            outputs: vec![ProcedureField {
8402                name: "out".into(),
8403                type_name: "INTEGER".into(),
8404                nullable: true,
8405            }],
8406            rows: vec![vec![IrLiteral::Int(1), IrLiteral::Int(2)]],
8407        };
8408        binder.with_procedures(Arc::new(ProcedureRegistry::from([(
8409            procedure.name.clone(),
8410            procedure,
8411        )])))
8412    }
8413
8414    #[test]
8415    fn call_binds_explicit_args_and_yield_alias() {
8416        let plan = procedure_binder()
8417            .bind(&parse("CALL test.proc(1) YIELD out AS value RETURN value").unwrap())
8418            .expect("CALL should bind");
8419        let GraphOp::Call { args, yields, .. } = &plan.ops[0] else {
8420            panic!("expected CALL op")
8421        };
8422        assert_eq!(args.len(), 1);
8423        assert_eq!(yields[0].field, "out");
8424        assert_eq!(yields[0].alias, "value");
8425    }
8426
8427    #[test]
8428    fn call_without_parentheses_uses_implicit_parameters() {
8429        let plan = procedure_binder()
8430            .bind(&parse("CALL test.proc YIELD out").unwrap())
8431            .expect("implicit CALL should bind");
8432        let GraphOp::Call { args, .. } = &plan.ops[0] else {
8433            panic!("expected CALL op")
8434        };
8435        assert!(matches!(plan.exprs.get(args[0]), IrExpr::Parameter(name) if name == "in"));
8436    }
8437
8438    #[test]
8439    fn call_rejects_unknown_procedure_argument_count_and_yield() {
8440        for (query, message) in [
8441            ("CALL missing.proc()", "ProcedureNotFound"),
8442            ("CALL test.proc()", "InvalidNumberOfArguments"),
8443            ("CALL test.proc(1) YIELD missing", "ProcedureOutputNotFound"),
8444        ] {
8445            let errors = procedure_binder()
8446                .bind(&parse(query).unwrap())
8447                .expect_err("CALL should fail");
8448            assert!(
8449                errors.iter().any(|error| error.message.contains(message)),
8450                "expected {message}, got {errors:?}"
8451            );
8452        }
8453    }
8454
8455    #[test]
8456    fn union_binds_branch_plans_and_mode() {
8457        let (binder, _) = make_binder(OntologyMode::Exploratory);
8458        let plan = binder
8459            .bind(&parse("RETURN 1 AS x UNION ALL RETURN 2 AS x").unwrap())
8460            .expect("UNION ALL binds");
8461        let [GraphOp::Union { all, inputs }] = plan.ops.as_slice() else {
8462            panic!("expected one UNION op")
8463        };
8464        assert!(*all);
8465        assert_eq!(inputs.len(), 2);
8466        assert!(inputs.iter().all(|branch| !branch.ops.is_empty()));
8467    }
8468
8469    #[test]
8470    fn union_rejects_mixed_modes_and_different_columns() {
8471        let (binder, _) = make_binder(OntologyMode::Exploratory);
8472        for (query, message) in [
8473            (
8474                "RETURN 1 AS x UNION RETURN 2 AS x UNION ALL RETURN 3 AS x",
8475                "InvalidCombinationOfUnion",
8476            ),
8477            (
8478                "RETURN 1 AS x UNION RETURN 2 AS y",
8479                "DifferentColumnsInUnion",
8480            ),
8481            ("CREATE (:A) UNION CREATE (:B)", "DifferentColumnsInUnion"),
8482        ] {
8483            let errors = binder
8484                .bind(&parse(query).unwrap())
8485                .expect_err("UNION should fail");
8486            assert!(errors.iter().any(|error| error.message.contains(message)));
8487        }
8488    }
8489
8490    #[test]
8491    fn return_wildcard_requires_a_variable_in_scope() {
8492        let (binder, _) = make_binder(OntologyMode::Exploratory);
8493        let errors = binder
8494            .bind(&parse("RETURN *").unwrap())
8495            .expect_err("empty-scope RETURN wildcard must fail");
8496        assert!(errors.iter().any(|error| {
8497            error
8498                .message
8499                .contains("wildcard requires at least one variable")
8500        }));
8501        assert_ne!(errors[0].span, Span::default());
8502    }
8503
8504    #[test]
8505    fn with_wildcard_preserves_an_empty_named_scope() {
8506        let (binder, _) = make_binder(OntologyMode::Exploratory);
8507        let plan = binder
8508            .bind(&parse("CREATE () WITH * CREATE ()").unwrap())
8509            .expect("empty-scope WITH wildcard should preserve pipeline rows");
8510        assert_eq!(plan.ops.len(), 3);
8511        assert!(matches!(&plan.ops[1], GraphOp::With { items, .. } if items.is_empty()));
8512    }
8513
8514    #[test]
8515    fn typed_uuid_parameters_are_identity_only_across_expression_surfaces() {
8516        let params = HashMap::from([("id".into(), IrLiteral::Uuid([0x55; 16]))]);
8517        for query in [
8518            "MATCH (n:Person) WHERE NOT (n.node_uuid = $id) RETURN n",
8519            "MATCH (n:Person) WHERE n.node_uuid <> $id RETURN n",
8520            "MATCH (n:Person) WHERE n.node_uuid > $id RETURN n",
8521            "MATCH (n:Person) WHERE n.node_uuid IN [$id] RETURN n",
8522            "RETURN $id",
8523            "RETURN toString($id)",
8524            "RETURN size([$id])",
8525            "RETURN [$id]",
8526            "RETURN 1 AS value SKIP $id",
8527            "RETURN 1 AS value LIMIT $id",
8528            "MATCH (n:Person) WHERE (($id = n.name) OR false) RETURN n",
8529            "MATCH (n:Person) WHERE n.name IN [$id] RETURN n",
8530            "MATCH (n:Person) RETURN n.name = $id AS bad",
8531            "MATCH (n:Person) WITH n, n.name = $id AS bad RETURN bad",
8532            "MATCH (n:Person) RETURN n.name AS name ORDER BY n.name = $id",
8533            "MATCH (n:Person) UNWIND [n.name = $id] AS bad RETURN bad",
8534            "MATCH (n:Person {probe: n.name = $id}) RETURN n",
8535            "MATCH (n:Person) SET n.probe = (n.name = $id) RETURN n",
8536            "MATCH (n:Person) MERGE (m:Other {probe: n.name = $id}) RETURN m",
8537            "MATCH (n:Person) DELETE (n.name = $id)",
8538            "MATCH (n:Person)-[r:KNOWS]->() WHERE r.node_uuid = $id RETURN r",
8539            "MATCH (n:Person)-[r:KNOWS]->() WHERE n.edge_uuid = $id RETURN n",
8540        ] {
8541            let (binder, _) = make_binder(OntologyMode::Exploratory);
8542            let errors = binder
8543                .with_parameter_literals(&params)
8544                .bind(&parse(query).unwrap())
8545                .expect_err("typed UUID must not reach a non-identity expression");
8546            assert!(
8547                errors.iter().any(|error| {
8548                    error.kind == BindErrorKind::InvalidArgument
8549                        && error.message
8550                            == "typed UUID parameter `$id` is only supported as a direct node_uuid or edge_uuid identity equality predicate"
8551                }),
8552                "query={query} errors={errors:?}"
8553            );
8554        }
8555
8556        let errors = procedure_binder()
8557            .with_parameter_literals(&params)
8558            .bind(
8559                &parse("MATCH (n:Person) CALL test.proc(n.name = $id) YIELD out RETURN out")
8560                    .unwrap(),
8561            )
8562            .expect_err("CALL argument must enforce typed UUID identity semantics");
8563        assert!(
8564            errors.iter().any(|error| {
8565                error.kind == BindErrorKind::InvalidArgument
8566                    && error.message.starts_with("typed UUID parameter `$id`")
8567            }),
8568            "errors={errors:?}"
8569        );
8570    }
8571
8572    #[test]
8573    fn typed_uuid_parameters_allow_only_kind_correct_identity_fields() {
8574        let params = HashMap::from([("id".into(), IrLiteral::Uuid([0x55; 16]))]);
8575        for query in [
8576            "MATCH (n:Person) WHERE n.node_uuid = $id RETURN n.node_uuid",
8577            "MATCH (n:Person) WHERE $id = n.node_uuid RETURN n.node_uuid",
8578            "MATCH ()-[r:KNOWS]->() WHERE r.edge_uuid = $id RETURN r.edge_uuid",
8579            "MATCH ()-[r:KNOWS]->() WHERE $id = r.edge_uuid RETURN r.edge_uuid",
8580        ] {
8581            let (binder, _) = make_binder(OntologyMode::Exploratory);
8582            binder
8583                .with_parameter_literals(&params)
8584                .bind(&parse(query).unwrap())
8585                .unwrap_or_else(|errors| panic!("query={query} errors={errors:?}"));
8586        }
8587
8588        for nested in [
8589            IrLiteral::List(vec![IrLiteral::Uuid([0x55; 16])]),
8590            IrLiteral::Map(vec![(
8591                "nested".into(),
8592                IrLiteral::List(vec![IrLiteral::Uuid([0x55; 16])]),
8593            )]),
8594        ] {
8595            let (binder, _) = make_binder(OntologyMode::Exploratory);
8596            let errors = binder
8597                .with_parameter_literals(&HashMap::from([("id".into(), nested)]))
8598                .bind(
8599                    &parse("MATCH (n:Person) WHERE n.node_uuid = $id RETURN n.node_uuid").unwrap(),
8600                )
8601                .expect_err("containers containing UUID values are never identity scalars");
8602            assert!(errors.iter().any(|error| {
8603                error.kind == BindErrorKind::InvalidArgument
8604                    && error.message.starts_with("typed UUID parameter `$id`")
8605            }));
8606        }
8607    }
8608}