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