Skip to main content

grafeo_engine/query/
binder.rs

1//! Semantic validation - catching errors before execution.
2//!
3//! The binder walks the logical plan and validates that everything makes sense:
4//! - Is that variable actually defined? (You can't use `RETURN x` if `x` wasn't matched)
5//! - Does that property access make sense? (Accessing `.age` on an integer fails)
6//! - Are types compatible? (Can't compare a string to an integer)
7//!
8//! Better to catch these errors early than waste time executing a broken query.
9
10use crate::query::plan::{
11    ExpandOp, FilterOp, LogicalExpression, LogicalOperator, LogicalPlan, NodeScanOp, ReturnItem,
12    ReturnOp, TripleScanOp,
13};
14use grafeo_common::types::LogicalType;
15use grafeo_common::utils::error::{Error, QueryError, QueryErrorKind, Result};
16use grafeo_common::utils::strings::{find_similar, format_suggestion};
17use indexmap::IndexMap;
18use std::collections::HashSet;
19
20/// Creates a semantic binding error.
21fn binding_error(message: impl Into<String>) -> Error {
22    Error::Query(QueryError::new(QueryErrorKind::Semantic, message))
23}
24
25/// Creates a semantic binding error with a hint.
26fn binding_error_with_hint(message: impl Into<String>, hint: impl Into<String>) -> Error {
27    Error::Query(QueryError::new(QueryErrorKind::Semantic, message).with_hint(hint))
28}
29
30/// Creates an "undefined variable" error with a suggestion if a similar variable exists.
31fn undefined_variable_error(variable: &str, context: &BindingContext, suffix: &str) -> Error {
32    let candidates: Vec<String> = context.variable_names();
33    let candidates_ref: Vec<&str> = candidates.iter().map(|s| s.as_str()).collect();
34
35    if let Some(suggestion) = find_similar(variable, &candidates_ref) {
36        binding_error_with_hint(
37            format!("Undefined variable '{variable}'{suffix}"),
38            format_suggestion(suggestion),
39        )
40    } else {
41        binding_error(format!("Undefined variable '{variable}'{suffix}"))
42    }
43}
44
45/// Information about a bound variable.
46#[derive(Debug, Clone)]
47pub struct VariableInfo {
48    /// The name of the variable.
49    pub name: String,
50    /// The inferred type of the variable.
51    pub data_type: LogicalType,
52    /// Whether this variable is a node.
53    pub is_node: bool,
54    /// Whether this variable is an edge.
55    pub is_edge: bool,
56}
57
58/// Context containing all bound variables and their information.
59///
60/// Uses `IndexMap` to maintain insertion order without a separate `Vec`,
61/// removing redundant storage and making `remove_variable` O(n) instead of
62/// two separate O(n) operations.
63#[derive(Debug, Clone, Default)]
64pub struct BindingContext {
65    /// Map from variable name to its info, in definition order.
66    variables: IndexMap<String, VariableInfo>,
67}
68
69impl BindingContext {
70    /// Creates a new empty binding context.
71    #[must_use]
72    pub fn new() -> Self {
73        Self {
74            variables: IndexMap::new(),
75        }
76    }
77
78    /// Adds a variable to the context.
79    ///
80    /// If the variable is already defined, replaces its info but preserves its
81    /// position in definition order.
82    pub fn add_variable(&mut self, name: String, info: VariableInfo) {
83        self.variables.insert(name, info);
84    }
85
86    /// Looks up a variable by name.
87    #[must_use]
88    pub fn get(&self, name: &str) -> Option<&VariableInfo> {
89        self.variables.get(name)
90    }
91
92    /// Checks if a variable is defined.
93    #[must_use]
94    pub fn contains(&self, name: &str) -> bool {
95        self.variables.contains_key(name)
96    }
97
98    /// Returns all variable names in definition order.
99    #[must_use]
100    pub fn variable_names(&self) -> Vec<String> {
101        self.variables.keys().cloned().collect()
102    }
103
104    /// Returns the number of bound variables.
105    #[must_use]
106    pub fn len(&self) -> usize {
107        self.variables.len()
108    }
109
110    /// Returns true if no variables are bound.
111    #[must_use]
112    pub fn is_empty(&self) -> bool {
113        self.variables.is_empty()
114    }
115
116    /// Removes a variable from the context (used for temporary scoping).
117    pub fn remove_variable(&mut self, name: &str) {
118        self.variables.shift_remove(name);
119    }
120}
121
122/// Semantic binder for query plans.
123///
124/// The binder walks the logical plan and:
125/// 1. Collects all variable definitions
126/// 2. Validates that all variable references are valid
127/// 3. Infers types where possible
128/// 4. Reports semantic errors
129pub struct Binder {
130    /// The current binding context.
131    context: BindingContext,
132}
133
134impl Binder {
135    /// Creates a new binder.
136    #[must_use]
137    pub fn new() -> Self {
138        Self {
139            context: BindingContext::new(),
140        }
141    }
142
143    /// Binds a logical plan, returning the binding context.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if semantic validation fails.
148    pub fn bind(&mut self, plan: &LogicalPlan) -> Result<BindingContext> {
149        self.bind_operator(&plan.root)?;
150        Ok(self.context.clone())
151    }
152
153    /// Binds a single logical operator.
154    fn bind_operator(&mut self, op: &LogicalOperator) -> Result<()> {
155        match op {
156            LogicalOperator::NodeScan(scan) => self.bind_node_scan(scan),
157            LogicalOperator::Expand(expand) => self.bind_expand(expand),
158            LogicalOperator::Filter(filter) => self.bind_filter(filter),
159            LogicalOperator::Return(ret) => self.bind_return(ret),
160            LogicalOperator::Project(project) => {
161                self.bind_operator(&project.input)?;
162                for projection in &project.projections {
163                    self.validate_expression(&projection.expression)?;
164                    // Add the projection alias to the context (for WITH clause support)
165                    if let Some(ref alias) = projection.alias {
166                        // Determine the type from the expression
167                        let data_type = self.infer_expression_type(&projection.expression);
168                        // Propagate node/edge status when projecting a variable
169                        // or a Case that selects between node variables (used
170                        // by optional() and union() translations).
171                        let (is_node, is_edge) = self.infer_entity_status(&projection.expression);
172                        self.context.add_variable(
173                            alias.clone(),
174                            VariableInfo {
175                                name: alias.clone(),
176                                data_type,
177                                is_node,
178                                is_edge,
179                            },
180                        );
181                    }
182                }
183                Ok(())
184            }
185            LogicalOperator::Limit(limit) => self.bind_operator(&limit.input),
186            LogicalOperator::Skip(skip) => self.bind_operator(&skip.input),
187            LogicalOperator::Sort(sort) => {
188                self.bind_operator(&sort.input)?;
189                for key in &sort.keys {
190                    self.validate_expression(&key.expression)?;
191                }
192                Ok(())
193            }
194            LogicalOperator::CreateNode(create) => {
195                // CreateNode introduces a new variable
196                if let Some(ref input) = create.input {
197                    self.bind_operator(input)?;
198                }
199                self.context.add_variable(
200                    create.variable.clone(),
201                    VariableInfo {
202                        name: create.variable.clone(),
203                        data_type: LogicalType::Node,
204                        is_node: true,
205                        is_edge: false,
206                    },
207                );
208                // Validate property expressions
209                for (_, expr) in &create.properties {
210                    self.validate_expression(expr)?;
211                }
212                Ok(())
213            }
214            LogicalOperator::EdgeScan(scan) => {
215                if let Some(ref input) = scan.input {
216                    self.bind_operator(input)?;
217                }
218                self.context.add_variable(
219                    scan.variable.clone(),
220                    VariableInfo {
221                        name: scan.variable.clone(),
222                        data_type: LogicalType::Edge,
223                        is_node: false,
224                        is_edge: true,
225                    },
226                );
227                Ok(())
228            }
229            LogicalOperator::Distinct(distinct) => self.bind_operator(&distinct.input),
230            LogicalOperator::Join(join) => self.bind_join(join),
231            LogicalOperator::Aggregate(agg) => self.bind_aggregate(agg),
232            LogicalOperator::CreateEdge(create) => {
233                self.bind_operator(&create.input)?;
234                // Validate that source and target variables are defined
235                if !self.context.contains(&create.from_variable) {
236                    return Err(undefined_variable_error(
237                        &create.from_variable,
238                        &self.context,
239                        " (source in CREATE EDGE)",
240                    ));
241                }
242                if !self.context.contains(&create.to_variable) {
243                    return Err(undefined_variable_error(
244                        &create.to_variable,
245                        &self.context,
246                        " (target in CREATE EDGE)",
247                    ));
248                }
249                // Add edge variable if present
250                if let Some(ref var) = create.variable {
251                    self.context.add_variable(
252                        var.clone(),
253                        VariableInfo {
254                            name: var.clone(),
255                            data_type: LogicalType::Edge,
256                            is_node: false,
257                            is_edge: true,
258                        },
259                    );
260                }
261                // Validate property expressions
262                for (_, expr) in &create.properties {
263                    self.validate_expression(expr)?;
264                }
265                Ok(())
266            }
267            LogicalOperator::DeleteNode(delete) => {
268                self.bind_operator(&delete.input)?;
269                // Validate that the variable to delete is defined
270                if !self.context.contains(&delete.variable) {
271                    return Err(undefined_variable_error(
272                        &delete.variable,
273                        &self.context,
274                        " in DELETE",
275                    ));
276                }
277                Ok(())
278            }
279            LogicalOperator::DeleteEdge(delete) => {
280                self.bind_operator(&delete.input)?;
281                // Validate that the variable to delete is defined
282                if !self.context.contains(&delete.variable) {
283                    return Err(undefined_variable_error(
284                        &delete.variable,
285                        &self.context,
286                        " in DELETE",
287                    ));
288                }
289                Ok(())
290            }
291            LogicalOperator::SetProperty(set) => {
292                self.bind_operator(&set.input)?;
293                // Validate that the variable to update is defined
294                if !self.context.contains(&set.variable) {
295                    return Err(undefined_variable_error(
296                        &set.variable,
297                        &self.context,
298                        " in SET",
299                    ));
300                }
301                // Validate property value expressions
302                for (_, expr) in &set.properties {
303                    self.validate_expression(expr)?;
304                }
305                Ok(())
306            }
307            LogicalOperator::Empty => Ok(()),
308
309            LogicalOperator::Unwind(unwind) => {
310                // First bind the input
311                self.bind_operator(&unwind.input)?;
312                // Validate the expression being unwound
313                self.validate_expression(&unwind.expression)?;
314                // Add the new variable to the context
315                self.context.add_variable(
316                    unwind.variable.clone(),
317                    VariableInfo {
318                        name: unwind.variable.clone(),
319                        data_type: LogicalType::Any, // Unwound elements can be any type
320                        is_node: false,
321                        is_edge: false,
322                    },
323                );
324                // Add ORDINALITY variable if present (1-based index)
325                if let Some(ref ord_var) = unwind.ordinality_var {
326                    self.context.add_variable(
327                        ord_var.clone(),
328                        VariableInfo {
329                            name: ord_var.clone(),
330                            data_type: LogicalType::Int64,
331                            is_node: false,
332                            is_edge: false,
333                        },
334                    );
335                }
336                // Add OFFSET variable if present (0-based index)
337                if let Some(ref off_var) = unwind.offset_var {
338                    self.context.add_variable(
339                        off_var.clone(),
340                        VariableInfo {
341                            name: off_var.clone(),
342                            data_type: LogicalType::Int64,
343                            is_node: false,
344                            is_edge: false,
345                        },
346                    );
347                }
348                Ok(())
349            }
350
351            // RDF/SPARQL operators
352            LogicalOperator::TripleScan(scan) => self.bind_triple_scan(scan),
353            LogicalOperator::Union(union) => {
354                for input in &union.inputs {
355                    self.bind_operator(input)?;
356                }
357                Ok(())
358            }
359            LogicalOperator::LeftJoin(lj) => {
360                self.bind_operator(&lj.left)?;
361                self.bind_operator(&lj.right)?;
362                if let Some(ref cond) = lj.condition {
363                    self.validate_expression(cond)?;
364                }
365                Ok(())
366            }
367            LogicalOperator::AntiJoin(aj) => {
368                self.bind_operator(&aj.left)?;
369                self.bind_operator(&aj.right)?;
370                Ok(())
371            }
372            LogicalOperator::Bind(bind) => {
373                self.bind_operator(&bind.input)?;
374                self.validate_expression(&bind.expression)?;
375                self.context.add_variable(
376                    bind.variable.clone(),
377                    VariableInfo {
378                        name: bind.variable.clone(),
379                        data_type: LogicalType::Any,
380                        is_node: false,
381                        is_edge: false,
382                    },
383                );
384                Ok(())
385            }
386            LogicalOperator::Merge(merge) => {
387                // First bind the input
388                self.bind_operator(&merge.input)?;
389                // Validate the match property expressions
390                for (_, expr) in &merge.match_properties {
391                    self.validate_expression(expr)?;
392                }
393                // Validate the ON CREATE property expressions
394                for (_, expr) in &merge.on_create {
395                    self.validate_expression(expr)?;
396                }
397                // Validate the ON MATCH property expressions
398                for (_, expr) in &merge.on_match {
399                    self.validate_expression(expr)?;
400                }
401                // MERGE introduces a new variable
402                self.context.add_variable(
403                    merge.variable.clone(),
404                    VariableInfo {
405                        name: merge.variable.clone(),
406                        data_type: LogicalType::Node,
407                        is_node: true,
408                        is_edge: false,
409                    },
410                );
411                Ok(())
412            }
413            LogicalOperator::MergeRelationship(merge_rel) => {
414                self.bind_operator(&merge_rel.input)?;
415                // Validate source and target variables exist
416                if !self.context.contains(&merge_rel.source_variable) {
417                    return Err(undefined_variable_error(
418                        &merge_rel.source_variable,
419                        &self.context,
420                        " in MERGE relationship source",
421                    ));
422                }
423                if !self.context.contains(&merge_rel.target_variable) {
424                    return Err(undefined_variable_error(
425                        &merge_rel.target_variable,
426                        &self.context,
427                        " in MERGE relationship target",
428                    ));
429                }
430                for (_, expr) in &merge_rel.match_properties {
431                    self.validate_expression(expr)?;
432                }
433                for (_, expr) in &merge_rel.on_create {
434                    self.validate_expression(expr)?;
435                }
436                for (_, expr) in &merge_rel.on_match {
437                    self.validate_expression(expr)?;
438                }
439                // MERGE relationship introduces the edge variable
440                self.context.add_variable(
441                    merge_rel.variable.clone(),
442                    VariableInfo {
443                        name: merge_rel.variable.clone(),
444                        data_type: LogicalType::Edge,
445                        is_node: false,
446                        is_edge: true,
447                    },
448                );
449                Ok(())
450            }
451            LogicalOperator::AddLabel(add_label) => {
452                self.bind_operator(&add_label.input)?;
453                // Validate that the variable exists
454                if !self.context.contains(&add_label.variable) {
455                    return Err(undefined_variable_error(
456                        &add_label.variable,
457                        &self.context,
458                        " in SET labels",
459                    ));
460                }
461                Ok(())
462            }
463            LogicalOperator::RemoveLabel(remove_label) => {
464                self.bind_operator(&remove_label.input)?;
465                // Validate that the variable exists
466                if !self.context.contains(&remove_label.variable) {
467                    return Err(undefined_variable_error(
468                        &remove_label.variable,
469                        &self.context,
470                        " in REMOVE labels",
471                    ));
472                }
473                Ok(())
474            }
475            LogicalOperator::ShortestPath(sp) => {
476                // First bind the input
477                self.bind_operator(&sp.input)?;
478                // Validate that source and target variables are defined
479                if !self.context.contains(&sp.source_var) {
480                    return Err(undefined_variable_error(
481                        &sp.source_var,
482                        &self.context,
483                        " (source in shortestPath)",
484                    ));
485                }
486                if !self.context.contains(&sp.target_var) {
487                    return Err(undefined_variable_error(
488                        &sp.target_var,
489                        &self.context,
490                        " (target in shortestPath)",
491                    ));
492                }
493                // Add the path alias variable to the context
494                self.context.add_variable(
495                    sp.path_alias.clone(),
496                    VariableInfo {
497                        name: sp.path_alias.clone(),
498                        data_type: LogicalType::Any, // Path is a complex type
499                        is_node: false,
500                        is_edge: false,
501                    },
502                );
503                // Also add the path length variable for length(p) calls
504                let path_length_var = format!("_path_length_{}", sp.path_alias);
505                self.context.add_variable(
506                    path_length_var.clone(),
507                    VariableInfo {
508                        name: path_length_var,
509                        data_type: LogicalType::Int64,
510                        is_node: false,
511                        is_edge: false,
512                    },
513                );
514                Ok(())
515            }
516            // SPARQL Update operators - these don't require variable binding
517            LogicalOperator::InsertTriple(insert) => {
518                if let Some(ref input) = insert.input {
519                    self.bind_operator(input)?;
520                }
521                Ok(())
522            }
523            LogicalOperator::DeleteTriple(delete) => {
524                if let Some(ref input) = delete.input {
525                    self.bind_operator(input)?;
526                }
527                Ok(())
528            }
529            LogicalOperator::Modify(modify) => {
530                self.bind_operator(&modify.where_clause)?;
531                Ok(())
532            }
533            LogicalOperator::ClearGraph(_)
534            | LogicalOperator::CreateGraph(_)
535            | LogicalOperator::DropGraph(_)
536            | LogicalOperator::LoadGraph(_)
537            | LogicalOperator::CopyGraph(_)
538            | LogicalOperator::MoveGraph(_)
539            | LogicalOperator::AddGraph(_)
540            | LogicalOperator::HorizontalAggregate(_) => Ok(()),
541            LogicalOperator::VectorScan(scan) => {
542                // VectorScan introduces a variable for matched nodes
543                if let Some(ref input) = scan.input {
544                    self.bind_operator(input)?;
545                }
546                self.context.add_variable(
547                    scan.variable.clone(),
548                    VariableInfo {
549                        name: scan.variable.clone(),
550                        data_type: LogicalType::Node,
551                        is_node: true,
552                        is_edge: false,
553                    },
554                );
555                // Validate the query vector expression
556                self.validate_expression(&scan.query_vector)?;
557                Ok(())
558            }
559            LogicalOperator::VectorJoin(join) => {
560                // VectorJoin takes input from left side and produces right-side matches
561                self.bind_operator(&join.input)?;
562                // Add right variable for matched nodes
563                self.context.add_variable(
564                    join.right_variable.clone(),
565                    VariableInfo {
566                        name: join.right_variable.clone(),
567                        data_type: LogicalType::Node,
568                        is_node: true,
569                        is_edge: false,
570                    },
571                );
572                // Optionally add score variable
573                if let Some(ref score_var) = join.score_variable {
574                    self.context.add_variable(
575                        score_var.clone(),
576                        VariableInfo {
577                            name: score_var.clone(),
578                            data_type: LogicalType::Float64,
579                            is_node: false,
580                            is_edge: false,
581                        },
582                    );
583                }
584                // Validate the query vector expression
585                self.validate_expression(&join.query_vector)?;
586                Ok(())
587            }
588            LogicalOperator::MapCollect(mc) => {
589                self.bind_operator(&mc.input)?;
590                self.context.add_variable(
591                    mc.alias.clone(),
592                    VariableInfo {
593                        name: mc.alias.clone(),
594                        data_type: LogicalType::Any,
595                        is_node: false,
596                        is_edge: false,
597                    },
598                );
599                Ok(())
600            }
601            LogicalOperator::Except(except) => {
602                self.bind_operator(&except.left)?;
603                self.bind_operator(&except.right)?;
604                Ok(())
605            }
606            LogicalOperator::Intersect(intersect) => {
607                self.bind_operator(&intersect.left)?;
608                self.bind_operator(&intersect.right)?;
609                Ok(())
610            }
611            LogicalOperator::Otherwise(otherwise) => {
612                self.bind_operator(&otherwise.left)?;
613                self.bind_operator(&otherwise.right)?;
614                Ok(())
615            }
616            LogicalOperator::Apply(apply) => {
617                // Snapshot context BEFORE binding the input, so we can detect
618                // which variables were added by the input plan.
619                let pre_apply_names: HashSet<String> =
620                    self.context.variable_names().iter().cloned().collect();
621
622                self.bind_operator(&apply.input)?;
623
624                // Scope down: when the input plan exposes a Return/Aggregate
625                // projection (not a raw scan/expand), remove its internal-only
626                // variables. Only the projected output columns should be visible
627                // to the subplan — this prevents variables internal to a sibling
628                // CALL block from leaking into the next CALL block.
629                let mut input_output_ctx = BindingContext::new();
630                Self::register_subplan_columns(&apply.input, &mut input_output_ctx);
631                let input_output_names: HashSet<String> =
632                    input_output_ctx.variable_names().iter().cloned().collect();
633
634                if !input_output_names.is_empty() {
635                    // Input has an explicit projection: remove its internals.
636                    let input_internals: Vec<String> = self
637                        .context
638                        .variable_names()
639                        .iter()
640                        .filter(|n| {
641                            !pre_apply_names.contains(*n) && !input_output_names.contains(*n)
642                        })
643                        .cloned()
644                        .collect();
645                    for name in input_internals {
646                        self.context.remove_variable(&name);
647                    }
648                }
649
650                // Snapshot the permitted outer context for the subplan.
651                let outer_names: HashSet<String> =
652                    self.context.variable_names().iter().cloned().collect();
653
654                self.bind_operator(&apply.subplan)?;
655
656                // Remove internal-only variables added by the subplan (those that
657                // are not output columns). Prevents subplan internals from leaking
658                // into the outer query or sibling CALL blocks.
659                let mut subplan_output_ctx = BindingContext::new();
660                Self::register_subplan_columns(&apply.subplan, &mut subplan_output_ctx);
661                let subplan_output_names: HashSet<String> = subplan_output_ctx
662                    .variable_names()
663                    .iter()
664                    .cloned()
665                    .collect();
666
667                let to_remove: Vec<String> = self
668                    .context
669                    .variable_names()
670                    .iter()
671                    .filter(|n| !outer_names.contains(*n) && !subplan_output_names.contains(*n))
672                    .cloned()
673                    .collect();
674                for name in to_remove {
675                    self.context.remove_variable(&name);
676                }
677
678                // Register output columns so downstream operators can reference them.
679                Self::register_subplan_columns(&apply.subplan, &mut self.context);
680                Ok(())
681            }
682            LogicalOperator::MultiWayJoin(mwj) => {
683                for input in &mwj.inputs {
684                    self.bind_operator(input)?;
685                }
686                for cond in &mwj.conditions {
687                    self.validate_expression(&cond.left)?;
688                    self.validate_expression(&cond.right)?;
689                }
690                Ok(())
691            }
692            LogicalOperator::ParameterScan(param_scan) => {
693                // Register parameter columns as variables (injected by outer Apply)
694                for col in &param_scan.columns {
695                    self.context.add_variable(
696                        col.clone(),
697                        VariableInfo {
698                            name: col.clone(),
699                            data_type: LogicalType::Any,
700                            is_node: true,
701                            is_edge: false,
702                        },
703                    );
704                }
705                Ok(())
706            }
707            // DDL operators don't need binding: they're handled before the binder
708            LogicalOperator::CreatePropertyGraph(_) => Ok(()),
709            // Procedure calls: register yielded columns as variables for downstream operators
710            LogicalOperator::CallProcedure(call) => {
711                if let Some(yields) = &call.yield_items {
712                    for item in yields {
713                        let var_name = item.alias.as_deref().unwrap_or(&item.field_name);
714                        self.context.add_variable(
715                            var_name.to_string(),
716                            VariableInfo {
717                                name: var_name.to_string(),
718                                data_type: LogicalType::Any,
719                                is_node: false,
720                                is_edge: false,
721                            },
722                        );
723                    }
724                }
725                Ok(())
726            }
727            LogicalOperator::LoadData(load) => {
728                // The row variable is bound as Any (Map or List depending on WITH HEADERS)
729                self.context.add_variable(
730                    load.variable.clone(),
731                    VariableInfo {
732                        name: load.variable.clone(),
733                        data_type: LogicalType::Any,
734                        is_node: false,
735                        is_edge: false,
736                    },
737                );
738                Ok(())
739            }
740            LogicalOperator::Construct(construct) => self.bind_operator(&construct.input),
741            LogicalOperator::TextScan(scan) => {
742                self.context.add_variable(
743                    scan.variable.clone(),
744                    VariableInfo {
745                        name: scan.variable.clone(),
746                        data_type: LogicalType::Node,
747                        is_node: true,
748                        is_edge: false,
749                    },
750                );
751                if let Some(ref score_col) = scan.score_column {
752                    self.context.add_variable(
753                        score_col.clone(),
754                        VariableInfo {
755                            name: score_col.clone(),
756                            data_type: LogicalType::Float64,
757                            is_node: false,
758                            is_edge: false,
759                        },
760                    );
761                }
762                self.validate_expression(&scan.query)?;
763                Ok(())
764            }
765        }
766    }
767
768    /// Binds a triple scan operator (for RDF/SPARQL).
769    fn bind_triple_scan(&mut self, scan: &TripleScanOp) -> Result<()> {
770        use crate::query::plan::TripleComponent;
771
772        // First bind the input if present
773        if let Some(ref input) = scan.input {
774            self.bind_operator(input)?;
775        }
776
777        // Add variables for subject, predicate, object
778        if let TripleComponent::Variable(name) = &scan.subject
779            && !self.context.contains(name)
780        {
781            self.context.add_variable(
782                name.clone(),
783                VariableInfo {
784                    name: name.clone(),
785                    data_type: LogicalType::Any, // RDF term
786                    is_node: false,
787                    is_edge: false,
788                },
789            );
790        }
791
792        if let TripleComponent::Variable(name) = &scan.predicate
793            && !self.context.contains(name)
794        {
795            self.context.add_variable(
796                name.clone(),
797                VariableInfo {
798                    name: name.clone(),
799                    data_type: LogicalType::Any, // IRI
800                    is_node: false,
801                    is_edge: false,
802                },
803            );
804        }
805
806        if let TripleComponent::Variable(name) = &scan.object
807            && !self.context.contains(name)
808        {
809            self.context.add_variable(
810                name.clone(),
811                VariableInfo {
812                    name: name.clone(),
813                    data_type: LogicalType::Any, // RDF term
814                    is_node: false,
815                    is_edge: false,
816                },
817            );
818        }
819
820        if let Some(TripleComponent::Variable(name)) = &scan.graph
821            && !self.context.contains(name)
822        {
823            self.context.add_variable(
824                name.clone(),
825                VariableInfo {
826                    name: name.clone(),
827                    data_type: LogicalType::Any, // IRI
828                    is_node: false,
829                    is_edge: false,
830                },
831            );
832        }
833
834        Ok(())
835    }
836
837    /// Binds a node scan operator.
838    fn bind_node_scan(&mut self, scan: &NodeScanOp) -> Result<()> {
839        // First bind the input if present
840        if let Some(ref input) = scan.input {
841            self.bind_operator(input)?;
842        }
843
844        // Add the scanned variable to scope
845        self.context.add_variable(
846            scan.variable.clone(),
847            VariableInfo {
848                name: scan.variable.clone(),
849                data_type: LogicalType::Node,
850                is_node: true,
851                is_edge: false,
852            },
853        );
854
855        Ok(())
856    }
857
858    /// Binds an expand operator.
859    fn bind_expand(&mut self, expand: &ExpandOp) -> Result<()> {
860        // First bind the input
861        self.bind_operator(&expand.input)?;
862
863        // Validate that the source variable is defined
864        if !self.context.contains(&expand.from_variable) {
865            return Err(undefined_variable_error(
866                &expand.from_variable,
867                &self.context,
868                " in EXPAND",
869            ));
870        }
871
872        // Validate that the source is a node
873        if let Some(info) = self.context.get(&expand.from_variable)
874            && !info.is_node
875        {
876            return Err(binding_error(format!(
877                "Variable '{}' is not a node, cannot expand from it",
878                expand.from_variable
879            )));
880        }
881
882        // Add edge variable if present
883        if let Some(ref edge_var) = expand.edge_variable {
884            self.context.add_variable(
885                edge_var.clone(),
886                VariableInfo {
887                    name: edge_var.clone(),
888                    data_type: LogicalType::Edge,
889                    is_node: false,
890                    is_edge: true,
891                },
892            );
893        }
894
895        // Add target variable
896        self.context.add_variable(
897            expand.to_variable.clone(),
898            VariableInfo {
899                name: expand.to_variable.clone(),
900                data_type: LogicalType::Node,
901                is_node: true,
902                is_edge: false,
903            },
904        );
905
906        // Add path variables for variable-length paths
907        if let Some(ref path_alias) = expand.path_alias {
908            // Register the path variable itself (e.g. p in MATCH p=...)
909            self.context.add_variable(
910                path_alias.clone(),
911                VariableInfo {
912                    name: path_alias.clone(),
913                    data_type: LogicalType::Any,
914                    is_node: false,
915                    is_edge: false,
916                },
917            );
918            // length(p) → _path_length_p
919            let path_length_var = format!("_path_length_{}", path_alias);
920            self.context.add_variable(
921                path_length_var.clone(),
922                VariableInfo {
923                    name: path_length_var,
924                    data_type: LogicalType::Int64,
925                    is_node: false,
926                    is_edge: false,
927                },
928            );
929            // nodes(p) → _path_nodes_p
930            let path_nodes_var = format!("_path_nodes_{}", path_alias);
931            self.context.add_variable(
932                path_nodes_var.clone(),
933                VariableInfo {
934                    name: path_nodes_var,
935                    data_type: LogicalType::Any,
936                    is_node: false,
937                    is_edge: false,
938                },
939            );
940            // edges(p) → _path_edges_p
941            let path_edges_var = format!("_path_edges_{}", path_alias);
942            self.context.add_variable(
943                path_edges_var.clone(),
944                VariableInfo {
945                    name: path_edges_var,
946                    data_type: LogicalType::Any,
947                    is_node: false,
948                    is_edge: false,
949                },
950            );
951        }
952
953        Ok(())
954    }
955
956    /// Binds a filter operator.
957    fn bind_filter(&mut self, filter: &FilterOp) -> Result<()> {
958        // First bind the input
959        self.bind_operator(&filter.input)?;
960
961        // Validate the predicate expression
962        self.validate_expression(&filter.predicate)?;
963
964        Ok(())
965    }
966
967    /// Registers output columns from a subplan into the binding context.
968    /// Walks through wrapping operators to find a Return and extracts column names.
969    fn register_subplan_columns(plan: &LogicalOperator, ctx: &mut BindingContext) {
970        match plan {
971            LogicalOperator::Return(ret) => {
972                for item in &ret.items {
973                    let col_name = if let Some(alias) = &item.alias {
974                        alias.clone()
975                    } else {
976                        match &item.expression {
977                            LogicalExpression::Variable(name) => name.clone(),
978                            LogicalExpression::Property { variable, property } => {
979                                format!("{variable}.{property}")
980                            }
981                            _ => continue,
982                        }
983                    };
984                    ctx.add_variable(
985                        col_name.clone(),
986                        VariableInfo {
987                            name: col_name,
988                            data_type: LogicalType::Any,
989                            is_node: false,
990                            is_edge: false,
991                        },
992                    );
993                }
994            }
995            LogicalOperator::Sort(s) => Self::register_subplan_columns(&s.input, ctx),
996            LogicalOperator::Limit(l) => Self::register_subplan_columns(&l.input, ctx),
997            LogicalOperator::Distinct(d) => Self::register_subplan_columns(&d.input, ctx),
998            LogicalOperator::Aggregate(agg) => {
999                // Aggregate produces named output columns
1000                for expr in &agg.aggregates {
1001                    if let Some(alias) = &expr.alias {
1002                        ctx.add_variable(
1003                            alias.clone(),
1004                            VariableInfo {
1005                                name: alias.clone(),
1006                                data_type: LogicalType::Any,
1007                                is_node: false,
1008                                is_edge: false,
1009                            },
1010                        );
1011                    }
1012                }
1013            }
1014            _ => {}
1015        }
1016    }
1017
1018    /// Binds a return operator.
1019    fn bind_return(&mut self, ret: &ReturnOp) -> Result<()> {
1020        // First bind the input
1021        self.bind_operator(&ret.input)?;
1022
1023        // Validate all return expressions and register aliases
1024        // (aliases must be visible to parent Sort for ORDER BY resolution)
1025        for item in &ret.items {
1026            self.validate_return_item(item)?;
1027            if let Some(ref alias) = item.alias {
1028                let data_type = self.infer_expression_type(&item.expression);
1029                self.context.add_variable(
1030                    alias.clone(),
1031                    VariableInfo {
1032                        name: alias.clone(),
1033                        data_type,
1034                        is_node: false,
1035                        is_edge: false,
1036                    },
1037                );
1038            }
1039        }
1040
1041        Ok(())
1042    }
1043
1044    /// Validates a return item.
1045    fn validate_return_item(&mut self, item: &ReturnItem) -> Result<()> {
1046        self.validate_expression(&item.expression)
1047    }
1048
1049    /// Validates that an expression only references defined variables.
1050    fn validate_expression(&mut self, expr: &LogicalExpression) -> Result<()> {
1051        match expr {
1052            LogicalExpression::Variable(name) => {
1053                // "*" is a wildcard marker for RETURN *, expanded by the planner
1054                if name == "*" {
1055                    return Ok(());
1056                }
1057                if !self.context.contains(name) && !name.starts_with("_anon_") {
1058                    return Err(undefined_variable_error(name, &self.context, ""));
1059                }
1060                Ok(())
1061            }
1062            LogicalExpression::Property { variable, .. } => {
1063                if !self.context.contains(variable) && !variable.starts_with("_anon_") {
1064                    return Err(undefined_variable_error(
1065                        variable,
1066                        &self.context,
1067                        " in property access",
1068                    ));
1069                }
1070                Ok(())
1071            }
1072            LogicalExpression::Literal(_) => Ok(()),
1073            LogicalExpression::Binary { left, right, .. } => {
1074                self.validate_expression(left)?;
1075                self.validate_expression(right)
1076            }
1077            LogicalExpression::Unary { operand, .. } => self.validate_expression(operand),
1078            LogicalExpression::FunctionCall { args, .. } => {
1079                for arg in args {
1080                    self.validate_expression(arg)?;
1081                }
1082                Ok(())
1083            }
1084            LogicalExpression::List(items) => {
1085                for item in items {
1086                    self.validate_expression(item)?;
1087                }
1088                Ok(())
1089            }
1090            LogicalExpression::Map(pairs) => {
1091                for (_, value) in pairs {
1092                    self.validate_expression(value)?;
1093                }
1094                Ok(())
1095            }
1096            LogicalExpression::IndexAccess { base, index } => {
1097                self.validate_expression(base)?;
1098                self.validate_expression(index)
1099            }
1100            LogicalExpression::SliceAccess { base, start, end } => {
1101                self.validate_expression(base)?;
1102                if let Some(s) = start {
1103                    self.validate_expression(s)?;
1104                }
1105                if let Some(e) = end {
1106                    self.validate_expression(e)?;
1107                }
1108                Ok(())
1109            }
1110            LogicalExpression::Case {
1111                operand,
1112                when_clauses,
1113                else_clause,
1114            } => {
1115                if let Some(op) = operand {
1116                    self.validate_expression(op)?;
1117                }
1118                for (cond, result) in when_clauses {
1119                    self.validate_expression(cond)?;
1120                    self.validate_expression(result)?;
1121                }
1122                if let Some(else_expr) = else_clause {
1123                    self.validate_expression(else_expr)?;
1124                }
1125                Ok(())
1126            }
1127            // Parameter references are validated externally
1128            LogicalExpression::Parameter(_) => Ok(()),
1129            // labels(n), type(e), id(n) need the variable to be defined
1130            LogicalExpression::Labels(var)
1131            | LogicalExpression::Type(var)
1132            | LogicalExpression::Id(var) => {
1133                if !self.context.contains(var) && !var.starts_with("_anon_") {
1134                    return Err(undefined_variable_error(var, &self.context, " in function"));
1135                }
1136                Ok(())
1137            }
1138            LogicalExpression::ListComprehension { list_expr, .. } => {
1139                // Validate the list expression against the outer context.
1140                // The filter and map expressions use the iteration variable
1141                // which is locally scoped, so we skip validating them here.
1142                self.validate_expression(list_expr)?;
1143                Ok(())
1144            }
1145            LogicalExpression::ListPredicate { list_expr, .. } => {
1146                // Validate the list expression against the outer context.
1147                // The predicate uses the iteration variable which is locally
1148                // scoped, so we skip validating it against the outer context.
1149                self.validate_expression(list_expr)?;
1150                Ok(())
1151            }
1152            LogicalExpression::ExistsSubquery(subquery)
1153            | LogicalExpression::CountSubquery(subquery)
1154            | LogicalExpression::ValueSubquery(subquery) => {
1155                // Subqueries have their own binding context
1156                // For now, just validate the structure exists
1157                let _ = subquery; // Would need recursive binding
1158                Ok(())
1159            }
1160            LogicalExpression::PatternComprehension {
1161                subplan,
1162                projection,
1163            } => {
1164                // Bind the subplan to register pattern variables (e.g., `f` in `(p)-[:KNOWS]->(f)`)
1165                self.bind_operator(subplan)?;
1166                // Now validate the projection expression (e.g., `f.name`)
1167                self.validate_expression(projection)
1168            }
1169            LogicalExpression::MapProjection { base, entries } => {
1170                if !self.context.contains(base) && !base.starts_with("_anon_") {
1171                    return Err(undefined_variable_error(
1172                        base,
1173                        &self.context,
1174                        " in map projection",
1175                    ));
1176                }
1177                for entry in entries {
1178                    if let crate::query::plan::MapProjectionEntry::LiteralEntry(_, expr) = entry {
1179                        self.validate_expression(expr)?;
1180                    }
1181                }
1182                Ok(())
1183            }
1184            LogicalExpression::Reduce {
1185                accumulator,
1186                initial,
1187                variable,
1188                list,
1189                expression,
1190            } => {
1191                self.validate_expression(initial)?;
1192                self.validate_expression(list)?;
1193                // accumulator and variable are locally scoped: inject them
1194                // into context, validate body, then remove
1195                let had_acc = self.context.contains(accumulator);
1196                let had_var = self.context.contains(variable);
1197                if !had_acc {
1198                    self.context.add_variable(
1199                        accumulator.clone(),
1200                        VariableInfo {
1201                            name: accumulator.clone(),
1202                            data_type: LogicalType::Any,
1203                            is_node: false,
1204                            is_edge: false,
1205                        },
1206                    );
1207                }
1208                if !had_var {
1209                    self.context.add_variable(
1210                        variable.clone(),
1211                        VariableInfo {
1212                            name: variable.clone(),
1213                            data_type: LogicalType::Any,
1214                            is_node: false,
1215                            is_edge: false,
1216                        },
1217                    );
1218                }
1219                self.validate_expression(expression)?;
1220                if !had_acc {
1221                    self.context.remove_variable(accumulator);
1222                }
1223                if !had_var {
1224                    self.context.remove_variable(variable);
1225                }
1226                Ok(())
1227            }
1228        }
1229    }
1230
1231    /// Infers the type of an expression for use in WITH clause aliasing.
1232    fn infer_expression_type(&self, expr: &LogicalExpression) -> LogicalType {
1233        match expr {
1234            LogicalExpression::Variable(name) => {
1235                // Look up the variable type from context
1236                self.context
1237                    .get(name)
1238                    .map_or(LogicalType::Any, |info| info.data_type.clone())
1239            }
1240            LogicalExpression::Property { .. } => LogicalType::Any, // Properties can be any type
1241            LogicalExpression::Literal(value) => {
1242                // Infer type from literal value
1243                use grafeo_common::types::Value;
1244                match value {
1245                    Value::Bool(_) => LogicalType::Bool,
1246                    Value::Int64(_) => LogicalType::Int64,
1247                    Value::Float64(_) => LogicalType::Float64,
1248                    Value::String(_) => LogicalType::String,
1249                    Value::List(_) => LogicalType::Any, // Complex type
1250                    Value::Map(_) => LogicalType::Any,  // Complex type
1251                    Value::Null => LogicalType::Any,
1252                    _ => LogicalType::Any,
1253                }
1254            }
1255            LogicalExpression::Binary { .. } => LogicalType::Any, // Could be bool or numeric
1256            LogicalExpression::Unary { .. } => LogicalType::Any,
1257            LogicalExpression::FunctionCall { name, .. } => {
1258                // Infer based on function name
1259                match name.to_lowercase().as_str() {
1260                    "count" | "sum" | "id" => LogicalType::Int64,
1261                    "avg" => LogicalType::Float64,
1262                    "type" => LogicalType::String,
1263                    // List-returning functions use Any since we don't track element type
1264                    "labels" | "collect" => LogicalType::Any,
1265                    _ => LogicalType::Any,
1266                }
1267            }
1268            LogicalExpression::List(_) => LogicalType::Any, // Complex type
1269            LogicalExpression::Map(_) => LogicalType::Any,  // Complex type
1270            _ => LogicalType::Any,
1271        }
1272    }
1273
1274    /// Infers whether an expression resolves to a node or edge entity.
1275    ///
1276    /// Returns `(is_node, is_edge)`. This propagates entity status through
1277    /// simple Variable references and Case expressions whose branches all
1278    /// agree on entity kind (used by optional() translation).
1279    fn infer_entity_status(&self, expr: &LogicalExpression) -> (bool, bool) {
1280        match expr {
1281            LogicalExpression::Variable(src) => self
1282                .context
1283                .get(src)
1284                .map_or((false, false), |info| (info.is_node, info.is_edge)),
1285            LogicalExpression::Case {
1286                when_clauses,
1287                else_clause,
1288                ..
1289            } => {
1290                // Collect entity status from all THEN and ELSE branches
1291                let mut all_node = true;
1292                let mut all_edge = true;
1293                let mut any_branch = false;
1294                for (_, then_expr) in when_clauses {
1295                    let (n, e) = self.infer_entity_status(then_expr);
1296                    all_node &= n;
1297                    all_edge &= e;
1298                    any_branch = true;
1299                }
1300                if let Some(else_expr) = else_clause {
1301                    let (n, e) = self.infer_entity_status(else_expr);
1302                    all_node &= n;
1303                    all_edge &= e;
1304                    any_branch = true;
1305                }
1306                if any_branch {
1307                    (all_node, all_edge)
1308                } else {
1309                    (false, false)
1310                }
1311            }
1312            _ => (false, false),
1313        }
1314    }
1315
1316    /// Binds a join operator.
1317    fn bind_join(&mut self, join: &crate::query::plan::JoinOp) -> Result<()> {
1318        // Bind both sides of the join
1319        self.bind_operator(&join.left)?;
1320        self.bind_operator(&join.right)?;
1321
1322        // Validate join conditions
1323        for condition in &join.conditions {
1324            self.validate_expression(&condition.left)?;
1325            self.validate_expression(&condition.right)?;
1326        }
1327
1328        Ok(())
1329    }
1330
1331    /// Binds an aggregate operator.
1332    fn bind_aggregate(&mut self, agg: &crate::query::plan::AggregateOp) -> Result<()> {
1333        // Bind the input first
1334        self.bind_operator(&agg.input)?;
1335
1336        // Validate group by expressions
1337        for expr in &agg.group_by {
1338            self.validate_expression(expr)?;
1339        }
1340
1341        // Validate aggregate expressions
1342        for agg_expr in &agg.aggregates {
1343            if let Some(ref expr) = agg_expr.expression {
1344                self.validate_expression(expr)?;
1345            }
1346            // Add the alias as a new variable if present
1347            if let Some(ref alias) = agg_expr.alias {
1348                self.context.add_variable(
1349                    alias.clone(),
1350                    VariableInfo {
1351                        name: alias.clone(),
1352                        data_type: LogicalType::Any,
1353                        is_node: false,
1354                        is_edge: false,
1355                    },
1356                );
1357            }
1358        }
1359
1360        // Register group-by output column names so ORDER BY / HAVING
1361        // can reference them (e.g. "n.city" from Property(n, city)).
1362        for expr in &agg.group_by {
1363            let col_name = crate::query::planner::common::expression_to_string(expr);
1364            if !self.context.contains(&col_name) {
1365                self.context.add_variable(
1366                    col_name.clone(),
1367                    VariableInfo {
1368                        name: col_name,
1369                        data_type: LogicalType::Any,
1370                        is_node: false,
1371                        is_edge: false,
1372                    },
1373                );
1374            }
1375        }
1376
1377        Ok(())
1378    }
1379}
1380
1381impl Default for Binder {
1382    fn default() -> Self {
1383        Self::new()
1384    }
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389    use super::*;
1390    use crate::query::plan::{BinaryOp, FilterOp};
1391
1392    #[test]
1393    fn test_bind_simple_scan() {
1394        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1395            items: vec![ReturnItem {
1396                expression: LogicalExpression::Variable("n".to_string()),
1397                alias: None,
1398            }],
1399            distinct: false,
1400            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1401                variable: "n".to_string(),
1402                label: Some("Person".to_string()),
1403                input: None,
1404            })),
1405        }));
1406
1407        let mut binder = Binder::new();
1408        let result = binder.bind(&plan);
1409
1410        assert!(result.is_ok());
1411        let ctx = result.unwrap();
1412        assert!(ctx.contains("n"));
1413        assert!(ctx.get("n").unwrap().is_node);
1414    }
1415
1416    #[test]
1417    fn test_bind_undefined_variable() {
1418        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1419            items: vec![ReturnItem {
1420                expression: LogicalExpression::Variable("undefined".to_string()),
1421                alias: None,
1422            }],
1423            distinct: false,
1424            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1425                variable: "n".to_string(),
1426                label: None,
1427                input: None,
1428            })),
1429        }));
1430
1431        let mut binder = Binder::new();
1432        let result = binder.bind(&plan);
1433
1434        assert!(result.is_err());
1435        let err = result.unwrap_err();
1436        assert!(err.to_string().contains("Undefined variable"));
1437    }
1438
1439    #[test]
1440    fn test_bind_property_access() {
1441        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1442            items: vec![ReturnItem {
1443                expression: LogicalExpression::Property {
1444                    variable: "n".to_string(),
1445                    property: "name".to_string(),
1446                },
1447                alias: None,
1448            }],
1449            distinct: false,
1450            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1451                variable: "n".to_string(),
1452                label: Some("Person".to_string()),
1453                input: None,
1454            })),
1455        }));
1456
1457        let mut binder = Binder::new();
1458        let result = binder.bind(&plan);
1459
1460        assert!(result.is_ok());
1461    }
1462
1463    #[test]
1464    fn test_bind_filter_with_undefined_variable() {
1465        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1466            items: vec![ReturnItem {
1467                expression: LogicalExpression::Variable("n".to_string()),
1468                alias: None,
1469            }],
1470            distinct: false,
1471            input: Box::new(LogicalOperator::Filter(FilterOp {
1472                predicate: LogicalExpression::Binary {
1473                    left: Box::new(LogicalExpression::Property {
1474                        variable: "m".to_string(), // undefined!
1475                        property: "age".to_string(),
1476                    }),
1477                    op: BinaryOp::Gt,
1478                    right: Box::new(LogicalExpression::Literal(
1479                        grafeo_common::types::Value::Int64(30),
1480                    )),
1481                },
1482                input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1483                    variable: "n".to_string(),
1484                    label: None,
1485                    input: None,
1486                })),
1487                pushdown_hint: None,
1488            })),
1489        }));
1490
1491        let mut binder = Binder::new();
1492        let result = binder.bind(&plan);
1493
1494        assert!(result.is_err());
1495        let err = result.unwrap_err();
1496        assert!(err.to_string().contains("Undefined variable 'm'"));
1497    }
1498
1499    #[test]
1500    fn test_bind_expand() {
1501        use crate::query::plan::{ExpandDirection, ExpandOp, PathMode};
1502
1503        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1504            items: vec![
1505                ReturnItem {
1506                    expression: LogicalExpression::Variable("a".to_string()),
1507                    alias: None,
1508                },
1509                ReturnItem {
1510                    expression: LogicalExpression::Variable("b".to_string()),
1511                    alias: None,
1512                },
1513            ],
1514            distinct: false,
1515            input: Box::new(LogicalOperator::Expand(ExpandOp {
1516                from_variable: "a".to_string(),
1517                to_variable: "b".to_string(),
1518                edge_variable: Some("e".to_string()),
1519                direction: ExpandDirection::Outgoing,
1520                edge_types: vec!["KNOWS".to_string()],
1521                min_hops: 1,
1522                max_hops: Some(1),
1523                input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1524                    variable: "a".to_string(),
1525                    label: Some("Person".to_string()),
1526                    input: None,
1527                })),
1528                path_alias: None,
1529                path_mode: PathMode::Walk,
1530            })),
1531        }));
1532
1533        let mut binder = Binder::new();
1534        let result = binder.bind(&plan);
1535
1536        assert!(result.is_ok());
1537        let ctx = result.unwrap();
1538        assert!(ctx.contains("a"));
1539        assert!(ctx.contains("b"));
1540        assert!(ctx.contains("e"));
1541        assert!(ctx.get("a").unwrap().is_node);
1542        assert!(ctx.get("b").unwrap().is_node);
1543        assert!(ctx.get("e").unwrap().is_edge);
1544    }
1545
1546    #[test]
1547    fn test_bind_expand_from_undefined_variable() {
1548        // Tests that expanding from an undefined variable produces a clear error
1549        use crate::query::plan::{ExpandDirection, ExpandOp, PathMode};
1550
1551        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1552            items: vec![ReturnItem {
1553                expression: LogicalExpression::Variable("b".to_string()),
1554                alias: None,
1555            }],
1556            distinct: false,
1557            input: Box::new(LogicalOperator::Expand(ExpandOp {
1558                from_variable: "undefined".to_string(), // not defined!
1559                to_variable: "b".to_string(),
1560                edge_variable: None,
1561                direction: ExpandDirection::Outgoing,
1562                edge_types: vec![],
1563                min_hops: 1,
1564                max_hops: Some(1),
1565                input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1566                    variable: "a".to_string(),
1567                    label: None,
1568                    input: None,
1569                })),
1570                path_alias: None,
1571                path_mode: PathMode::Walk,
1572            })),
1573        }));
1574
1575        let mut binder = Binder::new();
1576        let result = binder.bind(&plan);
1577
1578        assert!(result.is_err());
1579        let err = result.unwrap_err();
1580        assert!(
1581            err.to_string().contains("Undefined variable 'undefined'"),
1582            "Expected error about undefined variable, got: {}",
1583            err
1584        );
1585    }
1586
1587    #[test]
1588    fn test_bind_return_with_aggregate_and_non_aggregate() {
1589        // Tests binding of aggregate functions alongside regular expressions
1590        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1591            items: vec![
1592                ReturnItem {
1593                    expression: LogicalExpression::FunctionCall {
1594                        name: "count".to_string(),
1595                        args: vec![LogicalExpression::Variable("n".to_string())],
1596                        distinct: false,
1597                    },
1598                    alias: Some("cnt".to_string()),
1599                },
1600                ReturnItem {
1601                    expression: LogicalExpression::Literal(grafeo_common::types::Value::Int64(1)),
1602                    alias: Some("one".to_string()),
1603                },
1604            ],
1605            distinct: false,
1606            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1607                variable: "n".to_string(),
1608                label: Some("Person".to_string()),
1609                input: None,
1610            })),
1611        }));
1612
1613        let mut binder = Binder::new();
1614        let result = binder.bind(&plan);
1615
1616        // This should succeed - count(n) with literal is valid
1617        assert!(result.is_ok());
1618    }
1619
1620    #[test]
1621    fn test_bind_nested_property_access() {
1622        // Tests that nested property access on the same variable works
1623        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1624            items: vec![
1625                ReturnItem {
1626                    expression: LogicalExpression::Property {
1627                        variable: "n".to_string(),
1628                        property: "name".to_string(),
1629                    },
1630                    alias: None,
1631                },
1632                ReturnItem {
1633                    expression: LogicalExpression::Property {
1634                        variable: "n".to_string(),
1635                        property: "age".to_string(),
1636                    },
1637                    alias: None,
1638                },
1639            ],
1640            distinct: false,
1641            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1642                variable: "n".to_string(),
1643                label: Some("Person".to_string()),
1644                input: None,
1645            })),
1646        }));
1647
1648        let mut binder = Binder::new();
1649        let result = binder.bind(&plan);
1650
1651        assert!(result.is_ok());
1652    }
1653
1654    #[test]
1655    fn test_bind_binary_expression_with_undefined() {
1656        // Tests that binary expressions with undefined variables produce errors
1657        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1658            items: vec![ReturnItem {
1659                expression: LogicalExpression::Binary {
1660                    left: Box::new(LogicalExpression::Property {
1661                        variable: "n".to_string(),
1662                        property: "age".to_string(),
1663                    }),
1664                    op: BinaryOp::Add,
1665                    right: Box::new(LogicalExpression::Property {
1666                        variable: "m".to_string(), // undefined!
1667                        property: "age".to_string(),
1668                    }),
1669                },
1670                alias: Some("total".to_string()),
1671            }],
1672            distinct: false,
1673            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1674                variable: "n".to_string(),
1675                label: None,
1676                input: None,
1677            })),
1678        }));
1679
1680        let mut binder = Binder::new();
1681        let result = binder.bind(&plan);
1682
1683        assert!(result.is_err());
1684        assert!(
1685            result
1686                .unwrap_err()
1687                .to_string()
1688                .contains("Undefined variable 'm'")
1689        );
1690    }
1691
1692    #[test]
1693    fn test_bind_duplicate_variable_definition() {
1694        // Tests behavior when the same variable is defined twice (via two NodeScans)
1695        // This is typically not allowed or the second shadows the first
1696        use crate::query::plan::{JoinOp, JoinType};
1697
1698        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1699            items: vec![ReturnItem {
1700                expression: LogicalExpression::Variable("n".to_string()),
1701                alias: None,
1702            }],
1703            distinct: false,
1704            input: Box::new(LogicalOperator::Join(JoinOp {
1705                left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1706                    variable: "n".to_string(),
1707                    label: Some("A".to_string()),
1708                    input: None,
1709                })),
1710                right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1711                    variable: "m".to_string(), // different variable is fine
1712                    label: Some("B".to_string()),
1713                    input: None,
1714                })),
1715                join_type: JoinType::Inner,
1716                conditions: vec![],
1717            })),
1718        }));
1719
1720        let mut binder = Binder::new();
1721        let result = binder.bind(&plan);
1722
1723        // Join with different variables should work
1724        assert!(result.is_ok());
1725        let ctx = result.unwrap();
1726        assert!(ctx.contains("n"));
1727        assert!(ctx.contains("m"));
1728    }
1729
1730    #[test]
1731    fn test_bind_function_with_wrong_arity() {
1732        // Tests that functions with wrong number of arguments are handled
1733        // (behavior depends on whether binder validates arity)
1734        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1735            items: vec![ReturnItem {
1736                expression: LogicalExpression::FunctionCall {
1737                    name: "count".to_string(),
1738                    args: vec![], // count() needs an argument
1739                    distinct: false,
1740                },
1741                alias: None,
1742            }],
1743            distinct: false,
1744            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1745                variable: "n".to_string(),
1746                label: None,
1747                input: None,
1748            })),
1749        }));
1750
1751        let mut binder = Binder::new();
1752        let result = binder.bind(&plan);
1753
1754        // The binder may or may not catch this - if it passes, execution will fail
1755        // This test documents current behavior
1756        // If binding fails, that's fine; if it passes, execution will handle it
1757        let _ = result; // We're just testing it doesn't panic
1758    }
1759
1760    // --- Mutation operator validation ---
1761
1762    #[test]
1763    fn test_create_edge_rejects_undefined_source() {
1764        use crate::query::plan::CreateEdgeOp;
1765
1766        let plan = LogicalPlan::new(LogicalOperator::CreateEdge(CreateEdgeOp {
1767            variable: Some("e".to_string()),
1768            from_variable: "ghost".to_string(), // not defined!
1769            to_variable: "b".to_string(),
1770            edge_type: "KNOWS".to_string(),
1771            properties: vec![],
1772            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1773                variable: "b".to_string(),
1774                label: None,
1775                input: None,
1776            })),
1777        }));
1778
1779        let mut binder = Binder::new();
1780        let err = binder.bind(&plan).unwrap_err();
1781        assert!(
1782            err.to_string().contains("Undefined variable 'ghost'"),
1783            "Should reject undefined source variable, got: {err}"
1784        );
1785    }
1786
1787    #[test]
1788    fn test_create_edge_rejects_undefined_target() {
1789        use crate::query::plan::CreateEdgeOp;
1790
1791        let plan = LogicalPlan::new(LogicalOperator::CreateEdge(CreateEdgeOp {
1792            variable: None,
1793            from_variable: "a".to_string(),
1794            to_variable: "missing".to_string(), // not defined!
1795            edge_type: "KNOWS".to_string(),
1796            properties: vec![],
1797            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1798                variable: "a".to_string(),
1799                label: None,
1800                input: None,
1801            })),
1802        }));
1803
1804        let mut binder = Binder::new();
1805        let err = binder.bind(&plan).unwrap_err();
1806        assert!(
1807            err.to_string().contains("Undefined variable 'missing'"),
1808            "Should reject undefined target variable, got: {err}"
1809        );
1810    }
1811
1812    #[test]
1813    fn test_create_edge_validates_property_expressions() {
1814        use crate::query::plan::CreateEdgeOp;
1815
1816        // Source and target defined, but property references undefined variable
1817        let plan = LogicalPlan::new(LogicalOperator::CreateEdge(CreateEdgeOp {
1818            variable: Some("e".to_string()),
1819            from_variable: "a".to_string(),
1820            to_variable: "b".to_string(),
1821            edge_type: "KNOWS".to_string(),
1822            properties: vec![(
1823                "since".to_string(),
1824                LogicalExpression::Property {
1825                    variable: "x".to_string(), // undefined!
1826                    property: "year".to_string(),
1827                },
1828            )],
1829            input: Box::new(LogicalOperator::Join(crate::query::plan::JoinOp {
1830                left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1831                    variable: "a".to_string(),
1832                    label: None,
1833                    input: None,
1834                })),
1835                right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1836                    variable: "b".to_string(),
1837                    label: None,
1838                    input: None,
1839                })),
1840                join_type: crate::query::plan::JoinType::Inner,
1841                conditions: vec![],
1842            })),
1843        }));
1844
1845        let mut binder = Binder::new();
1846        let err = binder.bind(&plan).unwrap_err();
1847        assert!(err.to_string().contains("Undefined variable 'x'"));
1848    }
1849
1850    #[test]
1851    fn test_set_property_rejects_undefined_variable() {
1852        use crate::query::plan::SetPropertyOp;
1853
1854        let plan = LogicalPlan::new(LogicalOperator::SetProperty(SetPropertyOp {
1855            variable: "ghost".to_string(),
1856            properties: vec![(
1857                "name".to_string(),
1858                LogicalExpression::Literal(grafeo_common::types::Value::String("Alix".into())),
1859            )],
1860            replace: false,
1861            is_edge: false,
1862            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1863                variable: "n".to_string(),
1864                label: None,
1865                input: None,
1866            })),
1867        }));
1868
1869        let mut binder = Binder::new();
1870        let err = binder.bind(&plan).unwrap_err();
1871        assert!(
1872            err.to_string().contains("in SET"),
1873            "Error should indicate SET context, got: {err}"
1874        );
1875    }
1876
1877    #[test]
1878    fn test_delete_node_rejects_undefined_variable() {
1879        use crate::query::plan::DeleteNodeOp;
1880
1881        let plan = LogicalPlan::new(LogicalOperator::DeleteNode(DeleteNodeOp {
1882            variable: "phantom".to_string(),
1883            detach: false,
1884            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1885                variable: "n".to_string(),
1886                label: None,
1887                input: None,
1888            })),
1889        }));
1890
1891        let mut binder = Binder::new();
1892        let err = binder.bind(&plan).unwrap_err();
1893        assert!(err.to_string().contains("Undefined variable 'phantom'"));
1894    }
1895
1896    #[test]
1897    fn test_delete_edge_rejects_undefined_variable() {
1898        use crate::query::plan::DeleteEdgeOp;
1899
1900        let plan = LogicalPlan::new(LogicalOperator::DeleteEdge(DeleteEdgeOp {
1901            variable: "gone".to_string(),
1902            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1903                variable: "n".to_string(),
1904                label: None,
1905                input: None,
1906            })),
1907        }));
1908
1909        let mut binder = Binder::new();
1910        let err = binder.bind(&plan).unwrap_err();
1911        assert!(err.to_string().contains("Undefined variable 'gone'"));
1912    }
1913
1914    // --- WITH/Project clause ---
1915
1916    #[test]
1917    fn test_project_alias_becomes_available_downstream() {
1918        use crate::query::plan::{ProjectOp, Projection};
1919
1920        // WITH n.name AS person_name RETURN person_name
1921        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1922            items: vec![ReturnItem {
1923                expression: LogicalExpression::Variable("person_name".to_string()),
1924                alias: None,
1925            }],
1926            distinct: false,
1927            input: Box::new(LogicalOperator::Project(ProjectOp {
1928                projections: vec![Projection {
1929                    expression: LogicalExpression::Property {
1930                        variable: "n".to_string(),
1931                        property: "name".to_string(),
1932                    },
1933                    alias: Some("person_name".to_string()),
1934                }],
1935                input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
1936                    variable: "n".to_string(),
1937                    label: None,
1938                    input: None,
1939                })),
1940                pass_through_input: false,
1941            })),
1942        }));
1943
1944        let mut binder = Binder::new();
1945        let ctx = binder.bind(&plan).unwrap();
1946        assert!(
1947            ctx.contains("person_name"),
1948            "WITH alias should be available to RETURN"
1949        );
1950    }
1951
1952    #[test]
1953    fn test_project_rejects_undefined_expression() {
1954        use crate::query::plan::{ProjectOp, Projection};
1955
1956        let plan = LogicalPlan::new(LogicalOperator::Project(ProjectOp {
1957            projections: vec![Projection {
1958                expression: LogicalExpression::Variable("nope".to_string()),
1959                alias: Some("x".to_string()),
1960            }],
1961            input: Box::new(LogicalOperator::Empty),
1962            pass_through_input: false,
1963        }));
1964
1965        let mut binder = Binder::new();
1966        let result = binder.bind(&plan);
1967        assert!(result.is_err(), "WITH on undefined variable should fail");
1968    }
1969
1970    // --- UNWIND ---
1971
1972    #[test]
1973    fn test_unwind_adds_element_variable() {
1974        use crate::query::plan::UnwindOp;
1975
1976        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
1977            items: vec![ReturnItem {
1978                expression: LogicalExpression::Variable("item".to_string()),
1979                alias: None,
1980            }],
1981            distinct: false,
1982            input: Box::new(LogicalOperator::Unwind(UnwindOp {
1983                expression: LogicalExpression::List(vec![
1984                    LogicalExpression::Literal(grafeo_common::types::Value::Int64(1)),
1985                    LogicalExpression::Literal(grafeo_common::types::Value::Int64(2)),
1986                ]),
1987                variable: "item".to_string(),
1988                ordinality_var: None,
1989                offset_var: None,
1990                input: Box::new(LogicalOperator::Empty),
1991            })),
1992        }));
1993
1994        let mut binder = Binder::new();
1995        let ctx = binder.bind(&plan).unwrap();
1996        assert!(ctx.contains("item"), "UNWIND variable should be in scope");
1997        let info = ctx.get("item").unwrap();
1998        assert!(
1999            !info.is_node && !info.is_edge,
2000            "UNWIND variable is not a graph element"
2001        );
2002    }
2003
2004    // --- MERGE ---
2005
2006    #[test]
2007    fn test_merge_adds_variable_and_validates_properties() {
2008        use crate::query::plan::MergeOp;
2009
2010        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2011            items: vec![ReturnItem {
2012                expression: LogicalExpression::Variable("m".to_string()),
2013                alias: None,
2014            }],
2015            distinct: false,
2016            input: Box::new(LogicalOperator::Merge(MergeOp {
2017                variable: "m".to_string(),
2018                labels: vec!["Person".to_string()],
2019                match_properties: vec![(
2020                    "name".to_string(),
2021                    LogicalExpression::Literal(grafeo_common::types::Value::String("Alix".into())),
2022                )],
2023                on_create: vec![(
2024                    "created".to_string(),
2025                    LogicalExpression::Literal(grafeo_common::types::Value::Bool(true)),
2026                )],
2027                on_match: vec![(
2028                    "updated".to_string(),
2029                    LogicalExpression::Literal(grafeo_common::types::Value::Bool(true)),
2030                )],
2031                input: Box::new(LogicalOperator::Empty),
2032            })),
2033        }));
2034
2035        let mut binder = Binder::new();
2036        let ctx = binder.bind(&plan).unwrap();
2037        assert!(ctx.contains("m"));
2038        assert!(
2039            ctx.get("m").unwrap().is_node,
2040            "MERGE variable should be a node"
2041        );
2042    }
2043
2044    #[test]
2045    fn test_merge_rejects_undefined_in_on_create() {
2046        use crate::query::plan::MergeOp;
2047
2048        let plan = LogicalPlan::new(LogicalOperator::Merge(MergeOp {
2049            variable: "m".to_string(),
2050            labels: vec![],
2051            match_properties: vec![],
2052            on_create: vec![(
2053                "name".to_string(),
2054                LogicalExpression::Property {
2055                    variable: "other".to_string(), // undefined!
2056                    property: "name".to_string(),
2057                },
2058            )],
2059            on_match: vec![],
2060            input: Box::new(LogicalOperator::Empty),
2061        }));
2062
2063        let mut binder = Binder::new();
2064        let result = binder.bind(&plan);
2065        assert!(
2066            result.is_err(),
2067            "ON CREATE referencing undefined variable should fail"
2068        );
2069    }
2070
2071    // --- ShortestPath ---
2072
2073    #[test]
2074    fn test_shortest_path_rejects_undefined_source() {
2075        use crate::query::plan::{ExpandDirection, ShortestPathOp};
2076
2077        let plan = LogicalPlan::new(LogicalOperator::ShortestPath(ShortestPathOp {
2078            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2079                variable: "b".to_string(),
2080                label: None,
2081                input: None,
2082            })),
2083            source_var: "missing".to_string(), // not defined
2084            target_var: "b".to_string(),
2085            edge_types: vec![],
2086            direction: ExpandDirection::Both,
2087            path_alias: "p".to_string(),
2088            all_paths: false,
2089        }));
2090
2091        let mut binder = Binder::new();
2092        let err = binder.bind(&plan).unwrap_err();
2093        assert!(
2094            err.to_string().contains("source in shortestPath"),
2095            "Error should mention shortestPath source context, got: {err}"
2096        );
2097    }
2098
2099    #[test]
2100    fn test_shortest_path_adds_path_and_length_variables() {
2101        use crate::query::plan::{ExpandDirection, JoinOp, JoinType, ShortestPathOp};
2102
2103        let plan = LogicalPlan::new(LogicalOperator::ShortestPath(ShortestPathOp {
2104            input: Box::new(LogicalOperator::Join(JoinOp {
2105                left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2106                    variable: "a".to_string(),
2107                    label: None,
2108                    input: None,
2109                })),
2110                right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2111                    variable: "b".to_string(),
2112                    label: None,
2113                    input: None,
2114                })),
2115                join_type: JoinType::Cross,
2116                conditions: vec![],
2117            })),
2118            source_var: "a".to_string(),
2119            target_var: "b".to_string(),
2120            edge_types: vec!["ROAD".to_string()],
2121            direction: ExpandDirection::Outgoing,
2122            path_alias: "p".to_string(),
2123            all_paths: false,
2124        }));
2125
2126        let mut binder = Binder::new();
2127        let ctx = binder.bind(&plan).unwrap();
2128        assert!(ctx.contains("p"), "Path alias should be bound");
2129        assert!(
2130            ctx.contains("_path_length_p"),
2131            "Path length variable should be auto-created"
2132        );
2133    }
2134
2135    // --- Expression validation edge cases ---
2136
2137    #[test]
2138    fn test_case_expression_validates_all_branches() {
2139        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2140            items: vec![ReturnItem {
2141                expression: LogicalExpression::Case {
2142                    operand: None,
2143                    when_clauses: vec![
2144                        (
2145                            LogicalExpression::Binary {
2146                                left: Box::new(LogicalExpression::Property {
2147                                    variable: "n".to_string(),
2148                                    property: "age".to_string(),
2149                                }),
2150                                op: BinaryOp::Gt,
2151                                right: Box::new(LogicalExpression::Literal(
2152                                    grafeo_common::types::Value::Int64(18),
2153                                )),
2154                            },
2155                            LogicalExpression::Literal(grafeo_common::types::Value::String(
2156                                "adult".into(),
2157                            )),
2158                        ),
2159                        (
2160                            // This branch references undefined variable
2161                            LogicalExpression::Property {
2162                                variable: "ghost".to_string(),
2163                                property: "flag".to_string(),
2164                            },
2165                            LogicalExpression::Literal(grafeo_common::types::Value::String(
2166                                "flagged".into(),
2167                            )),
2168                        ),
2169                    ],
2170                    else_clause: Some(Box::new(LogicalExpression::Literal(
2171                        grafeo_common::types::Value::String("other".into()),
2172                    ))),
2173                },
2174                alias: None,
2175            }],
2176            distinct: false,
2177            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2178                variable: "n".to_string(),
2179                label: None,
2180                input: None,
2181            })),
2182        }));
2183
2184        let mut binder = Binder::new();
2185        let err = binder.bind(&plan).unwrap_err();
2186        assert!(
2187            err.to_string().contains("ghost"),
2188            "CASE should validate all when-clause conditions"
2189        );
2190    }
2191
2192    #[test]
2193    fn test_case_expression_validates_else_clause() {
2194        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2195            items: vec![ReturnItem {
2196                expression: LogicalExpression::Case {
2197                    operand: None,
2198                    when_clauses: vec![(
2199                        LogicalExpression::Literal(grafeo_common::types::Value::Bool(true)),
2200                        LogicalExpression::Literal(grafeo_common::types::Value::Int64(1)),
2201                    )],
2202                    else_clause: Some(Box::new(LogicalExpression::Property {
2203                        variable: "missing".to_string(),
2204                        property: "x".to_string(),
2205                    })),
2206                },
2207                alias: None,
2208            }],
2209            distinct: false,
2210            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2211                variable: "n".to_string(),
2212                label: None,
2213                input: None,
2214            })),
2215        }));
2216
2217        let mut binder = Binder::new();
2218        let err = binder.bind(&plan).unwrap_err();
2219        assert!(
2220            err.to_string().contains("missing"),
2221            "CASE ELSE should validate its expression too"
2222        );
2223    }
2224
2225    #[test]
2226    fn test_slice_access_validates_expressions() {
2227        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2228            items: vec![ReturnItem {
2229                expression: LogicalExpression::SliceAccess {
2230                    base: Box::new(LogicalExpression::Variable("n".to_string())),
2231                    start: Some(Box::new(LogicalExpression::Variable(
2232                        "undefined_start".to_string(),
2233                    ))),
2234                    end: None,
2235                },
2236                alias: None,
2237            }],
2238            distinct: false,
2239            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2240                variable: "n".to_string(),
2241                label: None,
2242                input: None,
2243            })),
2244        }));
2245
2246        let mut binder = Binder::new();
2247        let err = binder.bind(&plan).unwrap_err();
2248        assert!(err.to_string().contains("undefined_start"));
2249    }
2250
2251    #[test]
2252    fn test_list_comprehension_validates_list_source() {
2253        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2254            items: vec![ReturnItem {
2255                expression: LogicalExpression::ListComprehension {
2256                    variable: "x".to_string(),
2257                    list_expr: Box::new(LogicalExpression::Variable("not_defined".to_string())),
2258                    filter_expr: None,
2259                    map_expr: Box::new(LogicalExpression::Variable("x".to_string())),
2260                },
2261                alias: None,
2262            }],
2263            distinct: false,
2264            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2265                variable: "n".to_string(),
2266                label: None,
2267                input: None,
2268            })),
2269        }));
2270
2271        let mut binder = Binder::new();
2272        let err = binder.bind(&plan).unwrap_err();
2273        assert!(
2274            err.to_string().contains("not_defined"),
2275            "List comprehension should validate source list expression"
2276        );
2277    }
2278
2279    #[test]
2280    fn test_labels_type_id_reject_undefined() {
2281        // labels(x) where x is not defined
2282        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2283            items: vec![ReturnItem {
2284                expression: LogicalExpression::Labels("x".to_string()),
2285                alias: None,
2286            }],
2287            distinct: false,
2288            input: Box::new(LogicalOperator::Empty),
2289        }));
2290
2291        let mut binder = Binder::new();
2292        assert!(
2293            binder.bind(&plan).is_err(),
2294            "labels(x) on undefined x should fail"
2295        );
2296
2297        // type(e) where e is not defined
2298        let plan2 = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2299            items: vec![ReturnItem {
2300                expression: LogicalExpression::Type("e".to_string()),
2301                alias: None,
2302            }],
2303            distinct: false,
2304            input: Box::new(LogicalOperator::Empty),
2305        }));
2306
2307        let mut binder2 = Binder::new();
2308        assert!(
2309            binder2.bind(&plan2).is_err(),
2310            "type(e) on undefined e should fail"
2311        );
2312
2313        // id(n) where n is not defined
2314        let plan3 = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2315            items: vec![ReturnItem {
2316                expression: LogicalExpression::Id("n".to_string()),
2317                alias: None,
2318            }],
2319            distinct: false,
2320            input: Box::new(LogicalOperator::Empty),
2321        }));
2322
2323        let mut binder3 = Binder::new();
2324        assert!(
2325            binder3.bind(&plan3).is_err(),
2326            "id(n) on undefined n should fail"
2327        );
2328    }
2329
2330    #[test]
2331    fn test_expand_rejects_non_node_source() {
2332        use crate::query::plan::{ExpandDirection, ExpandOp, PathMode, UnwindOp};
2333
2334        // UNWIND [1,2] AS x  -- x is not a node
2335        // MATCH (x)-[:E]->(b)  -- should fail: x isn't a node
2336        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2337            items: vec![ReturnItem {
2338                expression: LogicalExpression::Variable("b".to_string()),
2339                alias: None,
2340            }],
2341            distinct: false,
2342            input: Box::new(LogicalOperator::Expand(ExpandOp {
2343                from_variable: "x".to_string(),
2344                to_variable: "b".to_string(),
2345                edge_variable: None,
2346                direction: ExpandDirection::Outgoing,
2347                edge_types: vec![],
2348                min_hops: 1,
2349                max_hops: Some(1),
2350                input: Box::new(LogicalOperator::Unwind(UnwindOp {
2351                    expression: LogicalExpression::List(vec![]),
2352                    variable: "x".to_string(),
2353                    ordinality_var: None,
2354                    offset_var: None,
2355                    input: Box::new(LogicalOperator::Empty),
2356                })),
2357                path_alias: None,
2358                path_mode: PathMode::Walk,
2359            })),
2360        }));
2361
2362        let mut binder = Binder::new();
2363        let err = binder.bind(&plan).unwrap_err();
2364        assert!(
2365            err.to_string().contains("not a node"),
2366            "Expanding from non-node should fail, got: {err}"
2367        );
2368    }
2369
2370    #[test]
2371    fn test_add_label_rejects_undefined_variable() {
2372        use crate::query::plan::AddLabelOp;
2373
2374        let plan = LogicalPlan::new(LogicalOperator::AddLabel(AddLabelOp {
2375            variable: "missing".to_string(),
2376            labels: vec!["Admin".to_string()],
2377            input: Box::new(LogicalOperator::Empty),
2378        }));
2379
2380        let mut binder = Binder::new();
2381        let err = binder.bind(&plan).unwrap_err();
2382        assert!(err.to_string().contains("SET labels"));
2383    }
2384
2385    #[test]
2386    fn test_remove_label_rejects_undefined_variable() {
2387        use crate::query::plan::RemoveLabelOp;
2388
2389        let plan = LogicalPlan::new(LogicalOperator::RemoveLabel(RemoveLabelOp {
2390            variable: "missing".to_string(),
2391            labels: vec!["Admin".to_string()],
2392            input: Box::new(LogicalOperator::Empty),
2393        }));
2394
2395        let mut binder = Binder::new();
2396        let err = binder.bind(&plan).unwrap_err();
2397        assert!(err.to_string().contains("REMOVE labels"));
2398    }
2399
2400    #[test]
2401    fn test_sort_validates_key_expressions() {
2402        use crate::query::plan::{SortKey, SortOp, SortOrder};
2403
2404        let plan = LogicalPlan::new(LogicalOperator::Sort(SortOp {
2405            keys: vec![SortKey {
2406                expression: LogicalExpression::Property {
2407                    variable: "missing".to_string(),
2408                    property: "name".to_string(),
2409                },
2410                order: SortOrder::Ascending,
2411                nulls: None,
2412            }],
2413            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2414                variable: "n".to_string(),
2415                label: None,
2416                input: None,
2417            })),
2418        }));
2419
2420        let mut binder = Binder::new();
2421        assert!(
2422            binder.bind(&plan).is_err(),
2423            "ORDER BY on undefined variable should fail"
2424        );
2425    }
2426
2427    #[test]
2428    fn test_create_node_adds_variable_before_property_validation() {
2429        use crate::query::plan::CreateNodeOp;
2430
2431        // CREATE (n:Person {friend: n.name}) - referencing the node being created
2432        // The variable should be available for property expressions (self-reference)
2433        let plan = LogicalPlan::new(LogicalOperator::CreateNode(CreateNodeOp {
2434            variable: "n".to_string(),
2435            labels: vec!["Person".to_string()],
2436            properties: vec![(
2437                "self_ref".to_string(),
2438                LogicalExpression::Property {
2439                    variable: "n".to_string(),
2440                    property: "name".to_string(),
2441                },
2442            )],
2443            input: None,
2444        }));
2445
2446        let mut binder = Binder::new();
2447        // This should succeed because CreateNode adds the variable before validating properties
2448        let ctx = binder.bind(&plan).unwrap();
2449        assert!(ctx.get("n").unwrap().is_node);
2450    }
2451
2452    #[test]
2453    fn test_undefined_variable_suggests_similar() {
2454        // 'person' is defined, user types 'persn' - should get a suggestion
2455        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2456            items: vec![ReturnItem {
2457                expression: LogicalExpression::Variable("persn".to_string()),
2458                alias: None,
2459            }],
2460            distinct: false,
2461            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2462                variable: "person".to_string(),
2463                label: None,
2464                input: None,
2465            })),
2466        }));
2467
2468        let mut binder = Binder::new();
2469        let err = binder.bind(&plan).unwrap_err();
2470        let msg = err.to_string();
2471        // The error should contain the variable name at minimum
2472        assert!(
2473            msg.contains("persn"),
2474            "Error should mention the undefined variable"
2475        );
2476    }
2477
2478    #[test]
2479    fn test_anon_variables_skip_validation() {
2480        // Variables starting with _anon_ are anonymous and should be silently accepted
2481        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2482            items: vec![ReturnItem {
2483                expression: LogicalExpression::Variable("_anon_42".to_string()),
2484                alias: None,
2485            }],
2486            distinct: false,
2487            input: Box::new(LogicalOperator::Empty),
2488        }));
2489
2490        let mut binder = Binder::new();
2491        let result = binder.bind(&plan);
2492        assert!(
2493            result.is_ok(),
2494            "Anonymous variables should bypass validation"
2495        );
2496    }
2497
2498    #[test]
2499    fn test_map_expression_validates_values() {
2500        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2501            items: vec![ReturnItem {
2502                expression: LogicalExpression::Map(vec![(
2503                    "key".to_string(),
2504                    LogicalExpression::Variable("undefined".to_string()),
2505                )]),
2506                alias: None,
2507            }],
2508            distinct: false,
2509            input: Box::new(LogicalOperator::Empty),
2510        }));
2511
2512        let mut binder = Binder::new();
2513        assert!(
2514            binder.bind(&plan).is_err(),
2515            "Map values should be validated"
2516        );
2517    }
2518
2519    #[test]
2520    fn test_vector_scan_validates_query_vector() {
2521        use crate::query::plan::VectorScanOp;
2522
2523        let plan = LogicalPlan::new(LogicalOperator::VectorScan(VectorScanOp {
2524            variable: "result".to_string(),
2525            index_name: None,
2526            property: "embedding".to_string(),
2527            label: Some("Doc".to_string()),
2528            query_vector: LogicalExpression::Variable("undefined_vec".to_string()),
2529            k: Some(10),
2530            metric: None,
2531            min_similarity: None,
2532            max_distance: None,
2533            input: None,
2534        }));
2535
2536        let mut binder = Binder::new();
2537        let err = binder.bind(&plan).unwrap_err();
2538        assert!(err.to_string().contains("undefined_vec"));
2539    }
2540
2541    /// UNWIND with ORDINALITY and OFFSET binds three variables: the element
2542    /// (Any), the 1-based ORDINALITY index (Int64), and the 0-based OFFSET
2543    /// index (Int64).
2544    #[test]
2545    fn test_bind_unwind_ordinality_and_offset() {
2546        use crate::query::plan::UnwindOp;
2547
2548        let plan = LogicalPlan::new(LogicalOperator::Unwind(UnwindOp {
2549            expression: LogicalExpression::List(vec![
2550                LogicalExpression::Literal(grafeo_common::types::Value::Int64(1)),
2551                LogicalExpression::Literal(grafeo_common::types::Value::Int64(2)),
2552                LogicalExpression::Literal(grafeo_common::types::Value::Int64(3)),
2553            ]),
2554            variable: "x".to_string(),
2555            ordinality_var: Some("i".to_string()),
2556            offset_var: Some("j".to_string()),
2557            input: Box::new(LogicalOperator::Empty),
2558        }));
2559
2560        let mut binder = Binder::new();
2561        let ctx = binder.bind(&plan).unwrap();
2562
2563        assert!(ctx.contains("x"));
2564        assert!(ctx.contains("i"));
2565        assert!(ctx.contains("j"));
2566        // ORDINALITY and OFFSET are both typed Int64.
2567        assert_eq!(ctx.get("i").unwrap().data_type, LogicalType::Int64);
2568        assert_eq!(ctx.get("j").unwrap().data_type, LogicalType::Int64);
2569        // Element variable is Any (could be any list-element type).
2570        assert_eq!(ctx.get("x").unwrap().data_type, LogicalType::Any);
2571        // None of the three is a node or edge.
2572        for v in ["x", "i", "j"] {
2573            let info = ctx.get(v).unwrap();
2574            assert!(!info.is_node);
2575            assert!(!info.is_edge);
2576        }
2577    }
2578
2579    /// MERGE on a relationship pattern validates its source/target variables
2580    /// and registers the edge variable. Match-property expressions are validated.
2581    #[test]
2582    fn test_bind_merge_relationship_properties() {
2583        use crate::query::plan::{JoinOp, JoinType, MergeRelationshipOp};
2584
2585        // Build a plan with two bound nodes (a, b) via a cross-join NodeScans,
2586        // then MERGE a WORKS relationship between them with a `start` property.
2587        let plan = LogicalPlan::new(LogicalOperator::MergeRelationship(MergeRelationshipOp {
2588            variable: "r".to_string(),
2589            source_variable: "a".to_string(),
2590            target_variable: "b".to_string(),
2591            edge_type: "WORKS".to_string(),
2592            match_properties: vec![(
2593                "start".to_string(),
2594                LogicalExpression::Literal(grafeo_common::types::Value::Int64(2026)),
2595            )],
2596            on_create: vec![],
2597            on_match: vec![],
2598            input: Box::new(LogicalOperator::Join(JoinOp {
2599                left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2600                    variable: "a".to_string(),
2601                    label: Some("Person".to_string()),
2602                    input: None,
2603                })),
2604                right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2605                    variable: "b".to_string(),
2606                    label: Some("Company".to_string()),
2607                    input: None,
2608                })),
2609                join_type: JoinType::Cross,
2610                conditions: vec![],
2611            })),
2612        }));
2613
2614        let mut binder = Binder::new();
2615        let ctx = binder.bind(&plan).unwrap();
2616        assert!(ctx.contains("a"));
2617        assert!(ctx.contains("b"));
2618        assert!(ctx.contains("r"));
2619        let rel = ctx.get("r").unwrap();
2620        assert!(rel.is_edge);
2621        assert!(!rel.is_node);
2622        assert_eq!(rel.data_type, LogicalType::Edge);
2623
2624        // Referencing an undefined variable in a match-property should fail.
2625        let bad_plan = LogicalPlan::new(LogicalOperator::MergeRelationship(MergeRelationshipOp {
2626            variable: "r".to_string(),
2627            source_variable: "a".to_string(),
2628            target_variable: "b".to_string(),
2629            edge_type: "WORKS".to_string(),
2630            match_properties: vec![(
2631                "start".to_string(),
2632                LogicalExpression::Property {
2633                    variable: "ghost".to_string(),
2634                    property: "year".to_string(),
2635                },
2636            )],
2637            on_create: vec![],
2638            on_match: vec![],
2639            input: Box::new(LogicalOperator::Join(JoinOp {
2640                left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2641                    variable: "a".to_string(),
2642                    label: None,
2643                    input: None,
2644                })),
2645                right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2646                    variable: "b".to_string(),
2647                    label: None,
2648                    input: None,
2649                })),
2650                join_type: JoinType::Cross,
2651                conditions: vec![],
2652            })),
2653        }));
2654        let mut binder2 = Binder::new();
2655        let err = binder2.bind(&bad_plan).unwrap_err();
2656        assert!(err.to_string().contains("Undefined variable 'ghost'"));
2657    }
2658
2659    /// Undefined variable 'xx' when only 'x' is defined produces a
2660    /// "Did you mean 'x'?" suggestion in the error message.
2661    #[test]
2662    fn test_undefined_variable_suggestion() {
2663        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2664            items: vec![ReturnItem {
2665                expression: LogicalExpression::Variable("xx".to_string()),
2666                alias: None,
2667            }],
2668            distinct: false,
2669            input: Box::new(LogicalOperator::Unwind(crate::query::plan::UnwindOp {
2670                expression: LogicalExpression::List(vec![LogicalExpression::Literal(
2671                    grafeo_common::types::Value::Int64(1),
2672                )]),
2673                variable: "x".to_string(),
2674                ordinality_var: None,
2675                offset_var: None,
2676                input: Box::new(LogicalOperator::Empty),
2677            })),
2678        }));
2679
2680        let mut binder = Binder::new();
2681        let err = binder.bind(&plan).unwrap_err();
2682        let msg = err.to_string();
2683        assert!(msg.contains("Undefined variable 'xx'"), "got: {msg}");
2684        assert!(
2685            msg.contains("Did you mean 'x'?"),
2686            "should suggest the similar variable 'x', got: {msg}"
2687        );
2688    }
2689
2690    /// Aggregate registers both aggregate aliases (e.g. `cnt`) and the
2691    /// stringified group-by expression ("n.age") so downstream clauses
2692    /// (ORDER BY / HAVING / RETURN) can reference them.
2693    #[test]
2694    fn test_bind_aggregate_group_by_alias() {
2695        use crate::query::plan::{AggregateExpr, AggregateFunction, AggregateOp};
2696
2697        // Aggregate groups by n.age, plus a COUNT(*) aliased as "cnt".
2698        // Downstream ORDER BY would reference either "n.age" (group column)
2699        // or "cnt" (aggregate alias), so both must end up in the binding
2700        // context after binding the Aggregate.
2701        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
2702            items: vec![ReturnItem {
2703                expression: LogicalExpression::Variable("cnt".to_string()),
2704                alias: None,
2705            }],
2706            distinct: false,
2707            input: Box::new(LogicalOperator::Aggregate(AggregateOp {
2708                group_by: vec![LogicalExpression::Property {
2709                    variable: "n".to_string(),
2710                    property: "age".to_string(),
2711                }],
2712                aggregates: vec![AggregateExpr {
2713                    function: AggregateFunction::Count,
2714                    expression: None,
2715                    expression2: None,
2716                    distinct: false,
2717                    alias: Some("cnt".to_string()),
2718                    percentile: None,
2719                    separator: None,
2720                }],
2721                input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2722                    variable: "n".to_string(),
2723                    label: Some("Person".to_string()),
2724                    input: None,
2725                })),
2726                having: None,
2727            })),
2728        }));
2729
2730        let mut binder = Binder::new();
2731        let ctx = binder.bind(&plan).unwrap();
2732        // Aggregate alias is registered as a variable.
2733        assert!(ctx.contains("cnt"), "aggregate alias 'cnt' should be bound");
2734        // The group-by output column "n.age" is registered so RETURN /
2735        // ORDER BY / HAVING can reference it.
2736        assert!(
2737            ctx.contains("n.age"),
2738            "group-by output column 'n.age' should be registered, got: {:?}",
2739            ctx.variable_names()
2740        );
2741    }
2742
2743    // ========================================================================
2744    // Coverage tests: BindingContext helpers
2745    // ========================================================================
2746
2747    #[test]
2748    fn test_binding_context_len_and_is_empty_and_remove() {
2749        let mut ctx = BindingContext::new();
2750        assert!(ctx.is_empty());
2751        assert_eq!(ctx.len(), 0);
2752
2753        ctx.add_variable(
2754            "vincent".to_string(),
2755            VariableInfo {
2756                name: "vincent".to_string(),
2757                data_type: LogicalType::Node,
2758                is_node: true,
2759                is_edge: false,
2760            },
2761        );
2762        ctx.add_variable(
2763            "jules".to_string(),
2764            VariableInfo {
2765                name: "jules".to_string(),
2766                data_type: LogicalType::Node,
2767                is_node: true,
2768                is_edge: false,
2769            },
2770        );
2771        assert!(!ctx.is_empty());
2772        assert_eq!(ctx.len(), 2);
2773        assert_eq!(
2774            ctx.variable_names(),
2775            vec!["vincent".to_string(), "jules".to_string()]
2776        );
2777
2778        ctx.remove_variable("vincent");
2779        assert_eq!(ctx.len(), 1);
2780        assert!(!ctx.contains("vincent"));
2781        assert!(ctx.contains("jules"));
2782
2783        // Removing nonexistent is a no-op.
2784        ctx.remove_variable("nonexistent");
2785        assert_eq!(ctx.len(), 1);
2786    }
2787
2788    #[test]
2789    fn test_binder_default_impl() {
2790        let binder = Binder::default();
2791        assert!(binder.context.is_empty());
2792    }
2793
2794    // ========================================================================
2795    // Coverage tests: simple leaf / passthrough operators
2796    // ========================================================================
2797
2798    #[test]
2799    fn test_bind_empty_operator_alone() {
2800        let plan = LogicalPlan::new(LogicalOperator::Empty);
2801        let mut binder = Binder::new();
2802        let ctx = binder.bind(&plan).unwrap();
2803        assert!(ctx.is_empty());
2804    }
2805
2806    #[test]
2807    fn test_bind_limit_and_skip_delegate_to_input() {
2808        use crate::query::plan::{CountExpr, LimitOp, SkipOp};
2809
2810        let plan = LogicalPlan::new(LogicalOperator::Limit(LimitOp {
2811            count: CountExpr::Literal(5),
2812            input: Box::new(LogicalOperator::Skip(SkipOp {
2813                count: CountExpr::Literal(1),
2814                input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2815                    variable: "mia".to_string(),
2816                    label: Some("Person".to_string()),
2817                    input: None,
2818                })),
2819            })),
2820        }));
2821
2822        let mut binder = Binder::new();
2823        let ctx = binder.bind(&plan).unwrap();
2824        assert!(ctx.contains("mia"));
2825    }
2826
2827    #[test]
2828    fn test_bind_distinct_delegates_to_input() {
2829        use crate::query::plan::DistinctOp;
2830
2831        let plan = LogicalPlan::new(LogicalOperator::Distinct(DistinctOp {
2832            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
2833                variable: "butch".to_string(),
2834                label: None,
2835                input: None,
2836            })),
2837            columns: None,
2838        }));
2839        let mut binder = Binder::new();
2840        let ctx = binder.bind(&plan).unwrap();
2841        assert!(ctx.contains("butch"));
2842    }
2843
2844    #[test]
2845    fn test_bind_edge_scan_with_input_binds_edge_variable() {
2846        use crate::query::plan::EdgeScanOp;
2847
2848        let plan = LogicalPlan::new(LogicalOperator::EdgeScan(EdgeScanOp {
2849            variable: "e".to_string(),
2850            edge_types: vec!["KNOWS".to_string()],
2851            input: Some(Box::new(LogicalOperator::NodeScan(NodeScanOp {
2852                variable: "django".to_string(),
2853                label: None,
2854                input: None,
2855            }))),
2856        }));
2857
2858        let mut binder = Binder::new();
2859        let ctx = binder.bind(&plan).unwrap();
2860        assert!(ctx.contains("django"));
2861        let edge_info = ctx.get("e").expect("edge variable bound");
2862        assert!(edge_info.is_edge);
2863        assert!(!edge_info.is_node);
2864    }
2865
2866    #[test]
2867    fn test_bind_edge_scan_without_input() {
2868        use crate::query::plan::EdgeScanOp;
2869
2870        let plan = LogicalPlan::new(LogicalOperator::EdgeScan(EdgeScanOp {
2871            variable: "rel".to_string(),
2872            edge_types: vec![],
2873            input: None,
2874        }));
2875
2876        let mut binder = Binder::new();
2877        let ctx = binder.bind(&plan).unwrap();
2878        assert!(ctx.get("rel").unwrap().is_edge);
2879    }
2880
2881    // ========================================================================
2882    // Coverage tests: SPARQL / RDF operators
2883    // ========================================================================
2884
2885    #[test]
2886    fn test_bind_triple_scan_registers_all_variable_components() {
2887        use crate::query::plan::{TripleComponent, TripleScanOp};
2888
2889        let plan = LogicalPlan::new(LogicalOperator::TripleScan(TripleScanOp {
2890            subject: TripleComponent::Variable("s".to_string()),
2891            predicate: TripleComponent::Variable("p".to_string()),
2892            object: TripleComponent::Variable("o".to_string()),
2893            graph: Some(TripleComponent::Variable("g".to_string())),
2894            input: None,
2895            dataset: None,
2896        }));
2897
2898        let mut binder = Binder::new();
2899        let ctx = binder.bind(&plan).unwrap();
2900        assert!(ctx.contains("s"));
2901        assert!(ctx.contains("p"));
2902        assert!(ctx.contains("o"));
2903        assert!(ctx.contains("g"));
2904    }
2905
2906    #[test]
2907    fn test_bind_triple_scan_skips_constant_components() {
2908        use crate::query::plan::{TripleComponent, TripleScanOp};
2909        use grafeo_common::types::Value;
2910
2911        let plan = LogicalPlan::new(LogicalOperator::TripleScan(TripleScanOp {
2912            subject: TripleComponent::Iri("http://example.org/s".to_string()),
2913            predicate: TripleComponent::Iri("http://example.org/p".to_string()),
2914            object: TripleComponent::Literal(Value::Int64(42)),
2915            graph: None,
2916            input: Some(Box::new(LogicalOperator::NodeScan(NodeScanOp {
2917                variable: "existing".to_string(),
2918                label: None,
2919                input: None,
2920            }))),
2921            dataset: None,
2922        }));
2923
2924        let mut binder = Binder::new();
2925        let ctx = binder.bind(&plan).unwrap();
2926        // Input was bound.
2927        assert!(ctx.contains("existing"));
2928        // No variable components registered.
2929        assert_eq!(ctx.len(), 1);
2930    }
2931
2932    #[test]
2933    fn test_bind_triple_scan_does_not_rebind_existing_variable() {
2934        use crate::query::plan::{TripleComponent, TripleScanOp};
2935
2936        // Use an input that already binds 's' as a node. The triple scan
2937        // must not clobber it with the RDF-term type.
2938        let plan = LogicalPlan::new(LogicalOperator::TripleScan(TripleScanOp {
2939            subject: TripleComponent::Variable("s".to_string()),
2940            predicate: TripleComponent::Variable("p".to_string()),
2941            object: TripleComponent::Variable("o".to_string()),
2942            graph: None,
2943            input: Some(Box::new(LogicalOperator::NodeScan(NodeScanOp {
2944                variable: "s".to_string(),
2945                label: None,
2946                input: None,
2947            }))),
2948            dataset: None,
2949        }));
2950
2951        let mut binder = Binder::new();
2952        let ctx = binder.bind(&plan).unwrap();
2953        // 's' retains its node status because the triple scan's branch is
2954        // skipped once the variable is already present.
2955        assert!(ctx.get("s").unwrap().is_node);
2956        assert!(ctx.contains("p"));
2957        assert!(ctx.contains("o"));
2958    }
2959
2960    #[test]
2961    fn test_bind_insert_triple_and_delete_triple_with_and_without_input() {
2962        use crate::query::plan::{DeleteTripleOp, InsertTripleOp, TripleComponent};
2963
2964        // With input.
2965        let plan = LogicalPlan::new(LogicalOperator::InsertTriple(InsertTripleOp {
2966            subject: TripleComponent::Variable("s".to_string()),
2967            predicate: TripleComponent::Iri("http://example.org/p".to_string()),
2968            object: TripleComponent::Variable("o".to_string()),
2969            graph: None,
2970            input: Some(Box::new(LogicalOperator::NodeScan(NodeScanOp {
2971                variable: "hans".to_string(),
2972                label: None,
2973                input: None,
2974            }))),
2975        }));
2976        let mut binder = Binder::new();
2977        let ctx = binder.bind(&plan).unwrap();
2978        assert!(ctx.contains("hans"));
2979
2980        // Without input.
2981        let plan2 = LogicalPlan::new(LogicalOperator::InsertTriple(InsertTripleOp {
2982            subject: TripleComponent::Iri("http://example.org/s".to_string()),
2983            predicate: TripleComponent::Iri("http://example.org/p".to_string()),
2984            object: TripleComponent::Iri("http://example.org/o".to_string()),
2985            graph: None,
2986            input: None,
2987        }));
2988        let mut binder2 = Binder::new();
2989        assert!(binder2.bind(&plan2).is_ok());
2990
2991        // Delete with input.
2992        let plan3 = LogicalPlan::new(LogicalOperator::DeleteTriple(DeleteTripleOp {
2993            subject: TripleComponent::Variable("s".to_string()),
2994            predicate: TripleComponent::Iri("http://example.org/p".to_string()),
2995            object: TripleComponent::Variable("o".to_string()),
2996            graph: None,
2997            input: Some(Box::new(LogicalOperator::NodeScan(NodeScanOp {
2998                variable: "shosanna".to_string(),
2999                label: None,
3000                input: None,
3001            }))),
3002        }));
3003        let mut binder3 = Binder::new();
3004        let ctx3 = binder3.bind(&plan3).unwrap();
3005        assert!(ctx3.contains("shosanna"));
3006
3007        // Delete without input.
3008        let plan4 = LogicalPlan::new(LogicalOperator::DeleteTriple(DeleteTripleOp {
3009            subject: TripleComponent::Iri("http://example.org/s".to_string()),
3010            predicate: TripleComponent::Iri("http://example.org/p".to_string()),
3011            object: TripleComponent::Iri("http://example.org/o".to_string()),
3012            graph: None,
3013            input: None,
3014        }));
3015        let mut binder4 = Binder::new();
3016        assert!(binder4.bind(&plan4).is_ok());
3017    }
3018
3019    #[test]
3020    fn test_bind_modify_operator_walks_where_clause() {
3021        use crate::query::plan::ModifyOp;
3022
3023        let plan = LogicalPlan::new(LogicalOperator::Modify(ModifyOp {
3024            delete_templates: vec![],
3025            insert_templates: vec![],
3026            where_clause: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3027                variable: "beatrix".to_string(),
3028                label: None,
3029                input: None,
3030            })),
3031            graph: None,
3032        }));
3033
3034        let mut binder = Binder::new();
3035        let ctx = binder.bind(&plan).unwrap();
3036        assert!(ctx.contains("beatrix"));
3037    }
3038
3039    #[test]
3040    fn test_bind_construct_walks_input() {
3041        use crate::query::plan::ConstructOp;
3042
3043        let plan = LogicalPlan::new(LogicalOperator::Construct(ConstructOp {
3044            templates: vec![],
3045            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3046                variable: "node".to_string(),
3047                label: None,
3048                input: None,
3049            })),
3050        }));
3051        let mut binder = Binder::new();
3052        let ctx = binder.bind(&plan).unwrap();
3053        assert!(ctx.contains("node"));
3054    }
3055
3056    #[test]
3057    fn test_bind_graph_ddl_operators_succeed_without_input() {
3058        use crate::query::plan::{
3059            AddGraphOp, ClearGraphOp, CopyGraphOp, CreateGraphOp, DropGraphOp, LoadGraphOp,
3060            MoveGraphOp,
3061        };
3062
3063        // Each DDL operator should bind cleanly even without input. We cover
3064        // every arm of the consolidated DDL match block.
3065        let cases: Vec<LogicalOperator> = vec![
3066            LogicalOperator::ClearGraph(ClearGraphOp {
3067                graph: None,
3068                silent: false,
3069            }),
3070            LogicalOperator::CreateGraph(CreateGraphOp {
3071                graph: "http://example.org/g".to_string(),
3072                silent: false,
3073            }),
3074            LogicalOperator::DropGraph(DropGraphOp {
3075                graph: None,
3076                silent: true,
3077            }),
3078            LogicalOperator::LoadGraph(LoadGraphOp {
3079                source: "http://example.org/data".to_string(),
3080                destination: None,
3081                silent: false,
3082            }),
3083            LogicalOperator::CopyGraph(CopyGraphOp {
3084                source: None,
3085                destination: None,
3086                silent: false,
3087            }),
3088            LogicalOperator::MoveGraph(MoveGraphOp {
3089                source: None,
3090                destination: None,
3091                silent: false,
3092            }),
3093            LogicalOperator::AddGraph(AddGraphOp {
3094                source: None,
3095                destination: None,
3096                silent: false,
3097            }),
3098        ];
3099
3100        for op in cases {
3101            let plan = LogicalPlan::new(op);
3102            let mut binder = Binder::new();
3103            assert!(binder.bind(&plan).is_ok());
3104        }
3105    }
3106
3107    #[test]
3108    fn test_bind_create_property_graph_is_noop() {
3109        use crate::query::plan::CreatePropertyGraphOp;
3110
3111        let plan = LogicalPlan::new(LogicalOperator::CreatePropertyGraph(
3112            CreatePropertyGraphOp {
3113                name: "social".to_string(),
3114                node_tables: vec![],
3115                edge_tables: vec![],
3116            },
3117        ));
3118        let mut binder = Binder::new();
3119        let ctx = binder.bind(&plan).unwrap();
3120        assert!(ctx.is_empty());
3121    }
3122
3123    // ========================================================================
3124    // Coverage tests: joins, set ops, union, bind
3125    // ========================================================================
3126
3127    #[test]
3128    fn test_bind_union_walks_all_inputs() {
3129        use crate::query::plan::UnionOp;
3130
3131        let plan = LogicalPlan::new(LogicalOperator::Union(UnionOp {
3132            inputs: vec![
3133                LogicalOperator::NodeScan(NodeScanOp {
3134                    variable: "amsterdam".to_string(),
3135                    label: None,
3136                    input: None,
3137                }),
3138                LogicalOperator::NodeScan(NodeScanOp {
3139                    variable: "berlin".to_string(),
3140                    label: None,
3141                    input: None,
3142                }),
3143            ],
3144        }));
3145        let mut binder = Binder::new();
3146        let ctx = binder.bind(&plan).unwrap();
3147        assert!(ctx.contains("amsterdam"));
3148        assert!(ctx.contains("berlin"));
3149    }
3150
3151    #[test]
3152    fn test_bind_left_join_with_condition_validates_it() {
3153        use crate::query::plan::LeftJoinOp;
3154
3155        // Left and right both bind variables; a condition referencing a
3156        // defined variable validates, whereas an unknown variable fails.
3157        let ok_plan = LogicalPlan::new(LogicalOperator::LeftJoin(LeftJoinOp {
3158            left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3159                variable: "paris".to_string(),
3160                label: None,
3161                input: None,
3162            })),
3163            right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3164                variable: "prague".to_string(),
3165                label: None,
3166                input: None,
3167            })),
3168            condition: Some(LogicalExpression::Variable("paris".to_string())),
3169        }));
3170        let mut binder = Binder::new();
3171        assert!(binder.bind(&ok_plan).is_ok());
3172
3173        // Missing condition is accepted too.
3174        let plan_no_cond = LogicalPlan::new(LogicalOperator::LeftJoin(LeftJoinOp {
3175            left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3176                variable: "paris".to_string(),
3177                label: None,
3178                input: None,
3179            })),
3180            right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3181                variable: "prague".to_string(),
3182                label: None,
3183                input: None,
3184            })),
3185            condition: None,
3186        }));
3187        assert!(Binder::new().bind(&plan_no_cond).is_ok());
3188
3189        // Condition referencing undefined variable rejects.
3190        let bad_plan = LogicalPlan::new(LogicalOperator::LeftJoin(LeftJoinOp {
3191            left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3192                variable: "paris".to_string(),
3193                label: None,
3194                input: None,
3195            })),
3196            right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3197                variable: "prague".to_string(),
3198                label: None,
3199                input: None,
3200            })),
3201            condition: Some(LogicalExpression::Variable("missing".to_string())),
3202        }));
3203        assert!(Binder::new().bind(&bad_plan).is_err());
3204    }
3205
3206    #[test]
3207    fn test_bind_anti_join_walks_both_sides() {
3208        use crate::query::plan::AntiJoinOp;
3209
3210        let plan = LogicalPlan::new(LogicalOperator::AntiJoin(AntiJoinOp {
3211            left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3212                variable: "left_side".to_string(),
3213                label: None,
3214                input: None,
3215            })),
3216            right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3217                variable: "right_side".to_string(),
3218                label: None,
3219                input: None,
3220            })),
3221        }));
3222        let mut binder = Binder::new();
3223        let ctx = binder.bind(&plan).unwrap();
3224        assert!(ctx.contains("left_side"));
3225        assert!(ctx.contains("right_side"));
3226    }
3227
3228    #[test]
3229    fn test_bind_bind_operator_adds_variable_and_validates_expression() {
3230        use crate::query::plan::BindOp;
3231
3232        // Bind with a defined expression reference.
3233        let plan = LogicalPlan::new(LogicalOperator::Bind(BindOp {
3234            expression: LogicalExpression::Variable("n".to_string()),
3235            variable: "x".to_string(),
3236            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3237                variable: "n".to_string(),
3238                label: None,
3239                input: None,
3240            })),
3241        }));
3242        let mut binder = Binder::new();
3243        let ctx = binder.bind(&plan).unwrap();
3244        assert!(ctx.contains("x"));
3245
3246        // BIND expression referencing undefined variable must fail.
3247        let bad_plan = LogicalPlan::new(LogicalOperator::Bind(BindOp {
3248            expression: LogicalExpression::Variable("ghost".to_string()),
3249            variable: "x".to_string(),
3250            input: Box::new(LogicalOperator::Empty),
3251        }));
3252        assert!(Binder::new().bind(&bad_plan).is_err());
3253    }
3254
3255    #[test]
3256    fn test_bind_except_intersect_otherwise_walk_both_sides() {
3257        use crate::query::plan::{ExceptOp, IntersectOp, OtherwiseOp};
3258
3259        for op in [
3260            LogicalOperator::Except(ExceptOp {
3261                left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3262                    variable: "l".to_string(),
3263                    label: None,
3264                    input: None,
3265                })),
3266                right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3267                    variable: "r".to_string(),
3268                    label: None,
3269                    input: None,
3270                })),
3271                all: false,
3272            }),
3273            LogicalOperator::Intersect(IntersectOp {
3274                left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3275                    variable: "l".to_string(),
3276                    label: None,
3277                    input: None,
3278                })),
3279                right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3280                    variable: "r".to_string(),
3281                    label: None,
3282                    input: None,
3283                })),
3284                all: false,
3285            }),
3286            LogicalOperator::Otherwise(OtherwiseOp {
3287                left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3288                    variable: "l".to_string(),
3289                    label: None,
3290                    input: None,
3291                })),
3292                right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3293                    variable: "r".to_string(),
3294                    label: None,
3295                    input: None,
3296                })),
3297            }),
3298        ] {
3299            let plan = LogicalPlan::new(op);
3300            let mut binder = Binder::new();
3301            let ctx = binder.bind(&plan).unwrap();
3302            assert!(ctx.contains("l"));
3303            assert!(ctx.contains("r"));
3304        }
3305    }
3306
3307    #[test]
3308    fn test_bind_multi_way_join_validates_conditions() {
3309        use crate::query::plan::{JoinCondition, MultiWayJoinOp};
3310
3311        let plan = LogicalPlan::new(LogicalOperator::MultiWayJoin(MultiWayJoinOp {
3312            inputs: vec![
3313                LogicalOperator::NodeScan(NodeScanOp {
3314                    variable: "a".to_string(),
3315                    label: None,
3316                    input: None,
3317                }),
3318                LogicalOperator::NodeScan(NodeScanOp {
3319                    variable: "b".to_string(),
3320                    label: None,
3321                    input: None,
3322                }),
3323            ],
3324            conditions: vec![JoinCondition {
3325                left: LogicalExpression::Variable("a".to_string()),
3326                right: LogicalExpression::Variable("b".to_string()),
3327            }],
3328            shared_variables: vec![],
3329        }));
3330        let mut binder = Binder::new();
3331        assert!(binder.bind(&plan).is_ok());
3332
3333        // Condition with undefined variable fails.
3334        let bad_plan = LogicalPlan::new(LogicalOperator::MultiWayJoin(MultiWayJoinOp {
3335            inputs: vec![LogicalOperator::NodeScan(NodeScanOp {
3336                variable: "a".to_string(),
3337                label: None,
3338                input: None,
3339            })],
3340            conditions: vec![JoinCondition {
3341                left: LogicalExpression::Variable("a".to_string()),
3342                right: LogicalExpression::Variable("nope".to_string()),
3343            }],
3344            shared_variables: vec![],
3345        }));
3346        assert!(Binder::new().bind(&bad_plan).is_err());
3347    }
3348
3349    // ========================================================================
3350    // Coverage tests: Apply / parameter scan / subplan scoping
3351    // ========================================================================
3352
3353    #[test]
3354    fn test_bind_parameter_scan_registers_columns() {
3355        use crate::query::plan::ParameterScanOp;
3356
3357        let plan = LogicalPlan::new(LogicalOperator::ParameterScan(ParameterScanOp {
3358            columns: vec!["vincent".to_string(), "jules".to_string()],
3359        }));
3360        let mut binder = Binder::new();
3361        let ctx = binder.bind(&plan).unwrap();
3362        assert!(ctx.contains("vincent"));
3363        assert!(ctx.contains("jules"));
3364    }
3365
3366    #[test]
3367    fn test_bind_apply_removes_internal_variables_from_input_and_subplan() {
3368        use crate::query::plan::ApplyOp;
3369
3370        // Outer input: a Return that exposes only an aliased column. The
3371        // internal NodeScan variable "inner_scan" is bound by Return's input,
3372        // then scoped out by Apply because Return registers explicit outputs.
3373        let plan = LogicalPlan::new(LogicalOperator::Apply(ApplyOp {
3374            input: Box::new(LogicalOperator::Return(ReturnOp {
3375                items: vec![ReturnItem {
3376                    expression: LogicalExpression::Variable("inner_scan".to_string()),
3377                    alias: Some("out_col".to_string()),
3378                }],
3379                distinct: false,
3380                input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3381                    variable: "inner_scan".to_string(),
3382                    label: None,
3383                    input: None,
3384                })),
3385            })),
3386            subplan: Box::new(LogicalOperator::Return(ReturnOp {
3387                items: vec![ReturnItem {
3388                    expression: LogicalExpression::Literal(grafeo_common::types::Value::Int64(1)),
3389                    alias: Some("sub_col".to_string()),
3390                }],
3391                distinct: false,
3392                input: Box::new(LogicalOperator::Empty),
3393            })),
3394            shared_variables: vec![],
3395            optional: false,
3396        }));
3397
3398        let mut binder = Binder::new();
3399        let ctx = binder.bind(&plan).unwrap();
3400
3401        // Output of the Apply should include projected columns from both
3402        // sides but not the internal node-scan variable.
3403        assert!(ctx.contains("out_col"), "input projection exposed");
3404        assert!(ctx.contains("sub_col"), "subplan output registered");
3405        assert!(
3406            !ctx.contains("inner_scan"),
3407            "internal scan variable should be scoped out"
3408        );
3409    }
3410
3411    #[test]
3412    fn test_bind_apply_without_explicit_projection_keeps_input_variables() {
3413        use crate::query::plan::ApplyOp;
3414
3415        // When the outer input has no projection, the scoping branch is
3416        // skipped and input variables stay visible.
3417        let plan = LogicalPlan::new(LogicalOperator::Apply(ApplyOp {
3418            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3419                variable: "outer".to_string(),
3420                label: None,
3421                input: None,
3422            })),
3423            subplan: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3424                variable: "inner".to_string(),
3425                label: None,
3426                input: None,
3427            })),
3428            shared_variables: vec![],
3429            optional: false,
3430        }));
3431
3432        let mut binder = Binder::new();
3433        let ctx = binder.bind(&plan).unwrap();
3434        assert!(ctx.contains("outer"));
3435    }
3436
3437    // ========================================================================
3438    // Coverage tests: MERGE relationship
3439    // ========================================================================
3440
3441    #[test]
3442    fn test_merge_relationship_rejects_undefined_source_and_target() {
3443        use crate::query::plan::MergeRelationshipOp;
3444
3445        // Undefined source.
3446        let plan = LogicalPlan::new(LogicalOperator::MergeRelationship(MergeRelationshipOp {
3447            variable: "r".to_string(),
3448            source_variable: "phantom".to_string(),
3449            target_variable: "b".to_string(),
3450            edge_type: "KNOWS".to_string(),
3451            match_properties: vec![],
3452            on_create: vec![],
3453            on_match: vec![],
3454            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3455                variable: "b".to_string(),
3456                label: None,
3457                input: None,
3458            })),
3459        }));
3460        let err = Binder::new().bind(&plan).unwrap_err();
3461        assert!(err.to_string().contains("MERGE relationship source"));
3462
3463        // Undefined target.
3464        let plan2 = LogicalPlan::new(LogicalOperator::MergeRelationship(MergeRelationshipOp {
3465            variable: "r".to_string(),
3466            source_variable: "a".to_string(),
3467            target_variable: "phantom".to_string(),
3468            edge_type: "KNOWS".to_string(),
3469            match_properties: vec![],
3470            on_create: vec![],
3471            on_match: vec![],
3472            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3473                variable: "a".to_string(),
3474                label: None,
3475                input: None,
3476            })),
3477        }));
3478        let err2 = Binder::new().bind(&plan2).unwrap_err();
3479        assert!(err2.to_string().contains("MERGE relationship target"));
3480    }
3481
3482    #[test]
3483    fn test_merge_relationship_happy_path_binds_edge_variable() {
3484        use crate::query::plan::{JoinOp, JoinType, MergeRelationshipOp};
3485
3486        let plan = LogicalPlan::new(LogicalOperator::MergeRelationship(MergeRelationshipOp {
3487            variable: "r".to_string(),
3488            source_variable: "a".to_string(),
3489            target_variable: "b".to_string(),
3490            edge_type: "KNOWS".to_string(),
3491            match_properties: vec![(
3492                "since".to_string(),
3493                LogicalExpression::Literal(grafeo_common::types::Value::Int64(2020)),
3494            )],
3495            on_create: vec![(
3496                "created_at".to_string(),
3497                LogicalExpression::Literal(grafeo_common::types::Value::Int64(1)),
3498            )],
3499            on_match: vec![(
3500                "updated_at".to_string(),
3501                LogicalExpression::Literal(grafeo_common::types::Value::Int64(2)),
3502            )],
3503            input: Box::new(LogicalOperator::Join(JoinOp {
3504                left: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3505                    variable: "a".to_string(),
3506                    label: None,
3507                    input: None,
3508                })),
3509                right: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3510                    variable: "b".to_string(),
3511                    label: None,
3512                    input: None,
3513                })),
3514                join_type: JoinType::Cross,
3515                conditions: vec![],
3516            })),
3517        }));
3518        let mut binder = Binder::new();
3519        let ctx = binder.bind(&plan).unwrap();
3520        let edge = ctx.get("r").expect("edge variable bound");
3521        assert!(edge.is_edge);
3522        assert!(!edge.is_node);
3523    }
3524
3525    // ========================================================================
3526    // Coverage tests: UNWIND, CallProcedure, LoadData, MapCollect, VectorJoin
3527    // ========================================================================
3528
3529    #[test]
3530    fn test_unwind_ordinality_and_offset_variables() {
3531        use crate::query::plan::UnwindOp;
3532
3533        let plan = LogicalPlan::new(LogicalOperator::Unwind(UnwindOp {
3534            expression: LogicalExpression::List(vec![LogicalExpression::Literal(
3535                grafeo_common::types::Value::Int64(1),
3536            )]),
3537            variable: "item".to_string(),
3538            ordinality_var: Some("ord".to_string()),
3539            offset_var: Some("off".to_string()),
3540            input: Box::new(LogicalOperator::Empty),
3541        }));
3542        let mut binder = Binder::new();
3543        let ctx = binder.bind(&plan).unwrap();
3544        assert_eq!(ctx.get("ord").unwrap().data_type, LogicalType::Int64);
3545        assert_eq!(ctx.get("off").unwrap().data_type, LogicalType::Int64);
3546    }
3547
3548    #[test]
3549    fn test_call_procedure_with_and_without_yields() {
3550        use crate::query::plan::{CallProcedureOp, ProcedureYield};
3551
3552        // With yield items including an alias.
3553        let plan = LogicalPlan::new(LogicalOperator::CallProcedure(CallProcedureOp {
3554            name: vec!["grafeo".to_string(), "pagerank".to_string()],
3555            arguments: vec![],
3556            yield_items: Some(vec![
3557                ProcedureYield {
3558                    field_name: "nodeId".to_string(),
3559                    alias: None,
3560                },
3561                ProcedureYield {
3562                    field_name: "score".to_string(),
3563                    alias: Some("rank".to_string()),
3564                },
3565            ]),
3566        }));
3567        let mut binder = Binder::new();
3568        let ctx = binder.bind(&plan).unwrap();
3569        assert!(ctx.contains("nodeId"));
3570        assert!(ctx.contains("rank"));
3571        assert!(!ctx.contains("score"), "aliased yield hides raw name");
3572
3573        // Without yields: nothing to register.
3574        let plan2 = LogicalPlan::new(LogicalOperator::CallProcedure(CallProcedureOp {
3575            name: vec!["grafeo".to_string(), "noop".to_string()],
3576            arguments: vec![],
3577            yield_items: None,
3578        }));
3579        let mut binder2 = Binder::new();
3580        let ctx2 = binder2.bind(&plan2).unwrap();
3581        assert!(ctx2.is_empty());
3582    }
3583
3584    #[test]
3585    fn test_load_data_binds_row_variable() {
3586        use crate::query::plan::{LoadDataFormat, LoadDataOp};
3587
3588        let plan = LogicalPlan::new(LogicalOperator::LoadData(LoadDataOp {
3589            format: LoadDataFormat::Csv,
3590            with_headers: true,
3591            path: "/tmp/data.csv".to_string(),
3592            variable: "row".to_string(),
3593            field_terminator: None,
3594        }));
3595        let mut binder = Binder::new();
3596        let ctx = binder.bind(&plan).unwrap();
3597        assert!(ctx.contains("row"));
3598        assert_eq!(ctx.get("row").unwrap().data_type, LogicalType::Any);
3599    }
3600
3601    #[test]
3602    fn test_map_collect_registers_alias() {
3603        use crate::query::plan::MapCollectOp;
3604
3605        let plan = LogicalPlan::new(LogicalOperator::MapCollect(MapCollectOp {
3606            key_var: "k".to_string(),
3607            value_var: "v".to_string(),
3608            alias: "grouped".to_string(),
3609            input: Box::new(LogicalOperator::Empty),
3610        }));
3611        let mut binder = Binder::new();
3612        let ctx = binder.bind(&plan).unwrap();
3613        assert!(ctx.contains("grouped"));
3614    }
3615
3616    #[test]
3617    fn test_vector_join_registers_right_and_score_variables() {
3618        use crate::query::plan::VectorJoinOp;
3619
3620        let plan = LogicalPlan::new(LogicalOperator::VectorJoin(VectorJoinOp {
3621            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3622                variable: "a".to_string(),
3623                label: None,
3624                input: None,
3625            })),
3626            left_vector_variable: None,
3627            left_property: None,
3628            query_vector: LogicalExpression::Literal(grafeo_common::types::Value::Int64(0)),
3629            right_variable: "b".to_string(),
3630            right_property: "embedding".to_string(),
3631            right_label: None,
3632            index_name: None,
3633            k: 5,
3634            metric: None,
3635            min_similarity: None,
3636            max_distance: None,
3637            score_variable: Some("score".to_string()),
3638        }));
3639        let mut binder = Binder::new();
3640        let ctx = binder.bind(&plan).unwrap();
3641        assert!(ctx.get("b").unwrap().is_node);
3642        assert_eq!(ctx.get("score").unwrap().data_type, LogicalType::Float64);
3643
3644        // VectorJoin with undefined query_vector reference should fail.
3645        let bad_plan = LogicalPlan::new(LogicalOperator::VectorJoin(VectorJoinOp {
3646            input: Box::new(LogicalOperator::Empty),
3647            left_vector_variable: None,
3648            left_property: None,
3649            query_vector: LogicalExpression::Variable("undef_vec".to_string()),
3650            right_variable: "b".to_string(),
3651            right_property: "embedding".to_string(),
3652            right_label: None,
3653            index_name: None,
3654            k: 5,
3655            metric: None,
3656            min_similarity: None,
3657            max_distance: None,
3658            score_variable: None,
3659        }));
3660        assert!(Binder::new().bind(&bad_plan).is_err());
3661    }
3662
3663    // ========================================================================
3664    // Coverage tests: expression validation edge cases
3665    // ========================================================================
3666
3667    #[test]
3668    fn test_expression_validation_unary_index_map_projection() {
3669        use crate::query::plan::{MapProjectionEntry, UnaryOp};
3670
3671        // Unary with undefined operand fails.
3672        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3673            items: vec![ReturnItem {
3674                expression: LogicalExpression::Unary {
3675                    op: UnaryOp::Not,
3676                    operand: Box::new(LogicalExpression::Variable("ghost".to_string())),
3677                },
3678                alias: None,
3679            }],
3680            distinct: false,
3681            input: Box::new(LogicalOperator::Empty),
3682        }));
3683        assert!(Binder::new().bind(&plan).is_err());
3684
3685        // IndexAccess validates both base and index.
3686        let plan2 = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3687            items: vec![ReturnItem {
3688                expression: LogicalExpression::IndexAccess {
3689                    base: Box::new(LogicalExpression::Variable("xs".to_string())),
3690                    index: Box::new(LogicalExpression::Variable("idx_ghost".to_string())),
3691                },
3692                alias: None,
3693            }],
3694            distinct: false,
3695            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3696                variable: "xs".to_string(),
3697                label: None,
3698                input: None,
3699            })),
3700        }));
3701        let err = Binder::new().bind(&plan2).unwrap_err();
3702        assert!(err.to_string().contains("idx_ghost"));
3703
3704        // MapProjection validates literal entries.
3705        let plan3 = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3706            items: vec![ReturnItem {
3707                expression: LogicalExpression::MapProjection {
3708                    base: "n".to_string(),
3709                    entries: vec![
3710                        MapProjectionEntry::PropertySelector("name".to_string()),
3711                        MapProjectionEntry::AllProperties,
3712                        MapProjectionEntry::LiteralEntry(
3713                            "extra".to_string(),
3714                            LogicalExpression::Variable("missing".to_string()),
3715                        ),
3716                    ],
3717                },
3718                alias: None,
3719            }],
3720            distinct: false,
3721            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3722                variable: "n".to_string(),
3723                label: None,
3724                input: None,
3725            })),
3726        }));
3727        assert!(Binder::new().bind(&plan3).is_err());
3728
3729        // MapProjection base undefined fails.
3730        let plan4 = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3731            items: vec![ReturnItem {
3732                expression: LogicalExpression::MapProjection {
3733                    base: "unknown".to_string(),
3734                    entries: vec![MapProjectionEntry::AllProperties],
3735                },
3736                alias: None,
3737            }],
3738            distinct: false,
3739            input: Box::new(LogicalOperator::Empty),
3740        }));
3741        let err4 = Binder::new().bind(&plan4).unwrap_err();
3742        assert!(err4.to_string().contains("map projection"));
3743    }
3744
3745    #[test]
3746    fn test_expression_validation_list_and_parameter_and_subquery() {
3747        // Parameter is always valid.
3748        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3749            items: vec![ReturnItem {
3750                expression: LogicalExpression::Parameter("p".to_string()),
3751                alias: None,
3752            }],
3753            distinct: false,
3754            input: Box::new(LogicalOperator::Empty),
3755        }));
3756        assert!(Binder::new().bind(&plan).is_ok());
3757
3758        // List with undefined element.
3759        let plan2 = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3760            items: vec![ReturnItem {
3761                expression: LogicalExpression::List(vec![LogicalExpression::Variable(
3762                    "no_such".to_string(),
3763                )]),
3764                alias: None,
3765            }],
3766            distinct: false,
3767            input: Box::new(LogicalOperator::Empty),
3768        }));
3769        assert!(Binder::new().bind(&plan2).is_err());
3770
3771        // Subquery expressions are accepted without recursive binding.
3772        let plan3 = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3773            items: vec![ReturnItem {
3774                expression: LogicalExpression::ExistsSubquery(Box::new(LogicalOperator::Empty)),
3775                alias: None,
3776            }],
3777            distinct: false,
3778            input: Box::new(LogicalOperator::Empty),
3779        }));
3780        assert!(Binder::new().bind(&plan3).is_ok());
3781
3782        let plan4 = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3783            items: vec![ReturnItem {
3784                expression: LogicalExpression::CountSubquery(Box::new(LogicalOperator::Empty)),
3785                alias: None,
3786            }],
3787            distinct: false,
3788            input: Box::new(LogicalOperator::Empty),
3789        }));
3790        assert!(Binder::new().bind(&plan4).is_ok());
3791
3792        let plan5 = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3793            items: vec![ReturnItem {
3794                expression: LogicalExpression::ValueSubquery(Box::new(LogicalOperator::Empty)),
3795                alias: None,
3796            }],
3797            distinct: false,
3798            input: Box::new(LogicalOperator::Empty),
3799        }));
3800        assert!(Binder::new().bind(&plan5).is_ok());
3801    }
3802
3803    #[test]
3804    fn test_expression_validation_list_predicate_and_pattern_comprehension() {
3805        use crate::query::plan::ListPredicateKind;
3806
3807        // ListPredicate validates source list.
3808        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3809            items: vec![ReturnItem {
3810                expression: LogicalExpression::ListPredicate {
3811                    kind: ListPredicateKind::All,
3812                    variable: "x".to_string(),
3813                    list_expr: Box::new(LogicalExpression::Variable("missing_list".to_string())),
3814                    predicate: Box::new(LogicalExpression::Literal(
3815                        grafeo_common::types::Value::Bool(true),
3816                    )),
3817                },
3818                alias: None,
3819            }],
3820            distinct: false,
3821            input: Box::new(LogicalOperator::Empty),
3822        }));
3823        assert!(Binder::new().bind(&plan).is_err());
3824
3825        // PatternComprehension binds its subplan and then validates projection.
3826        let plan2 = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3827            items: vec![ReturnItem {
3828                expression: LogicalExpression::PatternComprehension {
3829                    subplan: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3830                        variable: "f".to_string(),
3831                        label: None,
3832                        input: None,
3833                    })),
3834                    projection: Box::new(LogicalExpression::Property {
3835                        variable: "f".to_string(),
3836                        property: "name".to_string(),
3837                    }),
3838                },
3839                alias: Some("names".to_string()),
3840            }],
3841            distinct: false,
3842            input: Box::new(LogicalOperator::Empty),
3843        }));
3844        assert!(Binder::new().bind(&plan2).is_ok());
3845
3846        // Pattern comprehension with a bad projection.
3847        let plan3 = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3848            items: vec![ReturnItem {
3849                expression: LogicalExpression::PatternComprehension {
3850                    subplan: Box::new(LogicalOperator::NodeScan(NodeScanOp {
3851                        variable: "f".to_string(),
3852                        label: None,
3853                        input: None,
3854                    })),
3855                    projection: Box::new(LogicalExpression::Variable("ghost".to_string())),
3856                },
3857                alias: None,
3858            }],
3859            distinct: false,
3860            input: Box::new(LogicalOperator::Empty),
3861        }));
3862        assert!(Binder::new().bind(&plan3).is_err());
3863    }
3864
3865    #[test]
3866    fn test_expression_validation_reduce_adds_and_removes_locals() {
3867        // reduce() adds accumulator and iteration variable during validation.
3868        // After the call, they should not remain in outer scope.
3869        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3870            items: vec![ReturnItem {
3871                expression: LogicalExpression::Reduce {
3872                    accumulator: "acc".to_string(),
3873                    initial: Box::new(LogicalExpression::Literal(
3874                        grafeo_common::types::Value::Int64(0),
3875                    )),
3876                    variable: "x".to_string(),
3877                    list: Box::new(LogicalExpression::Variable("xs".to_string())),
3878                    expression: Box::new(LogicalExpression::Binary {
3879                        left: Box::new(LogicalExpression::Variable("acc".to_string())),
3880                        op: crate::query::plan::BinaryOp::Add,
3881                        right: Box::new(LogicalExpression::Variable("x".to_string())),
3882                    }),
3883                },
3884                alias: Some("sum".to_string()),
3885            }],
3886            distinct: false,
3887            input: Box::new(LogicalOperator::Project(crate::query::plan::ProjectOp {
3888                projections: vec![crate::query::plan::Projection {
3889                    expression: LogicalExpression::List(vec![LogicalExpression::Literal(
3890                        grafeo_common::types::Value::Int64(1),
3891                    )]),
3892                    alias: Some("xs".to_string()),
3893                }],
3894                input: Box::new(LogicalOperator::Empty),
3895                pass_through_input: false,
3896            })),
3897        }));
3898        let mut binder = Binder::new();
3899        let ctx = binder.bind(&plan).unwrap();
3900        // 'acc' and 'x' must not leak to outer scope.
3901        assert!(!ctx.contains("acc"));
3902        assert!(!ctx.contains("x"));
3903        // Alias 'sum' is registered.
3904        assert!(ctx.contains("sum"));
3905    }
3906
3907    #[test]
3908    fn test_expression_validation_reduce_preserves_preexisting_locals() {
3909        // If accumulator or variable name already exists in scope, the reduce
3910        // branch should leave them intact when it finishes.
3911        let plan = LogicalPlan::new(LogicalOperator::Return(ReturnOp {
3912            items: vec![ReturnItem {
3913                expression: LogicalExpression::Reduce {
3914                    accumulator: "acc".to_string(),
3915                    initial: Box::new(LogicalExpression::Literal(
3916                        grafeo_common::types::Value::Int64(0),
3917                    )),
3918                    variable: "x".to_string(),
3919                    list: Box::new(LogicalExpression::Variable("acc".to_string())),
3920                    expression: Box::new(LogicalExpression::Variable("acc".to_string())),
3921                },
3922                alias: Some("r".to_string()),
3923            }],
3924            distinct: false,
3925            input: Box::new(LogicalOperator::Project(crate::query::plan::ProjectOp {
3926                projections: vec![
3927                    crate::query::plan::Projection {
3928                        expression: LogicalExpression::List(vec![]),
3929                        alias: Some("acc".to_string()),
3930                    },
3931                    crate::query::plan::Projection {
3932                        expression: LogicalExpression::Literal(grafeo_common::types::Value::Int64(
3933                            0,
3934                        )),
3935                        alias: Some("x".to_string()),
3936                    },
3937                ],
3938                input: Box::new(LogicalOperator::Empty),
3939                pass_through_input: false,
3940            })),
3941        }));
3942        let mut binder = Binder::new();
3943        let ctx = binder.bind(&plan).unwrap();
3944        // Pre-existing bindings survive.
3945        assert!(ctx.contains("acc"));
3946        assert!(ctx.contains("x"));
3947    }
3948
3949    // ========================================================================
3950    // Coverage tests: type inference and entity status propagation
3951    // ========================================================================
3952
3953    #[test]
3954    fn test_infer_expression_type_for_literals_and_functions() {
3955        use crate::query::plan::{ProjectOp, Projection};
3956        use grafeo_common::types::Value;
3957
3958        // Wrap a series of projections with typed aliases.
3959        let projections = vec![
3960            Projection {
3961                expression: LogicalExpression::Literal(Value::Bool(true)),
3962                alias: Some("b".to_string()),
3963            },
3964            Projection {
3965                expression: LogicalExpression::Literal(Value::Int64(1)),
3966                alias: Some("i".to_string()),
3967            },
3968            Projection {
3969                expression: LogicalExpression::Literal(Value::Float64(1.5)),
3970                alias: Some("f".to_string()),
3971            },
3972            Projection {
3973                expression: LogicalExpression::Literal(Value::String("s".into())),
3974                alias: Some("s".to_string()),
3975            },
3976            Projection {
3977                expression: LogicalExpression::Literal(Value::List(std::sync::Arc::from(Vec::<
3978                    Value,
3979                >::new(
3980                )))),
3981                alias: Some("l".to_string()),
3982            },
3983            Projection {
3984                expression: LogicalExpression::Literal(Value::Map(std::sync::Arc::new(
3985                    std::collections::BTreeMap::new(),
3986                ))),
3987                alias: Some("m".to_string()),
3988            },
3989            Projection {
3990                expression: LogicalExpression::Literal(Value::Null),
3991                alias: Some("n".to_string()),
3992            },
3993            Projection {
3994                expression: LogicalExpression::FunctionCall {
3995                    name: "count".to_string(),
3996                    args: vec![LogicalExpression::Literal(Value::Int64(1))],
3997                    distinct: false,
3998                },
3999                alias: Some("cnt".to_string()),
4000            },
4001            Projection {
4002                expression: LogicalExpression::FunctionCall {
4003                    name: "AVG".to_string(),
4004                    args: vec![LogicalExpression::Literal(Value::Int64(1))],
4005                    distinct: false,
4006                },
4007                alias: Some("avg_val".to_string()),
4008            },
4009            Projection {
4010                expression: LogicalExpression::FunctionCall {
4011                    name: "type".to_string(),
4012                    args: vec![],
4013                    distinct: false,
4014                },
4015                alias: Some("tname".to_string()),
4016            },
4017            Projection {
4018                expression: LogicalExpression::FunctionCall {
4019                    name: "labels".to_string(),
4020                    args: vec![],
4021                    distinct: false,
4022                },
4023                alias: Some("lbls".to_string()),
4024            },
4025            Projection {
4026                expression: LogicalExpression::FunctionCall {
4027                    name: "unknown_fn".to_string(),
4028                    args: vec![],
4029                    distinct: false,
4030                },
4031                alias: Some("u".to_string()),
4032            },
4033            // List and Map expressions are Any.
4034            Projection {
4035                expression: LogicalExpression::List(vec![]),
4036                alias: Some("lit_list".to_string()),
4037            },
4038            Projection {
4039                expression: LogicalExpression::Map(vec![]),
4040                alias: Some("lit_map".to_string()),
4041            },
4042            // Unary / Binary / Property fall through to Any.
4043            Projection {
4044                expression: LogicalExpression::Unary {
4045                    op: crate::query::plan::UnaryOp::Not,
4046                    operand: Box::new(LogicalExpression::Literal(Value::Bool(true))),
4047                },
4048                alias: Some("unary_ty".to_string()),
4049            },
4050            Projection {
4051                expression: LogicalExpression::Binary {
4052                    left: Box::new(LogicalExpression::Literal(Value::Int64(1))),
4053                    op: crate::query::plan::BinaryOp::Add,
4054                    right: Box::new(LogicalExpression::Literal(Value::Int64(2))),
4055                },
4056                alias: Some("bin_ty".to_string()),
4057            },
4058        ];
4059
4060        let plan = LogicalPlan::new(LogicalOperator::Project(ProjectOp {
4061            projections,
4062            input: Box::new(LogicalOperator::Empty),
4063            pass_through_input: false,
4064        }));
4065        let mut binder = Binder::new();
4066        let ctx = binder.bind(&plan).unwrap();
4067
4068        assert_eq!(ctx.get("b").unwrap().data_type, LogicalType::Bool);
4069        assert_eq!(ctx.get("i").unwrap().data_type, LogicalType::Int64);
4070        assert_eq!(ctx.get("f").unwrap().data_type, LogicalType::Float64);
4071        assert_eq!(ctx.get("s").unwrap().data_type, LogicalType::String);
4072        assert_eq!(ctx.get("l").unwrap().data_type, LogicalType::Any);
4073        assert_eq!(ctx.get("m").unwrap().data_type, LogicalType::Any);
4074        assert_eq!(ctx.get("n").unwrap().data_type, LogicalType::Any);
4075        assert_eq!(ctx.get("cnt").unwrap().data_type, LogicalType::Int64);
4076        assert_eq!(ctx.get("avg_val").unwrap().data_type, LogicalType::Float64);
4077        assert_eq!(ctx.get("tname").unwrap().data_type, LogicalType::String);
4078        assert_eq!(ctx.get("lbls").unwrap().data_type, LogicalType::Any);
4079        assert_eq!(ctx.get("u").unwrap().data_type, LogicalType::Any);
4080        assert_eq!(ctx.get("lit_list").unwrap().data_type, LogicalType::Any);
4081        assert_eq!(ctx.get("lit_map").unwrap().data_type, LogicalType::Any);
4082        assert_eq!(ctx.get("unary_ty").unwrap().data_type, LogicalType::Any);
4083        assert_eq!(ctx.get("bin_ty").unwrap().data_type, LogicalType::Any);
4084    }
4085
4086    #[test]
4087    fn test_infer_entity_status_for_case_projection() {
4088        use crate::query::plan::{ProjectOp, Projection};
4089
4090        // Case with both branches selecting node variables should propagate
4091        // node status. The planner uses this for optional()/union() rewrites.
4092        let plan = LogicalPlan::new(LogicalOperator::Project(ProjectOp {
4093            projections: vec![
4094                Projection {
4095                    expression: LogicalExpression::Variable("n".to_string()),
4096                    alias: Some("original".to_string()),
4097                },
4098                Projection {
4099                    expression: LogicalExpression::Case {
4100                        operand: None,
4101                        when_clauses: vec![(
4102                            LogicalExpression::Literal(grafeo_common::types::Value::Bool(true)),
4103                            LogicalExpression::Variable("n".to_string()),
4104                        )],
4105                        else_clause: Some(Box::new(LogicalExpression::Variable("n".to_string()))),
4106                    },
4107                    alias: Some("case_node".to_string()),
4108                },
4109                Projection {
4110                    // Empty Case (should produce neither node nor edge).
4111                    expression: LogicalExpression::Case {
4112                        operand: None,
4113                        when_clauses: vec![],
4114                        else_clause: None,
4115                    },
4116                    alias: Some("empty_case".to_string()),
4117                },
4118                Projection {
4119                    // Case mixing node var and literal should lose node-ness.
4120                    expression: LogicalExpression::Case {
4121                        operand: None,
4122                        when_clauses: vec![(
4123                            LogicalExpression::Literal(grafeo_common::types::Value::Bool(true)),
4124                            LogicalExpression::Variable("n".to_string()),
4125                        )],
4126                        else_clause: Some(Box::new(LogicalExpression::Literal(
4127                            grafeo_common::types::Value::Int64(0),
4128                        ))),
4129                    },
4130                    alias: Some("mixed_case".to_string()),
4131                },
4132            ],
4133            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
4134                variable: "n".to_string(),
4135                label: None,
4136                input: None,
4137            })),
4138            pass_through_input: false,
4139        }));
4140        let mut binder = Binder::new();
4141        let ctx = binder.bind(&plan).unwrap();
4142
4143        assert!(ctx.get("original").unwrap().is_node);
4144        assert!(ctx.get("case_node").unwrap().is_node);
4145        let empty = ctx.get("empty_case").unwrap();
4146        assert!(!empty.is_node && !empty.is_edge);
4147        let mixed = ctx.get("mixed_case").unwrap();
4148        assert!(
4149            !mixed.is_node && !mixed.is_edge,
4150            "mixed Case branches lose entity status"
4151        );
4152    }
4153
4154    // ========================================================================
4155    // Coverage tests: aggregate, register_subplan_columns
4156    // ========================================================================
4157
4158    #[test]
4159    fn test_bind_aggregate_registers_group_by_column_and_alias() {
4160        use crate::query::plan::{AggregateExpr, AggregateFunction, AggregateOp};
4161
4162        let plan = LogicalPlan::new(LogicalOperator::Aggregate(AggregateOp {
4163            group_by: vec![LogicalExpression::Property {
4164                variable: "n".to_string(),
4165                property: "city".to_string(),
4166            }],
4167            aggregates: vec![AggregateExpr {
4168                function: AggregateFunction::Count,
4169                expression: Some(LogicalExpression::Variable("n".to_string())),
4170                expression2: None,
4171                distinct: false,
4172                alias: Some("c".to_string()),
4173                percentile: None,
4174                separator: None,
4175            }],
4176            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
4177                variable: "n".to_string(),
4178                label: None,
4179                input: None,
4180            })),
4181            having: None,
4182        }));
4183        let mut binder = Binder::new();
4184        let ctx = binder.bind(&plan).unwrap();
4185        assert!(ctx.contains("c"), "aggregate alias registered");
4186        assert!(ctx.contains("n.city"), "group-by column name registered");
4187    }
4188
4189    #[test]
4190    fn test_apply_registers_aggregate_subplan_columns() {
4191        use crate::query::plan::{
4192            AggregateExpr, AggregateFunction, AggregateOp, ApplyOp, DistinctOp,
4193        };
4194
4195        // Apply whose subplan is Distinct(Aggregate(...)). This exercises the
4196        // Distinct -> Aggregate recursion inside register_subplan_columns.
4197        let subplan = LogicalOperator::Distinct(DistinctOp {
4198            input: Box::new(LogicalOperator::Aggregate(AggregateOp {
4199                group_by: vec![],
4200                aggregates: vec![AggregateExpr {
4201                    function: AggregateFunction::Count,
4202                    expression: None,
4203                    expression2: None,
4204                    distinct: false,
4205                    alias: Some("total".to_string()),
4206                    percentile: None,
4207                    separator: None,
4208                }],
4209                input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
4210                    variable: "n".to_string(),
4211                    label: None,
4212                    input: None,
4213                })),
4214                having: None,
4215            })),
4216            columns: None,
4217        });
4218
4219        let plan = LogicalPlan::new(LogicalOperator::Apply(ApplyOp {
4220            input: Box::new(LogicalOperator::Empty),
4221            subplan: Box::new(subplan),
4222            shared_variables: vec![],
4223            optional: false,
4224        }));
4225
4226        let mut binder = Binder::new();
4227        let ctx = binder.bind(&plan).unwrap();
4228        assert!(
4229            ctx.contains("total"),
4230            "aggregate alias surfaces through Distinct wrapper"
4231        );
4232    }
4233
4234    #[test]
4235    fn test_apply_registers_sort_and_limit_wrapped_return_columns() {
4236        use crate::query::plan::{ApplyOp, CountExpr, LimitOp, SortKey, SortOp, SortOrder};
4237
4238        // Apply(subplan = Limit(Sort(Return(var, property))))
4239        let subplan = LogicalOperator::Limit(LimitOp {
4240            count: CountExpr::Literal(10),
4241            input: Box::new(LogicalOperator::Sort(SortOp {
4242                keys: vec![SortKey {
4243                    expression: LogicalExpression::Variable("n".to_string()),
4244                    order: SortOrder::Ascending,
4245                    nulls: None,
4246                }],
4247                input: Box::new(LogicalOperator::Return(ReturnOp {
4248                    items: vec![
4249                        ReturnItem {
4250                            expression: LogicalExpression::Variable("n".to_string()),
4251                            alias: None,
4252                        },
4253                        ReturnItem {
4254                            expression: LogicalExpression::Property {
4255                                variable: "n".to_string(),
4256                                property: "name".to_string(),
4257                            },
4258                            alias: None,
4259                        },
4260                        ReturnItem {
4261                            // Literal with no alias hits the `_ => continue` branch.
4262                            expression: LogicalExpression::Literal(
4263                                grafeo_common::types::Value::Int64(1),
4264                            ),
4265                            alias: None,
4266                        },
4267                    ],
4268                    distinct: false,
4269                    input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
4270                        variable: "n".to_string(),
4271                        label: None,
4272                        input: None,
4273                    })),
4274                })),
4275            })),
4276        });
4277
4278        let plan = LogicalPlan::new(LogicalOperator::Apply(ApplyOp {
4279            input: Box::new(LogicalOperator::Empty),
4280            subplan: Box::new(subplan),
4281            shared_variables: vec![],
4282            optional: false,
4283        }));
4284
4285        let mut binder = Binder::new();
4286        let ctx = binder.bind(&plan).unwrap();
4287        // Unaliased Variable registers under the variable name itself.
4288        assert!(ctx.contains("n"));
4289        // Unaliased Property registers as "variable.property".
4290        assert!(ctx.contains("n.name"));
4291    }
4292
4293    #[test]
4294    fn test_horizontal_aggregate_is_noop_in_binder() {
4295        use crate::query::plan::{AggregateFunction, EntityKind, HorizontalAggregateOp};
4296
4297        let plan = LogicalPlan::new(LogicalOperator::HorizontalAggregate(
4298            HorizontalAggregateOp {
4299                list_column: "_path_edges_p".to_string(),
4300                entity_kind: EntityKind::Edge,
4301                function: AggregateFunction::Sum,
4302                property: "weight".to_string(),
4303                alias: "total".to_string(),
4304                input: Box::new(LogicalOperator::Empty),
4305            },
4306        ));
4307        let mut binder = Binder::new();
4308        assert!(binder.bind(&plan).is_ok());
4309    }
4310
4311    // ========================================================================
4312    // Coverage tests: expand path alias (variable-length paths)
4313    // ========================================================================
4314
4315    #[test]
4316    fn test_expand_with_path_alias_registers_auxiliary_variables() {
4317        use crate::query::plan::{ExpandDirection, ExpandOp, PathMode};
4318
4319        let plan = LogicalPlan::new(LogicalOperator::Expand(ExpandOp {
4320            from_variable: "a".to_string(),
4321            to_variable: "b".to_string(),
4322            edge_variable: None,
4323            direction: ExpandDirection::Outgoing,
4324            edge_types: vec!["ROAD".to_string()],
4325            min_hops: 1,
4326            max_hops: Some(5),
4327            input: Box::new(LogicalOperator::NodeScan(NodeScanOp {
4328                variable: "a".to_string(),
4329                label: None,
4330                input: None,
4331            })),
4332            path_alias: Some("p".to_string()),
4333            path_mode: PathMode::Walk,
4334        }));
4335        let mut binder = Binder::new();
4336        let ctx = binder.bind(&plan).unwrap();
4337        assert!(ctx.contains("p"));
4338        assert!(ctx.contains("_path_length_p"));
4339        assert!(ctx.contains("_path_nodes_p"));
4340        assert!(ctx.contains("_path_edges_p"));
4341    }
4342}