Skip to main content

lance_graph/
logical_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Logical planning for graph queries
5//!
6//! This module implements the logical planning phase of the query pipeline:
7//! Parse → Semantic Analysis → **Logical Plan** → Physical Plan
8//!
9//! Logical plans describe WHAT operations to perform, not HOW to perform them.
10
11use crate::ast::*;
12use crate::error::{GraphError, Result};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// A logical plan operator - describes what operation to perform
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub enum LogicalOperator {
19    /// Scan all nodes with a specific label
20    ScanByLabel {
21        variable: String,
22        label: String,
23        properties: HashMap<String, PropertyValue>,
24    },
25
26    /// Unwind a list into a sequence of rows
27    Unwind {
28        /// The input operator
29        input: Option<Box<LogicalOperator>>,
30        /// The expression to unwind (must yield a list)
31        expression: ValueExpression,
32        /// The alias for the unwound element
33        alias: String,
34    },
35
36    /// Apply a filter predicate (WHERE clause)
37    Filter {
38        input: Box<LogicalOperator>,
39        predicate: BooleanExpression,
40    },
41
42    /// Traverse relationships (the core graph operation)
43    ///
44    /// Represents a single-hop relationship traversal: (source)-[rel]->(target)
45    Expand {
46        /// The input operator (typically a node scan or previous expand)
47        input: Box<LogicalOperator>,
48        /// Variable name for the source node (e.g., "a" in (a)-[]->(b))
49        source_variable: String,
50        /// Variable name for the target node (e.g., "b" in (a)-[]->(b))
51        target_variable: String,
52        /// Label of the target node (e.g., "Person", "Book")
53        /// This is essential for looking up the correct schema during planning
54        target_label: String,
55        /// Types of relationships to traverse (e.g., ["KNOWS", "FRIEND_OF"])
56        relationship_types: Vec<String>,
57        /// Direction of traversal (Outgoing, Incoming, or Undirected)
58        direction: RelationshipDirection,
59        /// Optional variable name for the relationship itself (e.g., "r" in -[r]->)
60        relationship_variable: Option<String>,
61        /// Property filters to apply on the relationship
62        properties: HashMap<String, PropertyValue>,
63        /// Property filters to apply on the target node
64        target_properties: HashMap<String, PropertyValue>,
65    },
66
67    /// Variable-length path expansion (*1..2 syntax)
68    ///
69    /// Represents multi-hop relationship traversals: (source)-[rel*min..max]->(target)
70    /// This is implemented by unrolling into multiple fixed-length paths and unioning them
71    VariableLengthExpand {
72        /// The input operator (typically a node scan)
73        input: Box<LogicalOperator>,
74        /// Variable name for the source node
75        source_variable: String,
76        /// Variable name for the target node (reachable in min..max hops)
77        target_variable: String,
78        /// Types of relationships to traverse in each hop
79        relationship_types: Vec<String>,
80        /// Direction of traversal for each hop
81        direction: RelationshipDirection,
82        /// Optional variable name for the relationship pattern
83        relationship_variable: Option<String>,
84        /// Minimum number of hops (defaults to 1 if None)
85        min_length: Option<u32>,
86        /// Maximum number of hops (defaults to system max if None)
87        max_length: Option<u32>,
88        /// Property filters to apply on target nodes
89        target_properties: HashMap<String, PropertyValue>,
90    },
91
92    /// Project specific columns (RETURN clause)
93    Project {
94        input: Box<LogicalOperator>,
95        projections: Vec<ProjectionItem>,
96    },
97
98    /// Join multiple disconnected patterns
99    Join {
100        left: Box<LogicalOperator>,
101        right: Box<LogicalOperator>,
102        join_type: JoinType,
103    },
104
105    /// Apply DISTINCT
106    Distinct { input: Box<LogicalOperator> },
107
108    /// Apply ORDER BY
109    Sort {
110        input: Box<LogicalOperator>,
111        sort_items: Vec<SortItem>,
112    },
113
114    /// Apply SKIP/OFFSET
115    Offset {
116        input: Box<LogicalOperator>,
117        offset: u64,
118    },
119
120    /// Apply LIMIT
121    Limit {
122        input: Box<LogicalOperator>,
123        count: u64,
124    },
125}
126
127/// Projection item for SELECT/RETURN clauses
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct ProjectionItem {
130    pub expression: ValueExpression,
131    pub alias: Option<String>,
132}
133
134/// Join types for combining multiple patterns
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub enum JoinType {
137    Inner,
138    Left,
139    Right,
140    Full,
141    Cross,
142}
143
144/// Sort specification for ORDER BY
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct SortItem {
147    pub expression: ValueExpression,
148    pub direction: SortDirection,
149}
150
151/// Logical plan builder - converts AST to logical plan
152pub struct LogicalPlanner {
153    /// Track variables in scope
154    variables: HashMap<String, String>, // variable -> label
155}
156
157impl LogicalPlanner {
158    pub fn new() -> Self {
159        Self {
160            variables: HashMap::new(),
161        }
162    }
163
164    /// Convert a Cypher AST to a logical plan
165    pub fn plan(&mut self, query: &CypherQuery) -> Result<LogicalOperator> {
166        // Plan main MATCH clauses
167        let mut plan = self.plan_reading_clauses(None, &query.reading_clauses)?;
168
169        // Apply WHERE clause if present (before WITH)
170        if let Some(where_clause) = &query.where_clause {
171            plan = LogicalOperator::Filter {
172                input: Box::new(plan),
173                predicate: where_clause.expression.clone(),
174            };
175        }
176
177        // Apply WITH clause if present (intermediate projection/aggregation)
178        if let Some(with_clause) = &query.with_clause {
179            plan = self.plan_with_clause(with_clause, plan)?;
180        }
181
182        // Plan post-WITH MATCH clauses
183        if !query.post_with_reading_clauses.is_empty() {
184            plan = self.plan_reading_clauses(Some(plan), &query.post_with_reading_clauses)?;
185        }
186
187        // Apply post-WITH WHERE clause if present
188        if let Some(post_where) = &query.post_with_where_clause {
189            plan = LogicalOperator::Filter {
190                input: Box::new(plan),
191                predicate: post_where.expression.clone(),
192            };
193        }
194
195        // Apply RETURN clause
196        plan = self.plan_return_clause(&query.return_clause, plan)?;
197
198        // Apply ORDER BY if present
199        if let Some(order_by) = &query.order_by {
200            plan = LogicalOperator::Sort {
201                input: Box::new(plan),
202                sort_items: order_by
203                    .items
204                    .iter()
205                    .map(|item| SortItem {
206                        expression: item.expression.clone(),
207                        direction: item.direction.clone(),
208                    })
209                    .collect(),
210            };
211        }
212
213        // Apply SKIP/OFFSET if present
214        if let Some(skip) = query.skip {
215            plan = LogicalOperator::Offset {
216                input: Box::new(plan),
217                offset: skip,
218            };
219        }
220
221        // Apply LIMIT if present
222        if let Some(limit) = query.limit {
223            plan = LogicalOperator::Limit {
224                input: Box::new(plan),
225                count: limit,
226            };
227        }
228
229        Ok(plan)
230    }
231
232    fn plan_reading_clauses(
233        &mut self,
234        base_plan: Option<LogicalOperator>,
235        reading_clauses: &[ReadingClause],
236    ) -> Result<LogicalOperator> {
237        let mut plan = base_plan;
238
239        if reading_clauses.is_empty() && plan.is_none() {
240            return Err(GraphError::PlanError {
241                message: "Query must have at least one MATCH or UNWIND clause".to_string(),
242                location: snafu::Location::new(file!(), line!(), column!()),
243            });
244        }
245
246        for clause in reading_clauses {
247            plan = Some(self.plan_reading_clause_with_base(plan, clause)?);
248        }
249
250        plan.ok_or_else(|| GraphError::PlanError {
251            message: "Failed to plan clauses".to_string(),
252            location: snafu::Location::new(file!(), line!(), column!()),
253        })
254    }
255
256    /// Plan a single READING clause, optionally starting from an existing base plan
257    fn plan_reading_clause_with_base(
258        &mut self,
259        base: Option<LogicalOperator>,
260        clause: &ReadingClause,
261    ) -> Result<LogicalOperator> {
262        match clause {
263            ReadingClause::Match(match_clause) => {
264                self.plan_match_clause_with_base(base, match_clause)
265            }
266            ReadingClause::Unwind(unwind_clause) => {
267                self.plan_unwind_clause_with_base(base, unwind_clause)
268            }
269        }
270    }
271
272    /// Plan an UNWIND clause
273    fn plan_unwind_clause_with_base(
274        &mut self,
275        base: Option<LogicalOperator>,
276        unwind_clause: &UnwindClause,
277    ) -> Result<LogicalOperator> {
278        // Register the alias variable
279        self.variables
280            .insert(unwind_clause.alias.clone(), "Unwound".to_string());
281
282        Ok(LogicalOperator::Unwind {
283            input: base.map(Box::new),
284            expression: unwind_clause.expression.clone(),
285            alias: unwind_clause.alias.clone(),
286        })
287    }
288
289    /// Plan a single MATCH clause, optionally starting from an existing base plan
290    fn plan_match_clause_with_base(
291        &mut self,
292        base: Option<LogicalOperator>,
293        match_clause: &MatchClause,
294    ) -> Result<LogicalOperator> {
295        if match_clause.patterns.is_empty() {
296            return Err(GraphError::PlanError {
297                message: "MATCH clause must have at least one pattern".to_string(),
298                location: snafu::Location::new(file!(), line!(), column!()),
299            });
300        }
301
302        let mut plan = base;
303        for pattern in &match_clause.patterns {
304            match pattern {
305                GraphPattern::Node(node) => {
306                    let already_bound = node
307                        .variable
308                        .as_deref()
309                        .is_some_and(|v| self.variables.contains_key(v));
310
311                    match (already_bound, plan.as_ref()) {
312                        (true, _) => { /* no-op */ }
313                        (false, None) => plan = Some(self.plan_node_scan(node)?),
314                        (false, Some(_)) => {
315                            let right = self.plan_node_scan(node)?;
316                            plan = Some(LogicalOperator::Join {
317                                left: Box::new(plan.unwrap()),
318                                right: Box::new(right),
319                                join_type: JoinType::Cross, // TODO: infer better join type based on shared vars
320                            });
321                        }
322                    }
323                }
324                GraphPattern::Path(path) => plan = Some(self.plan_path(plan, path)?),
325            }
326        }
327
328        plan.ok_or_else(|| GraphError::PlanError {
329            message: "Failed to plan MATCH clause".to_string(),
330            location: snafu::Location::new(file!(), line!(), column!()),
331        })
332    }
333
334    /// Plan a node scan (ScanByLabel)
335    fn plan_node_scan(&mut self, node: &NodePattern) -> Result<LogicalOperator> {
336        let variable = node
337            .variable
338            .clone()
339            .unwrap_or_else(|| format!("_node_{}", self.variables.len()));
340
341        // Validate label consistency if variable already exists
342        self.validate_variable_label(&variable, &node.labels)?;
343
344        // Reuse existing label or derive from AST (default to "Node")
345        let label = self
346            .variables
347            .get(&variable)
348            .cloned()
349            .or_else(|| node.labels.first().cloned())
350            .unwrap_or_else(|| "Node".to_string());
351
352        // Register variable with its label
353        self.variables.insert(variable.clone(), label.clone());
354
355        Ok(LogicalOperator::ScanByLabel {
356            variable,
357            label,
358            properties: node.properties.clone(),
359        })
360    }
361
362    /// Validate that a node's explicit label (if any) matches the already-registered
363    /// label for this variable. Returns `Ok(())` if the variable is new or if labels
364    /// are consistent, and an error if they conflict.
365    fn validate_variable_label(&self, variable: &str, ast_labels: &[String]) -> Result<()> {
366        if let Some(existing_label) = self.variables.get(variable) {
367            if let Some(ast_label) = ast_labels.first() {
368                if ast_label != existing_label {
369                    return Err(GraphError::PlanError {
370                        message: format!(
371                            "Variable '{}' already has label '{}', cannot redefine as '{}'",
372                            variable, existing_label, ast_label
373                        ),
374                        location: snafu::Location::new(file!(), line!(), column!()),
375                    });
376                }
377            }
378        }
379        Ok(())
380    }
381
382    /// Plan a full path pattern, respecting the starting variable if provided
383    fn plan_path(
384        &mut self,
385        base: Option<LogicalOperator>,
386        path: &PathPattern,
387    ) -> Result<LogicalOperator> {
388        // Establish a base plan
389        let mut plan = if let Some(p) = base {
390            p
391        } else {
392            self.plan_node_scan(&path.start_node)?
393        };
394
395        // Determine the current source variable for the first hop
396        let mut current_src = match &path.start_node.variable {
397            Some(var) => var.clone(),
398            None => self.extract_variable_from_plan(&plan)?,
399        };
400
401        // Validate start node label consistency if variable already exists
402        if let Some(start_var) = &path.start_node.variable {
403            self.validate_variable_label(start_var, &path.start_node.labels)?;
404        }
405
406        // For each segment, add an expand
407        for segment in &path.segments {
408            // Determine / register target variable
409            let target_variable = segment
410                .end_node
411                .variable
412                .clone()
413                .unwrap_or_else(|| format!("_node_{}", self.variables.len()));
414
415            // Validate label consistency for already-registered variables
416            self.validate_variable_label(&target_variable, &segment.end_node.labels)?;
417
418            // Reuse existing label or derive from AST (default to "Node")
419            let target_label = self
420                .variables
421                .get(&target_variable)
422                .cloned()
423                .or_else(|| segment.end_node.labels.first().cloned())
424                .unwrap_or_else(|| "Node".to_string());
425
426            self.variables
427                .insert(target_variable.clone(), target_label.clone());
428
429            // Optimize fixed-length var-length expansions (*1 or *1..1)
430            let next_plan = match segment.relationship.length.as_ref() {
431                Some(length_range)
432                    if length_range.min == Some(1) && length_range.max == Some(1) =>
433                {
434                    LogicalOperator::Expand {
435                        input: Box::new(plan),
436                        source_variable: current_src.clone(),
437                        target_variable: target_variable.clone(),
438                        target_label: target_label.clone(),
439                        relationship_types: segment.relationship.types.clone(),
440                        direction: segment.relationship.direction.clone(),
441                        relationship_variable: segment.relationship.variable.clone(),
442                        properties: segment.relationship.properties.clone(),
443                        target_properties: segment.end_node.properties.clone(),
444                    }
445                }
446                Some(length_range) => LogicalOperator::VariableLengthExpand {
447                    input: Box::new(plan),
448                    source_variable: current_src.clone(),
449                    target_variable: target_variable.clone(),
450                    relationship_types: segment.relationship.types.clone(),
451                    direction: segment.relationship.direction.clone(),
452                    relationship_variable: segment.relationship.variable.clone(),
453                    min_length: length_range.min,
454                    max_length: length_range.max,
455                    target_properties: segment.end_node.properties.clone(),
456                },
457                None => LogicalOperator::Expand {
458                    input: Box::new(plan),
459                    source_variable: current_src.clone(),
460                    target_variable: target_variable.clone(),
461                    target_label: target_label.clone(),
462                    relationship_types: segment.relationship.types.clone(),
463                    direction: segment.relationship.direction.clone(),
464                    relationship_variable: segment.relationship.variable.clone(),
465                    properties: segment.relationship.properties.clone(),
466                    target_properties: segment.end_node.properties.clone(),
467                },
468            };
469
470            plan = next_plan;
471            current_src = target_variable;
472        }
473
474        Ok(plan)
475    }
476
477    /// Extract the main variable from a logical plan (for chaining)
478    #[allow(clippy::only_used_in_recursion)]
479    fn extract_variable_from_plan(&self, plan: &LogicalOperator) -> Result<String> {
480        match plan {
481            LogicalOperator::ScanByLabel { variable, .. } => Ok(variable.clone()),
482            LogicalOperator::Unwind { alias, .. } => Ok(alias.clone()),
483            LogicalOperator::Expand {
484                target_variable, ..
485            } => Ok(target_variable.clone()),
486            LogicalOperator::VariableLengthExpand {
487                target_variable, ..
488            } => Ok(target_variable.clone()),
489            LogicalOperator::Filter { input, .. } => self.extract_variable_from_plan(input),
490            LogicalOperator::Project { input, .. } => self.extract_variable_from_plan(input),
491            LogicalOperator::Distinct { input } => self.extract_variable_from_plan(input),
492            LogicalOperator::Sort { input, .. } => self.extract_variable_from_plan(input),
493            LogicalOperator::Offset { input, .. } => self.extract_variable_from_plan(input),
494            LogicalOperator::Limit { input, .. } => self.extract_variable_from_plan(input),
495            LogicalOperator::Join { left, right, .. } => {
496                // Prefer the right branch's tail variable, else fall back to left
497                self.extract_variable_from_plan(right)
498                    .or_else(|_| self.extract_variable_from_plan(left))
499            }
500        }
501    }
502
503    /// Plan RETURN clause (Project)
504    fn plan_return_clause(
505        &self,
506        return_clause: &ReturnClause,
507        input: LogicalOperator,
508    ) -> Result<LogicalOperator> {
509        let projections = return_clause
510            .items
511            .iter()
512            .map(|item| ProjectionItem {
513                expression: item.expression.clone(),
514                alias: item.alias.clone(),
515            })
516            .collect();
517
518        let mut plan = LogicalOperator::Project {
519            input: Box::new(input),
520            projections,
521        };
522
523        // Add DISTINCT if needed
524        if return_clause.distinct {
525            plan = LogicalOperator::Distinct {
526                input: Box::new(plan),
527            };
528        }
529
530        Ok(plan)
531    }
532
533    /// Plan WITH clause - intermediate projection/aggregation with optional ORDER BY and LIMIT
534    fn plan_with_clause(
535        &self,
536        with_clause: &WithClause,
537        input: LogicalOperator,
538    ) -> Result<LogicalOperator> {
539        // WITH creates a projection (like RETURN)
540        let projections = with_clause
541            .items
542            .iter()
543            .map(|item| ProjectionItem {
544                expression: item.expression.clone(),
545                alias: item.alias.clone(),
546            })
547            .collect();
548
549        let mut plan = LogicalOperator::Project {
550            input: Box::new(input),
551            projections,
552        };
553
554        // Apply ORDER BY within WITH if present
555        if let Some(order_by) = &with_clause.order_by {
556            plan = LogicalOperator::Sort {
557                input: Box::new(plan),
558                sort_items: order_by
559                    .items
560                    .iter()
561                    .map(|item| SortItem {
562                        expression: item.expression.clone(),
563                        direction: item.direction.clone(),
564                    })
565                    .collect(),
566            };
567        }
568
569        // Apply LIMIT within WITH if present
570        if let Some(limit) = with_clause.limit {
571            plan = LogicalOperator::Limit {
572                input: Box::new(plan),
573                count: limit,
574            };
575        }
576
577        Ok(plan)
578    }
579}
580
581impl Default for LogicalPlanner {
582    fn default() -> Self {
583        Self::new()
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use crate::parser::parse_cypher_query;
591
592    #[test]
593    fn test_relationship_query_logical_plan_structure() {
594        let query_text = r#"MATCH (p:Person {name: "Alice"})-[:KNOWS]->(f:Person) WHERE f.age > 30 RETURN f.name"#;
595
596        // Parse the query
597        let ast = parse_cypher_query(query_text).unwrap();
598
599        // Plan to logical operators
600        let mut planner = LogicalPlanner::new();
601        let logical_plan = planner.plan(&ast).unwrap();
602
603        // Verify the overall structure is a projection
604        match &logical_plan {
605            LogicalOperator::Project { input, projections } => {
606                // Verify projection is f.name
607                assert_eq!(projections.len(), 1);
608                match &projections[0].expression {
609                    ValueExpression::Property(prop_ref) => {
610                        assert_eq!(prop_ref.variable, "f");
611                        assert_eq!(prop_ref.property, "name");
612                    }
613                    _ => panic!("Expected property reference for f.name"),
614                }
615
616                // Verify input is a filter for f.age > 30
617                match input.as_ref() {
618                    LogicalOperator::Filter {
619                        predicate,
620                        input: filter_input,
621                    } => {
622                        // Verify the predicate is f.age > 30
623                        match predicate {
624                            BooleanExpression::Comparison {
625                                left,
626                                operator,
627                                right,
628                            } => {
629                                match left {
630                                    ValueExpression::Property(prop_ref) => {
631                                        assert_eq!(prop_ref.variable, "f");
632                                        assert_eq!(prop_ref.property, "age");
633                                    }
634                                    _ => panic!("Expected property reference for f.age"),
635                                }
636                                assert_eq!(*operator, ComparisonOperator::GreaterThan);
637                                match right {
638                                    ValueExpression::Literal(PropertyValue::Integer(val)) => {
639                                        assert_eq!(*val, 30);
640                                    }
641                                    _ => panic!("Expected integer literal 30"),
642                                }
643                            }
644                            _ => panic!("Expected comparison expression"),
645                        }
646
647                        // Verify the input to the filter is an expand operation
648                        match filter_input.as_ref() {
649                            LogicalOperator::Expand {
650                                input: expand_input,
651                                source_variable,
652                                target_variable,
653                                relationship_types,
654                                direction,
655                                ..
656                            } => {
657                                assert_eq!(source_variable, "p");
658                                assert_eq!(target_variable, "f");
659                                assert_eq!(relationship_types, &vec!["KNOWS".to_string()]);
660                                assert_eq!(*direction, RelationshipDirection::Outgoing);
661
662                                // Verify the input to expand is a scan with properties for p.name = 'Alice'
663                                match expand_input.as_ref() {
664                                    LogicalOperator::ScanByLabel {
665                                        variable,
666                                        label,
667                                        properties,
668                                    } => {
669                                        assert_eq!(variable, "p");
670                                        assert_eq!(label, "Person");
671
672                                        // Verify the properties contain name = "Alice"
673                                        assert_eq!(properties.len(), 1);
674                                        match properties.get("name") {
675                                            Some(PropertyValue::String(val)) => {
676                                                assert_eq!(val, "Alice");
677                                            }
678                                            _ => {
679                                                panic!("Expected name property with value 'Alice'")
680                                            }
681                                        }
682                                    }
683                                    _ => panic!("Expected ScanByLabel with properties for Person"),
684                                }
685                            }
686                            _ => panic!("Expected Expand operation"),
687                        }
688                    }
689                    _ => panic!("Expected Filter for f.age > 30"),
690                }
691            }
692            _ => panic!("Expected Project at the top level"),
693        }
694    }
695
696    #[test]
697    fn test_simple_node_query_logical_plan() {
698        let query_text = "MATCH (n:Person) RETURN n.name";
699
700        let ast = parse_cypher_query(query_text).unwrap();
701        let mut planner = LogicalPlanner::new();
702        let logical_plan = planner.plan(&ast).unwrap();
703
704        // Should be: Project { input: ScanByLabel }
705        match &logical_plan {
706            LogicalOperator::Project { input, projections } => {
707                assert_eq!(projections.len(), 1);
708                match input.as_ref() {
709                    LogicalOperator::ScanByLabel {
710                        variable, label, ..
711                    } => {
712                        assert_eq!(variable, "n");
713                        assert_eq!(label, "Person");
714                    }
715                    _ => panic!("Expected ScanByLabel"),
716                }
717            }
718            _ => panic!("Expected Project"),
719        }
720    }
721
722    #[test]
723    fn test_node_with_properties_logical_plan() {
724        let query_text = "MATCH (n:Person {age: 25}) RETURN n.name";
725
726        let ast = parse_cypher_query(query_text).unwrap();
727        let mut planner = LogicalPlanner::new();
728        let logical_plan = planner.plan(&ast).unwrap();
729
730        // Should be: Project { input: ScanByLabel with properties }
731        // Properties from MATCH clause are pushed down to the scan level
732        match &logical_plan {
733            LogicalOperator::Project { input, .. } => {
734                match input.as_ref() {
735                    LogicalOperator::ScanByLabel {
736                        variable,
737                        label,
738                        properties,
739                    } => {
740                        assert_eq!(variable, "n");
741                        assert_eq!(label, "Person");
742
743                        // Verify the properties are in the scan
744                        assert_eq!(properties.len(), 1);
745                        match properties.get("age") {
746                            Some(PropertyValue::Integer(25)) => {}
747                            _ => panic!("Expected age property with value 25"),
748                        }
749                    }
750                    _ => panic!("Expected ScanByLabel with properties"),
751                }
752            }
753            _ => panic!("Expected Project"),
754        }
755    }
756
757    #[test]
758    fn test_variable_length_path_logical_plan() {
759        let query_text = "MATCH (a:Person)-[:KNOWS*1..2]->(b:Person) RETURN b.name";
760
761        let ast = parse_cypher_query(query_text).unwrap();
762        let mut planner = LogicalPlanner::new();
763        let logical_plan = planner.plan(&ast).unwrap();
764
765        // Should be: Project { input: VariableLengthExpand { input: ScanByLabel } }
766        match &logical_plan {
767            LogicalOperator::Project { input, .. } => match input.as_ref() {
768                LogicalOperator::VariableLengthExpand {
769                    input: expand_input,
770                    source_variable,
771                    target_variable,
772                    relationship_types,
773                    min_length,
774                    max_length,
775                    ..
776                } => {
777                    assert_eq!(source_variable, "a");
778                    assert_eq!(target_variable, "b");
779                    assert_eq!(relationship_types, &vec!["KNOWS".to_string()]);
780                    assert_eq!(*min_length, Some(1));
781                    assert_eq!(*max_length, Some(2));
782
783                    match expand_input.as_ref() {
784                        LogicalOperator::ScanByLabel {
785                            variable, label, ..
786                        } => {
787                            assert_eq!(variable, "a");
788                            assert_eq!(label, "Person");
789                        }
790                        _ => panic!("Expected ScanByLabel"),
791                    }
792                }
793                _ => panic!("Expected VariableLengthExpand"),
794            },
795            _ => panic!("Expected Project"),
796        }
797    }
798
799    #[test]
800    fn test_where_clause_logical_plan() {
801        // Note: Current parser only supports simple comparisons, not AND/OR
802        let query_text = r#"MATCH (n:Person) WHERE n.age > 25 RETURN n.name"#;
803
804        let ast = parse_cypher_query(query_text).unwrap();
805        let mut planner = LogicalPlanner::new();
806        let logical_plan = planner.plan(&ast).unwrap();
807
808        // Should be: Project { input: Filter { input: ScanByLabel } }
809        match &logical_plan {
810            LogicalOperator::Project { input, .. } => {
811                match input.as_ref() {
812                    LogicalOperator::Filter {
813                        predicate,
814                        input: scan_input,
815                    } => {
816                        // Verify it's a simple comparison: n.age > 25
817                        match predicate {
818                            BooleanExpression::Comparison {
819                                left,
820                                operator,
821                                right: _,
822                            } => {
823                                match left {
824                                    ValueExpression::Property(prop_ref) => {
825                                        assert_eq!(prop_ref.variable, "n");
826                                        assert_eq!(prop_ref.property, "age");
827                                    }
828                                    _ => panic!("Expected property reference for age"),
829                                }
830                                assert_eq!(*operator, ComparisonOperator::GreaterThan);
831                            }
832                            _ => panic!("Expected comparison expression"),
833                        }
834
835                        match scan_input.as_ref() {
836                            LogicalOperator::ScanByLabel { .. } => {}
837                            _ => panic!("Expected ScanByLabel"),
838                        }
839                    }
840                    _ => panic!("Expected Filter"),
841                }
842            }
843            _ => panic!("Expected Project"),
844        }
845    }
846
847    #[test]
848    fn test_multiple_node_patterns_join_in_match() {
849        let query_text = "MATCH (a:Person), (b:Company) RETURN a.name, b.name";
850
851        let ast = parse_cypher_query(query_text).unwrap();
852        let mut planner = LogicalPlanner::new();
853        let logical_plan = planner.plan(&ast).unwrap();
854
855        // Expect: Project { input: Join { left: Scan(a:Person), right: Scan(b:Company) } }
856        match &logical_plan {
857            LogicalOperator::Project { input, projections } => {
858                assert_eq!(projections.len(), 2);
859                match input.as_ref() {
860                    LogicalOperator::Join {
861                        left,
862                        right,
863                        join_type,
864                    } => {
865                        assert!(matches!(join_type, JoinType::Cross));
866                        match left.as_ref() {
867                            LogicalOperator::ScanByLabel {
868                                variable, label, ..
869                            } => {
870                                assert_eq!(variable, "a");
871                                assert_eq!(label, "Person");
872                            }
873                            _ => panic!("Expected left ScanByLabel for a:Person"),
874                        }
875                        match right.as_ref() {
876                            LogicalOperator::ScanByLabel {
877                                variable, label, ..
878                            } => {
879                                assert_eq!(variable, "b");
880                                assert_eq!(label, "Company");
881                            }
882                            _ => panic!("Expected right ScanByLabel for b:Company"),
883                        }
884                    }
885                    _ => panic!("Expected Join after Project"),
886                }
887            }
888            _ => panic!("Expected Project at top level"),
889        }
890    }
891
892    #[test]
893    fn test_shared_variable_chained_paths_in_match() {
894        let query_text =
895            "MATCH (a:Person)-[:KNOWS]->(b:Person), (b)-[:LIKES]->(c:Thing) RETURN c.name";
896
897        let ast = parse_cypher_query(query_text).unwrap();
898        let mut planner = LogicalPlanner::new();
899        let logical_plan = planner.plan(&ast).unwrap();
900
901        // Expect: Project { input: Expand (b->c) { input: Expand (a->b) { input: Scan(a) } } }
902        match &logical_plan {
903            LogicalOperator::Project { input, .. } => match input.as_ref() {
904                LogicalOperator::Expand {
905                    source_variable: src2,
906                    target_variable: tgt2,
907                    input: inner2,
908                    ..
909                } => {
910                    assert_eq!(src2, "b");
911                    assert_eq!(tgt2, "c");
912                    match inner2.as_ref() {
913                        LogicalOperator::Expand {
914                            source_variable: src1,
915                            target_variable: tgt1,
916                            input: inner1,
917                            ..
918                        } => {
919                            assert_eq!(src1, "a");
920                            assert_eq!(tgt1, "b");
921                            match inner1.as_ref() {
922                                LogicalOperator::ScanByLabel {
923                                    variable, label, ..
924                                } => {
925                                    assert_eq!(variable, "a");
926                                    assert_eq!(label, "Person");
927                                }
928                                _ => panic!("Expected ScanByLabel for a:Person"),
929                            }
930                        }
931                        _ => panic!("Expected first Expand a->b"),
932                    }
933                }
934                _ => panic!("Expected second Expand b->c at top of input"),
935            },
936            _ => panic!("Expected Project at top level"),
937        }
938    }
939
940    #[test]
941    fn test_fixed_length_variable_path_is_expand() {
942        let query_text = "MATCH (a:Person)-[:KNOWS*1..1]->(b:Person) RETURN b.name";
943
944        let ast = parse_cypher_query(query_text).unwrap();
945        let mut planner = LogicalPlanner::new();
946        let logical_plan = planner.plan(&ast).unwrap();
947
948        match &logical_plan {
949            LogicalOperator::Project { input, .. } => match input.as_ref() {
950                LogicalOperator::Expand {
951                    source_variable,
952                    target_variable,
953                    ..
954                } => {
955                    assert_eq!(source_variable, "a");
956                    assert_eq!(target_variable, "b");
957                }
958                _ => panic!("Expected Expand for fixed-length *1..1"),
959            },
960            _ => panic!("Expected Project at top level"),
961        }
962    }
963
964    #[test]
965    fn test_distinct_and_order_limit_wrapping() {
966        // DISTINCT should wrap Project with Distinct
967        let q1 = "MATCH (n:Person) RETURN DISTINCT n.name";
968        let ast1 = parse_cypher_query(q1).unwrap();
969        let mut planner = LogicalPlanner::new();
970        let logical1 = planner.plan(&ast1).unwrap();
971        match logical1 {
972            LogicalOperator::Distinct { input } => match *input {
973                LogicalOperator::Project { .. } => {}
974                _ => panic!("Expected Project under Distinct"),
975            },
976            _ => panic!("Expected Distinct at top level"),
977        }
978
979        // ORDER BY + LIMIT should be Limit(Sort(Project(..)))
980        let q2 = "MATCH (n:Person) RETURN n.name ORDER BY n.name LIMIT 10";
981        let ast2 = parse_cypher_query(q2).unwrap();
982        let mut planner2 = LogicalPlanner::new();
983        let logical2 = planner2.plan(&ast2).unwrap();
984        match logical2 {
985            LogicalOperator::Limit { input, count } => {
986                assert_eq!(count, 10);
987                match *input {
988                    LogicalOperator::Sort { input: inner, .. } => match *inner {
989                        LogicalOperator::Project { .. } => {}
990                        _ => panic!("Expected Project under Sort"),
991                    },
992                    _ => panic!("Expected Sort under Limit"),
993                }
994            }
995            _ => panic!("Expected Limit at top level"),
996        }
997    }
998
999    #[test]
1000    fn test_order_skip_limit_wrapping() {
1001        // ORDER BY + SKIP + LIMIT should be Limit(Offset(Sort(Project(..))))
1002        let q = "MATCH (n:Person) RETURN n.name ORDER BY n.name SKIP 5 LIMIT 10";
1003        let ast = parse_cypher_query(q).unwrap();
1004        let mut planner = LogicalPlanner::new();
1005        let logical = planner.plan(&ast).unwrap();
1006        match logical {
1007            LogicalOperator::Limit { input, count } => {
1008                assert_eq!(count, 10);
1009                match *input {
1010                    LogicalOperator::Offset {
1011                        input: inner,
1012                        offset,
1013                    } => {
1014                        assert_eq!(offset, 5);
1015                        match *inner {
1016                            LogicalOperator::Sort { input: inner2, .. } => match *inner2 {
1017                                LogicalOperator::Project { .. } => {}
1018                                _ => panic!("Expected Project under Sort"),
1019                            },
1020                            _ => panic!("Expected Sort under Offset"),
1021                        }
1022                    }
1023                    _ => panic!("Expected Offset under Limit"),
1024                }
1025            }
1026            _ => panic!("Expected Limit at top level"),
1027        }
1028    }
1029
1030    #[test]
1031    fn test_skip_only_wrapping() {
1032        // SKIP only should be Offset(Project(..))
1033        let q = "MATCH (n:Person) RETURN n.name SKIP 3";
1034        let ast = parse_cypher_query(q).unwrap();
1035        let mut planner = LogicalPlanner::new();
1036        let logical = planner.plan(&ast).unwrap();
1037        match logical {
1038            LogicalOperator::Offset { input, offset } => {
1039                assert_eq!(offset, 3);
1040                match *input {
1041                    LogicalOperator::Project { .. } => {}
1042                    _ => panic!("Expected Project under Offset"),
1043                }
1044            }
1045            _ => panic!("Expected Offset at top level"),
1046        }
1047    }
1048
1049    #[test]
1050    fn test_relationship_properties_pushed_into_expand() {
1051        let q = "MATCH (a)-[:KNOWS {since: 2020}]->(b) RETURN b";
1052        let ast = parse_cypher_query(q).unwrap();
1053        let mut planner = LogicalPlanner::new();
1054        let logical = planner.plan(&ast).unwrap();
1055        match logical {
1056            LogicalOperator::Project { input, .. } => match *input {
1057                LogicalOperator::Expand { properties, .. } => match properties.get("since") {
1058                    Some(PropertyValue::Integer(2020)) => {}
1059                    _ => panic!("Expected relationship property since=2020 in Expand"),
1060                },
1061                _ => panic!("Expected Expand under Project"),
1062            },
1063            _ => panic!("Expected Project at top level"),
1064        }
1065    }
1066
1067    #[test]
1068    fn test_multiple_match_clauses_cross_join() {
1069        let q = "MATCH (a:Person) MATCH (b:Company) RETURN a.name, b.name";
1070        let ast = parse_cypher_query(q).unwrap();
1071        let mut planner = LogicalPlanner::new();
1072        let logical = planner.plan(&ast).unwrap();
1073        match logical {
1074            LogicalOperator::Project { input, .. } => match *input {
1075                LogicalOperator::Join {
1076                    left,
1077                    right,
1078                    join_type,
1079                } => {
1080                    assert!(matches!(join_type, JoinType::Cross));
1081                    match (*left, *right) {
1082                        (
1083                            LogicalOperator::ScanByLabel {
1084                                variable: va,
1085                                label: la,
1086                                ..
1087                            },
1088                            LogicalOperator::ScanByLabel {
1089                                variable: vb,
1090                                label: lb,
1091                                ..
1092                            },
1093                        ) => {
1094                            assert_eq!(va, "a");
1095                            assert_eq!(la, "Person");
1096                            assert_eq!(vb, "b");
1097                            assert_eq!(lb, "Company");
1098                        }
1099                        _ => panic!("Expected two scans under Join"),
1100                    }
1101                }
1102                _ => panic!("Expected Join under Project"),
1103            },
1104            _ => panic!("Expected Project at top level"),
1105        }
1106    }
1107
1108    #[test]
1109    fn test_variable_only_node_default_label() {
1110        let q = "MATCH (x) RETURN x";
1111        let ast = parse_cypher_query(q).unwrap();
1112        let mut planner = LogicalPlanner::new();
1113        let logical = planner.plan(&ast).unwrap();
1114        match logical {
1115            LogicalOperator::Project { input, .. } => match *input {
1116                LogicalOperator::ScanByLabel {
1117                    variable, label, ..
1118                } => {
1119                    assert_eq!(variable, "x");
1120                    assert_eq!(label, "Node");
1121                }
1122                _ => panic!("Expected ScanByLabel under Project"),
1123            },
1124            _ => panic!("Expected Project at top level"),
1125        }
1126    }
1127
1128    #[test]
1129    fn test_multi_label_node_uses_first_label() {
1130        let q = "MATCH (n:Person:Employee) RETURN n";
1131        let ast = parse_cypher_query(q).unwrap();
1132        let mut planner = LogicalPlanner::new();
1133        let logical = planner.plan(&ast).unwrap();
1134        match logical {
1135            LogicalOperator::Project { input, .. } => match *input {
1136                LogicalOperator::ScanByLabel { label, .. } => {
1137                    assert_eq!(label, "Person");
1138                }
1139                _ => panic!("Expected ScanByLabel under Project"),
1140            },
1141            _ => panic!("Expected Project at top level"),
1142        }
1143    }
1144
1145    #[test]
1146    fn test_open_ended_and_partial_var_length_ranges() {
1147        // * (unbounded)
1148        let q1 = "MATCH (a)-[:R*]->(b) RETURN b";
1149        let ast1 = parse_cypher_query(q1).unwrap();
1150        let mut planner1 = LogicalPlanner::new();
1151        let plan1 = planner1.plan(&ast1).unwrap();
1152        match plan1 {
1153            LogicalOperator::Project { input, .. } => match *input {
1154                LogicalOperator::VariableLengthExpand {
1155                    min_length,
1156                    max_length,
1157                    ..
1158                } => {
1159                    assert_eq!(min_length, None);
1160                    assert_eq!(max_length, None);
1161                }
1162                _ => panic!("Expected VariableLengthExpand for *"),
1163            },
1164            _ => panic!("Expected Project at top level"),
1165        }
1166
1167        // *2.. (min only)
1168        let q2 = "MATCH (a)-[:R*2..]->(b) RETURN b";
1169        let ast2 = parse_cypher_query(q2).unwrap();
1170        let mut planner2 = LogicalPlanner::new();
1171        let plan2 = planner2.plan(&ast2).unwrap();
1172        match plan2 {
1173            LogicalOperator::Project { input, .. } => match *input {
1174                LogicalOperator::VariableLengthExpand {
1175                    min_length,
1176                    max_length,
1177                    ..
1178                } => {
1179                    assert_eq!(min_length, Some(2));
1180                    assert_eq!(max_length, None);
1181                }
1182                _ => panic!("Expected VariableLengthExpand for *2.."),
1183            },
1184            _ => panic!("Expected Project at top level"),
1185        }
1186
1187        // *..3 (max only)
1188        let q3 = "MATCH (a)-[:R*..3]->(b) RETURN b";
1189        let ast3 = parse_cypher_query(q3).unwrap();
1190        let mut planner3 = LogicalPlanner::new();
1191        let plan3 = planner3.plan(&ast3).unwrap();
1192        match plan3 {
1193            LogicalOperator::Project { input, .. } => match *input {
1194                LogicalOperator::VariableLengthExpand {
1195                    min_length,
1196                    max_length,
1197                    ..
1198                } => {
1199                    assert_eq!(min_length, None);
1200                    assert_eq!(max_length, Some(3));
1201                }
1202                _ => panic!("Expected VariableLengthExpand for *..3"),
1203            },
1204            _ => panic!("Expected Project at top level"),
1205        }
1206    }
1207
1208    #[test]
1209    fn test_variable_reuse_across_patterns() {
1210        let query_text =
1211            "MATCH (a:Person)-[:KNOWS]->(shared:Person), (shared)-[:KNOWS]->(b:Person) RETURN b.name";
1212
1213        let ast = parse_cypher_query(query_text).unwrap();
1214        let mut planner = LogicalPlanner::new();
1215        let logical_plan = planner.plan(&ast).unwrap();
1216
1217        // Expect: Project { Expand(shared->b) { Expand(a->shared) { Scan(a) } } }
1218        match &logical_plan {
1219            LogicalOperator::Project { input, .. } => match input.as_ref() {
1220                LogicalOperator::Expand {
1221                    input: inner,
1222                    source_variable,
1223                    target_variable,
1224                    ..
1225                } => {
1226                    assert_eq!(source_variable, "shared");
1227                    assert_eq!(target_variable, "b");
1228
1229                    match inner.as_ref() {
1230                        LogicalOperator::Expand {
1231                            source_variable: first_src,
1232                            target_variable: first_dst,
1233                            ..
1234                        } => {
1235                            assert_eq!(first_src, "a");
1236                            assert_eq!(first_dst, "shared");
1237                        }
1238                        _ => panic!("Expected first Expand (a->shared)"),
1239                    }
1240                }
1241                _ => panic!("Expected second Expand (shared->b)"),
1242            },
1243            _ => panic!("Expected Project at top level"),
1244        }
1245    }
1246
1247    #[test]
1248    fn test_variable_reuse_with_conflicting_labels() {
1249        let query_text =
1250            "MATCH (a:Person)-[:KNOWS]->(shared:Person), (shared:Company)-[:EMPLOYS]->(b:Person) RETURN b.name";
1251
1252        let ast = parse_cypher_query(query_text).unwrap();
1253        let mut planner = LogicalPlanner::new();
1254        let err = planner.plan(&ast).unwrap_err();
1255        let err_msg = err.to_string();
1256
1257        assert!(
1258            err_msg.contains("already has label 'Person'")
1259                && err_msg.contains("cannot redefine as 'Company'"),
1260            "Expected error about label conflict, got: {}",
1261            err_msg
1262        );
1263    }
1264}