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