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        let label = node
342            .labels
343            .first()
344            .cloned()
345            .unwrap_or_else(|| "Node".to_string());
346
347        // Register variable
348        self.variables.insert(variable.clone(), label.clone());
349
350        Ok(LogicalOperator::ScanByLabel {
351            variable,
352            label,
353            properties: node.properties.clone(),
354        })
355    }
356
357    // (removed) plan_path_segment is superseded by plan_path
358
359    /// Plan a full path pattern, respecting the starting variable if provided
360    fn plan_path(
361        &mut self,
362        base: Option<LogicalOperator>,
363        path: &PathPattern,
364    ) -> Result<LogicalOperator> {
365        // Establish a base plan
366        let mut plan = if let Some(p) = base {
367            p
368        } else {
369            self.plan_node_scan(&path.start_node)?
370        };
371
372        // Determine the current source variable for the first hop
373        let mut current_src = match &path.start_node.variable {
374            Some(var) => var.clone(),
375            None => self.extract_variable_from_plan(&plan)?,
376        };
377
378        // For each segment, add an expand
379        for segment in &path.segments {
380            // Determine / register target variable
381            let target_variable = segment
382                .end_node
383                .variable
384                .clone()
385                .unwrap_or_else(|| format!("_node_{}", self.variables.len()));
386
387            let target_label = segment
388                .end_node
389                .labels
390                .first()
391                .cloned()
392                .unwrap_or_else(|| "Node".to_string());
393            self.variables
394                .insert(target_variable.clone(), target_label.clone());
395
396            // Optimize fixed-length var-length expansions (*1 or *1..1)
397            let next_plan = match segment.relationship.length.as_ref() {
398                Some(length_range)
399                    if length_range.min == Some(1) && length_range.max == Some(1) =>
400                {
401                    LogicalOperator::Expand {
402                        input: Box::new(plan),
403                        source_variable: current_src.clone(),
404                        target_variable: target_variable.clone(),
405                        target_label: target_label.clone(),
406                        relationship_types: segment.relationship.types.clone(),
407                        direction: segment.relationship.direction.clone(),
408                        relationship_variable: segment.relationship.variable.clone(),
409                        properties: segment.relationship.properties.clone(),
410                        target_properties: segment.end_node.properties.clone(),
411                    }
412                }
413                Some(length_range) => LogicalOperator::VariableLengthExpand {
414                    input: Box::new(plan),
415                    source_variable: current_src.clone(),
416                    target_variable: target_variable.clone(),
417                    relationship_types: segment.relationship.types.clone(),
418                    direction: segment.relationship.direction.clone(),
419                    relationship_variable: segment.relationship.variable.clone(),
420                    min_length: length_range.min,
421                    max_length: length_range.max,
422                    target_properties: segment.end_node.properties.clone(),
423                },
424                None => LogicalOperator::Expand {
425                    input: Box::new(plan),
426                    source_variable: current_src.clone(),
427                    target_variable: target_variable.clone(),
428                    target_label: target_label.clone(),
429                    relationship_types: segment.relationship.types.clone(),
430                    direction: segment.relationship.direction.clone(),
431                    relationship_variable: segment.relationship.variable.clone(),
432                    properties: segment.relationship.properties.clone(),
433                    target_properties: segment.end_node.properties.clone(),
434                },
435            };
436
437            plan = next_plan;
438            current_src = target_variable;
439        }
440
441        Ok(plan)
442    }
443
444    /// Extract the main variable from a logical plan (for chaining)
445    #[allow(clippy::only_used_in_recursion)]
446    fn extract_variable_from_plan(&self, plan: &LogicalOperator) -> Result<String> {
447        match plan {
448            LogicalOperator::ScanByLabel { variable, .. } => Ok(variable.clone()),
449            LogicalOperator::Unwind { alias, .. } => Ok(alias.clone()),
450            LogicalOperator::Expand {
451                target_variable, ..
452            } => Ok(target_variable.clone()),
453            LogicalOperator::VariableLengthExpand {
454                target_variable, ..
455            } => Ok(target_variable.clone()),
456            LogicalOperator::Filter { input, .. } => self.extract_variable_from_plan(input),
457            LogicalOperator::Project { input, .. } => self.extract_variable_from_plan(input),
458            LogicalOperator::Distinct { input } => self.extract_variable_from_plan(input),
459            LogicalOperator::Sort { input, .. } => self.extract_variable_from_plan(input),
460            LogicalOperator::Offset { input, .. } => self.extract_variable_from_plan(input),
461            LogicalOperator::Limit { input, .. } => self.extract_variable_from_plan(input),
462            LogicalOperator::Join { left, right, .. } => {
463                // Prefer the right branch's tail variable, else fall back to left
464                self.extract_variable_from_plan(right)
465                    .or_else(|_| self.extract_variable_from_plan(left))
466            }
467        }
468    }
469
470    /// Plan RETURN clause (Project)
471    fn plan_return_clause(
472        &self,
473        return_clause: &ReturnClause,
474        input: LogicalOperator,
475    ) -> Result<LogicalOperator> {
476        let projections = return_clause
477            .items
478            .iter()
479            .map(|item| ProjectionItem {
480                expression: item.expression.clone(),
481                alias: item.alias.clone(),
482            })
483            .collect();
484
485        let mut plan = LogicalOperator::Project {
486            input: Box::new(input),
487            projections,
488        };
489
490        // Add DISTINCT if needed
491        if return_clause.distinct {
492            plan = LogicalOperator::Distinct {
493                input: Box::new(plan),
494            };
495        }
496
497        Ok(plan)
498    }
499
500    /// Plan WITH clause - intermediate projection/aggregation with optional ORDER BY and LIMIT
501    fn plan_with_clause(
502        &self,
503        with_clause: &WithClause,
504        input: LogicalOperator,
505    ) -> Result<LogicalOperator> {
506        // WITH creates a projection (like RETURN)
507        let projections = with_clause
508            .items
509            .iter()
510            .map(|item| ProjectionItem {
511                expression: item.expression.clone(),
512                alias: item.alias.clone(),
513            })
514            .collect();
515
516        let mut plan = LogicalOperator::Project {
517            input: Box::new(input),
518            projections,
519        };
520
521        // Apply ORDER BY within WITH if present
522        if let Some(order_by) = &with_clause.order_by {
523            plan = LogicalOperator::Sort {
524                input: Box::new(plan),
525                sort_items: order_by
526                    .items
527                    .iter()
528                    .map(|item| SortItem {
529                        expression: item.expression.clone(),
530                        direction: item.direction.clone(),
531                    })
532                    .collect(),
533            };
534        }
535
536        // Apply LIMIT within WITH if present
537        if let Some(limit) = with_clause.limit {
538            plan = LogicalOperator::Limit {
539                input: Box::new(plan),
540                count: limit,
541            };
542        }
543
544        Ok(plan)
545    }
546}
547
548impl Default for LogicalPlanner {
549    fn default() -> Self {
550        Self::new()
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::parser::parse_cypher_query;
558
559    #[test]
560    fn test_relationship_query_logical_plan_structure() {
561        let query_text = r#"MATCH (p:Person {name: "Alice"})-[:KNOWS]->(f:Person) WHERE f.age > 30 RETURN f.name"#;
562
563        // Parse the query
564        let ast = parse_cypher_query(query_text).unwrap();
565
566        // Plan to logical operators
567        let mut planner = LogicalPlanner::new();
568        let logical_plan = planner.plan(&ast).unwrap();
569
570        // Verify the overall structure is a projection
571        match &logical_plan {
572            LogicalOperator::Project { input, projections } => {
573                // Verify projection is f.name
574                assert_eq!(projections.len(), 1);
575                match &projections[0].expression {
576                    ValueExpression::Property(prop_ref) => {
577                        assert_eq!(prop_ref.variable, "f");
578                        assert_eq!(prop_ref.property, "name");
579                    }
580                    _ => panic!("Expected property reference for f.name"),
581                }
582
583                // Verify input is a filter for f.age > 30
584                match input.as_ref() {
585                    LogicalOperator::Filter {
586                        predicate,
587                        input: filter_input,
588                    } => {
589                        // Verify the predicate is f.age > 30
590                        match predicate {
591                            BooleanExpression::Comparison {
592                                left,
593                                operator,
594                                right,
595                            } => {
596                                match left {
597                                    ValueExpression::Property(prop_ref) => {
598                                        assert_eq!(prop_ref.variable, "f");
599                                        assert_eq!(prop_ref.property, "age");
600                                    }
601                                    _ => panic!("Expected property reference for f.age"),
602                                }
603                                assert_eq!(*operator, ComparisonOperator::GreaterThan);
604                                match right {
605                                    ValueExpression::Literal(PropertyValue::Integer(val)) => {
606                                        assert_eq!(*val, 30);
607                                    }
608                                    _ => panic!("Expected integer literal 30"),
609                                }
610                            }
611                            _ => panic!("Expected comparison expression"),
612                        }
613
614                        // Verify the input to the filter is an expand operation
615                        match filter_input.as_ref() {
616                            LogicalOperator::Expand {
617                                input: expand_input,
618                                source_variable,
619                                target_variable,
620                                relationship_types,
621                                direction,
622                                ..
623                            } => {
624                                assert_eq!(source_variable, "p");
625                                assert_eq!(target_variable, "f");
626                                assert_eq!(relationship_types, &vec!["KNOWS".to_string()]);
627                                assert_eq!(*direction, RelationshipDirection::Outgoing);
628
629                                // Verify the input to expand is a scan with properties for p.name = 'Alice'
630                                match expand_input.as_ref() {
631                                    LogicalOperator::ScanByLabel {
632                                        variable,
633                                        label,
634                                        properties,
635                                    } => {
636                                        assert_eq!(variable, "p");
637                                        assert_eq!(label, "Person");
638
639                                        // Verify the properties contain name = "Alice"
640                                        assert_eq!(properties.len(), 1);
641                                        match properties.get("name") {
642                                            Some(PropertyValue::String(val)) => {
643                                                assert_eq!(val, "Alice");
644                                            }
645                                            _ => {
646                                                panic!("Expected name property with value 'Alice'")
647                                            }
648                                        }
649                                    }
650                                    _ => panic!("Expected ScanByLabel with properties for Person"),
651                                }
652                            }
653                            _ => panic!("Expected Expand operation"),
654                        }
655                    }
656                    _ => panic!("Expected Filter for f.age > 30"),
657                }
658            }
659            _ => panic!("Expected Project at the top level"),
660        }
661    }
662
663    #[test]
664    fn test_simple_node_query_logical_plan() {
665        let query_text = "MATCH (n:Person) RETURN n.name";
666
667        let ast = parse_cypher_query(query_text).unwrap();
668        let mut planner = LogicalPlanner::new();
669        let logical_plan = planner.plan(&ast).unwrap();
670
671        // Should be: Project { input: ScanByLabel }
672        match &logical_plan {
673            LogicalOperator::Project { input, projections } => {
674                assert_eq!(projections.len(), 1);
675                match input.as_ref() {
676                    LogicalOperator::ScanByLabel {
677                        variable, label, ..
678                    } => {
679                        assert_eq!(variable, "n");
680                        assert_eq!(label, "Person");
681                    }
682                    _ => panic!("Expected ScanByLabel"),
683                }
684            }
685            _ => panic!("Expected Project"),
686        }
687    }
688
689    #[test]
690    fn test_node_with_properties_logical_plan() {
691        let query_text = "MATCH (n:Person {age: 25}) RETURN n.name";
692
693        let ast = parse_cypher_query(query_text).unwrap();
694        let mut planner = LogicalPlanner::new();
695        let logical_plan = planner.plan(&ast).unwrap();
696
697        // Should be: Project { input: ScanByLabel with properties }
698        // Properties from MATCH clause are pushed down to the scan level
699        match &logical_plan {
700            LogicalOperator::Project { input, .. } => {
701                match input.as_ref() {
702                    LogicalOperator::ScanByLabel {
703                        variable,
704                        label,
705                        properties,
706                    } => {
707                        assert_eq!(variable, "n");
708                        assert_eq!(label, "Person");
709
710                        // Verify the properties are in the scan
711                        assert_eq!(properties.len(), 1);
712                        match properties.get("age") {
713                            Some(PropertyValue::Integer(25)) => {}
714                            _ => panic!("Expected age property with value 25"),
715                        }
716                    }
717                    _ => panic!("Expected ScanByLabel with properties"),
718                }
719            }
720            _ => panic!("Expected Project"),
721        }
722    }
723
724    #[test]
725    fn test_variable_length_path_logical_plan() {
726        let query_text = "MATCH (a:Person)-[:KNOWS*1..2]->(b:Person) RETURN b.name";
727
728        let ast = parse_cypher_query(query_text).unwrap();
729        let mut planner = LogicalPlanner::new();
730        let logical_plan = planner.plan(&ast).unwrap();
731
732        // Should be: Project { input: VariableLengthExpand { input: ScanByLabel } }
733        match &logical_plan {
734            LogicalOperator::Project { input, .. } => match input.as_ref() {
735                LogicalOperator::VariableLengthExpand {
736                    input: expand_input,
737                    source_variable,
738                    target_variable,
739                    relationship_types,
740                    min_length,
741                    max_length,
742                    ..
743                } => {
744                    assert_eq!(source_variable, "a");
745                    assert_eq!(target_variable, "b");
746                    assert_eq!(relationship_types, &vec!["KNOWS".to_string()]);
747                    assert_eq!(*min_length, Some(1));
748                    assert_eq!(*max_length, Some(2));
749
750                    match expand_input.as_ref() {
751                        LogicalOperator::ScanByLabel {
752                            variable, label, ..
753                        } => {
754                            assert_eq!(variable, "a");
755                            assert_eq!(label, "Person");
756                        }
757                        _ => panic!("Expected ScanByLabel"),
758                    }
759                }
760                _ => panic!("Expected VariableLengthExpand"),
761            },
762            _ => panic!("Expected Project"),
763        }
764    }
765
766    #[test]
767    fn test_where_clause_logical_plan() {
768        // Note: Current parser only supports simple comparisons, not AND/OR
769        let query_text = r#"MATCH (n:Person) WHERE n.age > 25 RETURN n.name"#;
770
771        let ast = parse_cypher_query(query_text).unwrap();
772        let mut planner = LogicalPlanner::new();
773        let logical_plan = planner.plan(&ast).unwrap();
774
775        // Should be: Project { input: Filter { input: ScanByLabel } }
776        match &logical_plan {
777            LogicalOperator::Project { input, .. } => {
778                match input.as_ref() {
779                    LogicalOperator::Filter {
780                        predicate,
781                        input: scan_input,
782                    } => {
783                        // Verify it's a simple comparison: n.age > 25
784                        match predicate {
785                            BooleanExpression::Comparison {
786                                left,
787                                operator,
788                                right: _,
789                            } => {
790                                match left {
791                                    ValueExpression::Property(prop_ref) => {
792                                        assert_eq!(prop_ref.variable, "n");
793                                        assert_eq!(prop_ref.property, "age");
794                                    }
795                                    _ => panic!("Expected property reference for age"),
796                                }
797                                assert_eq!(*operator, ComparisonOperator::GreaterThan);
798                            }
799                            _ => panic!("Expected comparison expression"),
800                        }
801
802                        match scan_input.as_ref() {
803                            LogicalOperator::ScanByLabel { .. } => {}
804                            _ => panic!("Expected ScanByLabel"),
805                        }
806                    }
807                    _ => panic!("Expected Filter"),
808                }
809            }
810            _ => panic!("Expected Project"),
811        }
812    }
813
814    #[test]
815    fn test_multiple_node_patterns_join_in_match() {
816        let query_text = "MATCH (a:Person), (b:Company) RETURN a.name, b.name";
817
818        let ast = parse_cypher_query(query_text).unwrap();
819        let mut planner = LogicalPlanner::new();
820        let logical_plan = planner.plan(&ast).unwrap();
821
822        // Expect: Project { input: Join { left: Scan(a:Person), right: Scan(b:Company) } }
823        match &logical_plan {
824            LogicalOperator::Project { input, projections } => {
825                assert_eq!(projections.len(), 2);
826                match input.as_ref() {
827                    LogicalOperator::Join {
828                        left,
829                        right,
830                        join_type,
831                    } => {
832                        assert!(matches!(join_type, JoinType::Cross));
833                        match left.as_ref() {
834                            LogicalOperator::ScanByLabel {
835                                variable, label, ..
836                            } => {
837                                assert_eq!(variable, "a");
838                                assert_eq!(label, "Person");
839                            }
840                            _ => panic!("Expected left ScanByLabel for a:Person"),
841                        }
842                        match right.as_ref() {
843                            LogicalOperator::ScanByLabel {
844                                variable, label, ..
845                            } => {
846                                assert_eq!(variable, "b");
847                                assert_eq!(label, "Company");
848                            }
849                            _ => panic!("Expected right ScanByLabel for b:Company"),
850                        }
851                    }
852                    _ => panic!("Expected Join after Project"),
853                }
854            }
855            _ => panic!("Expected Project at top level"),
856        }
857    }
858
859    #[test]
860    fn test_shared_variable_chained_paths_in_match() {
861        let query_text =
862            "MATCH (a:Person)-[:KNOWS]->(b:Person), (b)-[:LIKES]->(c:Thing) RETURN c.name";
863
864        let ast = parse_cypher_query(query_text).unwrap();
865        let mut planner = LogicalPlanner::new();
866        let logical_plan = planner.plan(&ast).unwrap();
867
868        // Expect: Project { input: Expand (b->c) { input: Expand (a->b) { input: Scan(a) } } }
869        match &logical_plan {
870            LogicalOperator::Project { input, .. } => match input.as_ref() {
871                LogicalOperator::Expand {
872                    source_variable: src2,
873                    target_variable: tgt2,
874                    input: inner2,
875                    ..
876                } => {
877                    assert_eq!(src2, "b");
878                    assert_eq!(tgt2, "c");
879                    match inner2.as_ref() {
880                        LogicalOperator::Expand {
881                            source_variable: src1,
882                            target_variable: tgt1,
883                            input: inner1,
884                            ..
885                        } => {
886                            assert_eq!(src1, "a");
887                            assert_eq!(tgt1, "b");
888                            match inner1.as_ref() {
889                                LogicalOperator::ScanByLabel {
890                                    variable, label, ..
891                                } => {
892                                    assert_eq!(variable, "a");
893                                    assert_eq!(label, "Person");
894                                }
895                                _ => panic!("Expected ScanByLabel for a:Person"),
896                            }
897                        }
898                        _ => panic!("Expected first Expand a->b"),
899                    }
900                }
901                _ => panic!("Expected second Expand b->c at top of input"),
902            },
903            _ => panic!("Expected Project at top level"),
904        }
905    }
906
907    #[test]
908    fn test_fixed_length_variable_path_is_expand() {
909        let query_text = "MATCH (a:Person)-[:KNOWS*1..1]->(b:Person) RETURN b.name";
910
911        let ast = parse_cypher_query(query_text).unwrap();
912        let mut planner = LogicalPlanner::new();
913        let logical_plan = planner.plan(&ast).unwrap();
914
915        match &logical_plan {
916            LogicalOperator::Project { input, .. } => match input.as_ref() {
917                LogicalOperator::Expand {
918                    source_variable,
919                    target_variable,
920                    ..
921                } => {
922                    assert_eq!(source_variable, "a");
923                    assert_eq!(target_variable, "b");
924                }
925                _ => panic!("Expected Expand for fixed-length *1..1"),
926            },
927            _ => panic!("Expected Project at top level"),
928        }
929    }
930
931    #[test]
932    fn test_distinct_and_order_limit_wrapping() {
933        // DISTINCT should wrap Project with Distinct
934        let q1 = "MATCH (n:Person) RETURN DISTINCT n.name";
935        let ast1 = parse_cypher_query(q1).unwrap();
936        let mut planner = LogicalPlanner::new();
937        let logical1 = planner.plan(&ast1).unwrap();
938        match logical1 {
939            LogicalOperator::Distinct { input } => match *input {
940                LogicalOperator::Project { .. } => {}
941                _ => panic!("Expected Project under Distinct"),
942            },
943            _ => panic!("Expected Distinct at top level"),
944        }
945
946        // ORDER BY + LIMIT should be Limit(Sort(Project(..)))
947        let q2 = "MATCH (n:Person) RETURN n.name ORDER BY n.name LIMIT 10";
948        let ast2 = parse_cypher_query(q2).unwrap();
949        let mut planner2 = LogicalPlanner::new();
950        let logical2 = planner2.plan(&ast2).unwrap();
951        match logical2 {
952            LogicalOperator::Limit { input, count } => {
953                assert_eq!(count, 10);
954                match *input {
955                    LogicalOperator::Sort { input: inner, .. } => match *inner {
956                        LogicalOperator::Project { .. } => {}
957                        _ => panic!("Expected Project under Sort"),
958                    },
959                    _ => panic!("Expected Sort under Limit"),
960                }
961            }
962            _ => panic!("Expected Limit at top level"),
963        }
964    }
965
966    #[test]
967    fn test_order_skip_limit_wrapping() {
968        // ORDER BY + SKIP + LIMIT should be Limit(Offset(Sort(Project(..))))
969        let q = "MATCH (n:Person) RETURN n.name ORDER BY n.name SKIP 5 LIMIT 10";
970        let ast = parse_cypher_query(q).unwrap();
971        let mut planner = LogicalPlanner::new();
972        let logical = planner.plan(&ast).unwrap();
973        match logical {
974            LogicalOperator::Limit { input, count } => {
975                assert_eq!(count, 10);
976                match *input {
977                    LogicalOperator::Offset {
978                        input: inner,
979                        offset,
980                    } => {
981                        assert_eq!(offset, 5);
982                        match *inner {
983                            LogicalOperator::Sort { input: inner2, .. } => match *inner2 {
984                                LogicalOperator::Project { .. } => {}
985                                _ => panic!("Expected Project under Sort"),
986                            },
987                            _ => panic!("Expected Sort under Offset"),
988                        }
989                    }
990                    _ => panic!("Expected Offset under Limit"),
991                }
992            }
993            _ => panic!("Expected Limit at top level"),
994        }
995    }
996
997    #[test]
998    fn test_skip_only_wrapping() {
999        // SKIP only should be Offset(Project(..))
1000        let q = "MATCH (n:Person) RETURN n.name SKIP 3";
1001        let ast = parse_cypher_query(q).unwrap();
1002        let mut planner = LogicalPlanner::new();
1003        let logical = planner.plan(&ast).unwrap();
1004        match logical {
1005            LogicalOperator::Offset { input, offset } => {
1006                assert_eq!(offset, 3);
1007                match *input {
1008                    LogicalOperator::Project { .. } => {}
1009                    _ => panic!("Expected Project under Offset"),
1010                }
1011            }
1012            _ => panic!("Expected Offset at top level"),
1013        }
1014    }
1015
1016    #[test]
1017    fn test_relationship_properties_pushed_into_expand() {
1018        let q = "MATCH (a)-[:KNOWS {since: 2020}]->(b) RETURN b";
1019        let ast = parse_cypher_query(q).unwrap();
1020        let mut planner = LogicalPlanner::new();
1021        let logical = planner.plan(&ast).unwrap();
1022        match logical {
1023            LogicalOperator::Project { input, .. } => match *input {
1024                LogicalOperator::Expand { properties, .. } => match properties.get("since") {
1025                    Some(PropertyValue::Integer(2020)) => {}
1026                    _ => panic!("Expected relationship property since=2020 in Expand"),
1027                },
1028                _ => panic!("Expected Expand under Project"),
1029            },
1030            _ => panic!("Expected Project at top level"),
1031        }
1032    }
1033
1034    #[test]
1035    fn test_multiple_match_clauses_cross_join() {
1036        let q = "MATCH (a:Person) MATCH (b:Company) RETURN a.name, b.name";
1037        let ast = parse_cypher_query(q).unwrap();
1038        let mut planner = LogicalPlanner::new();
1039        let logical = planner.plan(&ast).unwrap();
1040        match logical {
1041            LogicalOperator::Project { input, .. } => match *input {
1042                LogicalOperator::Join {
1043                    left,
1044                    right,
1045                    join_type,
1046                } => {
1047                    assert!(matches!(join_type, JoinType::Cross));
1048                    match (*left, *right) {
1049                        (
1050                            LogicalOperator::ScanByLabel {
1051                                variable: va,
1052                                label: la,
1053                                ..
1054                            },
1055                            LogicalOperator::ScanByLabel {
1056                                variable: vb,
1057                                label: lb,
1058                                ..
1059                            },
1060                        ) => {
1061                            assert_eq!(va, "a");
1062                            assert_eq!(la, "Person");
1063                            assert_eq!(vb, "b");
1064                            assert_eq!(lb, "Company");
1065                        }
1066                        _ => panic!("Expected two scans under Join"),
1067                    }
1068                }
1069                _ => panic!("Expected Join under Project"),
1070            },
1071            _ => panic!("Expected Project at top level"),
1072        }
1073    }
1074
1075    #[test]
1076    fn test_variable_only_node_default_label() {
1077        let q = "MATCH (x) RETURN x";
1078        let ast = parse_cypher_query(q).unwrap();
1079        let mut planner = LogicalPlanner::new();
1080        let logical = planner.plan(&ast).unwrap();
1081        match logical {
1082            LogicalOperator::Project { input, .. } => match *input {
1083                LogicalOperator::ScanByLabel {
1084                    variable, label, ..
1085                } => {
1086                    assert_eq!(variable, "x");
1087                    assert_eq!(label, "Node");
1088                }
1089                _ => panic!("Expected ScanByLabel under Project"),
1090            },
1091            _ => panic!("Expected Project at top level"),
1092        }
1093    }
1094
1095    #[test]
1096    fn test_multi_label_node_uses_first_label() {
1097        let q = "MATCH (n:Person:Employee) RETURN n";
1098        let ast = parse_cypher_query(q).unwrap();
1099        let mut planner = LogicalPlanner::new();
1100        let logical = planner.plan(&ast).unwrap();
1101        match logical {
1102            LogicalOperator::Project { input, .. } => match *input {
1103                LogicalOperator::ScanByLabel { label, .. } => {
1104                    assert_eq!(label, "Person");
1105                }
1106                _ => panic!("Expected ScanByLabel under Project"),
1107            },
1108            _ => panic!("Expected Project at top level"),
1109        }
1110    }
1111
1112    #[test]
1113    fn test_open_ended_and_partial_var_length_ranges() {
1114        // * (unbounded)
1115        let q1 = "MATCH (a)-[:R*]->(b) RETURN b";
1116        let ast1 = parse_cypher_query(q1).unwrap();
1117        let mut planner1 = LogicalPlanner::new();
1118        let plan1 = planner1.plan(&ast1).unwrap();
1119        match plan1 {
1120            LogicalOperator::Project { input, .. } => match *input {
1121                LogicalOperator::VariableLengthExpand {
1122                    min_length,
1123                    max_length,
1124                    ..
1125                } => {
1126                    assert_eq!(min_length, None);
1127                    assert_eq!(max_length, None);
1128                }
1129                _ => panic!("Expected VariableLengthExpand for *"),
1130            },
1131            _ => panic!("Expected Project at top level"),
1132        }
1133
1134        // *2.. (min only)
1135        let q2 = "MATCH (a)-[:R*2..]->(b) RETURN b";
1136        let ast2 = parse_cypher_query(q2).unwrap();
1137        let mut planner2 = LogicalPlanner::new();
1138        let plan2 = planner2.plan(&ast2).unwrap();
1139        match plan2 {
1140            LogicalOperator::Project { input, .. } => match *input {
1141                LogicalOperator::VariableLengthExpand {
1142                    min_length,
1143                    max_length,
1144                    ..
1145                } => {
1146                    assert_eq!(min_length, Some(2));
1147                    assert_eq!(max_length, None);
1148                }
1149                _ => panic!("Expected VariableLengthExpand for *2.."),
1150            },
1151            _ => panic!("Expected Project at top level"),
1152        }
1153
1154        // *..3 (max only)
1155        let q3 = "MATCH (a)-[:R*..3]->(b) RETURN b";
1156        let ast3 = parse_cypher_query(q3).unwrap();
1157        let mut planner3 = LogicalPlanner::new();
1158        let plan3 = planner3.plan(&ast3).unwrap();
1159        match plan3 {
1160            LogicalOperator::Project { input, .. } => match *input {
1161                LogicalOperator::VariableLengthExpand {
1162                    min_length,
1163                    max_length,
1164                    ..
1165                } => {
1166                    assert_eq!(min_length, None);
1167                    assert_eq!(max_length, Some(3));
1168                }
1169                _ => panic!("Expected VariableLengthExpand for *..3"),
1170            },
1171            _ => panic!("Expected Project at top level"),
1172        }
1173    }
1174}