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