Skip to main content

lance_csr/
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::case_insensitive::qualify_column;
12use crate::error::{GraphError, Result};
13use crate::{ast::*, GraphConfig};
14use serde::{Deserialize, Serialize};
15use snafu::Location;
16use std::collections::HashMap;
17
18/// A logical plan operator - describes what operation to perform
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub enum LogicalOperator {
21    /// One row with no bound variables, for queries without reading clauses.
22    SingleRow,
23    /// Language-level UNION; unlike pattern alternation, branches must share output schema.
24    QueryUnion {
25        left: Box<LogicalOperator>,
26        right: Box<LogicalOperator>,
27        all: bool,
28    },
29    /// Scan all nodes with a specific label
30    ScanByLabel {
31        variable: String,
32        label: String,
33        properties: HashMap<String, PropertyValue>,
34    },
35
36    /// Unwind a list into a sequence of rows
37    Unwind {
38        /// The input operator
39        input: Option<Box<LogicalOperator>>,
40        /// The expression to unwind (must yield a list)
41        expression: ValueExpression,
42        /// The alias for the unwound element
43        alias: String,
44        /// Set when the list holds node ids of this label (`collect(node)`): each unwound
45        /// id is joined back to its node table, so `alias` is a node again.
46        label: Option<String>,
47    },
48
49    /// Apply a filter predicate (WHERE clause)
50    Filter {
51        input: Box<LogicalOperator>,
52        predicate: BooleanExpression,
53    },
54
55    /// Traverse relationships (the core graph operation)
56    ///
57    /// Represents a single-hop relationship traversal: (source)-[rel]->(target)
58    Expand {
59        /// The input operator (typically a node scan or previous expand)
60        input: Box<LogicalOperator>,
61        /// Variable name for the source node (e.g., "a" in (a)-[]->(b))
62        source_variable: String,
63        /// Variable name for the target node (e.g., "b" in (a)-[]->(b))
64        target_variable: String,
65        /// Label of the target node (e.g., "Person", "Book")
66        /// This is essential for looking up the correct schema during planning
67        target_label: String,
68        /// Types of relationships to traverse (e.g., ["KNOWS", "FRIEND_OF"])
69        relationship_types: Vec<String>,
70        /// Direction of traversal (Outgoing, Incoming, or Undirected)
71        direction: RelationshipDirection,
72        /// Optional variable name for the relationship itself (e.g., "r" in -[r]->)
73        relationship_variable: Option<String>,
74        /// Property filters to apply on the relationship
75        properties: HashMap<String, PropertyValue>,
76        /// Property filters to apply on the target node
77        target_properties: HashMap<String, PropertyValue>,
78    },
79
80    /// Variable-length path expansion (*1..2 syntax)
81    ///
82    /// Represents multi-hop relationship traversals: (source)-[rel*min..max]->(target)
83    /// Uses iterative CSR traversal when indexed, otherwise fixed-length branches and a union.
84    VariableLengthExpand {
85        /// The input operator (typically a node scan)
86        input: Box<LogicalOperator>,
87        /// Variable name for the source node
88        source_variable: String,
89        /// Variable name for the target node (reachable in min..max hops)
90        target_variable: String,
91        /// The label of the final node, which may differ from intermediate nodes.
92        target_label: String,
93        /// Types of relationships to traverse in each hop
94        relationship_types: Vec<String>,
95        /// Direction of traversal for each hop
96        direction: RelationshipDirection,
97        /// Optional variable name for the relationship pattern
98        relationship_variable: Option<String>,
99        /// Minimum number of hops (defaults to 1 if None)
100        min_length: Option<u32>,
101        /// Maximum number of hops (defaults to system max if None)
102        max_length: Option<u32>,
103        /// Property filters to apply on target nodes
104        target_properties: HashMap<String, PropertyValue>,
105    },
106
107    /// Project specific columns (RETURN clause)
108    Project {
109        input: Box<LogicalOperator>,
110        projections: Vec<ProjectionItem>,
111    },
112
113    /// Join multiple disconnected patterns
114    Join {
115        left: Box<LogicalOperator>,
116        right: Box<LogicalOperator>,
117        join_type: JoinType,
118    },
119
120    /// Apply DISTINCT
121    Distinct { input: Box<LogicalOperator> },
122
123    /// Apply ORDER BY
124    Sort {
125        input: Box<LogicalOperator>,
126        sort_items: Vec<SortItem>,
127    },
128
129    /// Apply SKIP/OFFSET
130    Offset {
131        input: Box<LogicalOperator>,
132        offset: u64,
133    },
134
135    /// Apply LIMIT
136    Limit {
137        input: Box<LogicalOperator>,
138        count: u64,
139    },
140
141    /// UNION ALL of the branches of one MATCH clause that uses relationship-type
142    /// alternation or multi-label nodes (see `pattern_alternatives`). Branches bind the
143    /// same variables, possibly with different labels; columns a branch lacks are null.
144    Union { inputs: Vec<LogicalOperator> },
145
146    /// A named path retaining actual relationship occurrences and typed node records.
147    PathMatch {
148        input: Box<LogicalOperator>,
149        path: PathPattern,
150    },
151
152    /// `shortestPath` / `allShortestPaths` between two variables bound in `input`; adds
153    /// `<path_variable>__length` and `<path_variable>__nodes` (node ids, source first).
154    ShortestPath {
155        input: Box<LogicalOperator>,
156        source_variable: String,
157        target_variable: String,
158        relationship_type: String,
159        direction: RelationshipDirection,
160        min_hops: u32,
161        max_hops: u32,
162        all: bool,
163        path_variable: String,
164    },
165
166    /// OPTIONAL MATCH: LEFT JOIN of `input` with `optional` (which is planned on top of
167    /// `input`'s distinct shared variables) on the shared variables' id columns `keys`.
168    OptionalMatch {
169        input: Box<LogicalOperator>,
170        optional: Box<LogicalOperator>,
171        keys: Vec<String>,
172    },
173
174    /// `[variable IN list WHERE keep | map]` for each row of `input`, into the column
175    /// `output`. The list is unnested with element positions (an empty or null list keeps
176    /// its row), `element` is planned over those element rows (its leaf is
177    /// [`LogicalOperator::ComprehensionElement`]; EXISTS marks of `keep` join onto it), and
178    /// the kept values are collected back per row in position order. With `label`, the
179    /// list holds node ids and each element is joined to its node.
180    ListComprehension {
181        input: Box<LogicalOperator>,
182        list: ValueExpression,
183        variable: String,
184        label: Option<String>,
185        element: Box<LogicalOperator>,
186        keep: Option<BooleanExpression>,
187        map: ValueExpression,
188        output: String,
189        /// Count retained elements without building an ordered list.
190        #[serde(default)]
191        count_only: bool,
192    },
193
194    /// Evaluate a read-only subquery once per input row, extending it by each result.
195    Apply {
196        input: Box<LogicalOperator>,
197        inner: Box<LogicalOperator>,
198        imports: Vec<String>,
199        labels: HashMap<String, String>,
200        id: usize,
201    },
202    /// The single imported row of a CALL body; its values are bound at execution.
203    CallInput { labels: HashMap<String, String> },
204
205    /// The element rows of the enclosing [`LogicalOperator::ListComprehension`].
206    ComprehensionElement,
207}
208
209/// Projection item for SELECT/RETURN clauses
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct ProjectionItem {
212    pub expression: ValueExpression,
213    pub alias: Option<String>,
214}
215
216/// Join types for combining multiple patterns
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub enum JoinType {
219    Inner,
220    Left,
221    Right,
222    Full,
223    Cross,
224}
225
226/// Sort specification for ORDER BY
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228pub struct SortItem {
229    pub expression: ValueExpression,
230    pub direction: SortDirection,
231}
232
233/// Mean neighbours per key, by (lowercase relationship type, outgoing): edges divided by
234/// the distinct source ids (outgoing) or target ids (incoming). Planning estimates only.
235pub type FanOut = HashMap<(String, bool), f64>;
236
237/// Logical plan builder - converts AST to logical plan
238pub struct LogicalPlanner<'a> {
239    /// Track variables in scope
240    variables: HashMap<String, String>, // variable -> label
241    config: &'a GraphConfig,
242    fan_out: Option<&'a FanOut>,
243    adjacency: Option<&'a crate::csr_expand::AdjacencyCatalog>,
244    /// Mark columns created for EXISTS subqueries so far (names `__exists_<n>`).
245    exists_marks: usize,
246    /// Aliases holding lists of node ids (`collect(node) AS friends`), with the label.
247    node_lists: HashMap<String, String>,
248    /// List comprehensions planned so far (output columns `__list_<n>`).
249    comprehensions: usize,
250    calls: usize,
251    /// The node label of each path variable's nodes, for `nodes(path)`.
252    path_labels: HashMap<String, String>,
253    rich_paths: bool,
254    /// The rows a projection or WHERE started from, while its EXISTS marks and
255    /// comprehensions are being added: each is seeded from these, not from the rows with
256    /// the previous ones joined on, so planned copies grow linearly, not exponentially.
257    seed_source: Option<LogicalOperator>,
258}
259
260impl<'a> LogicalPlanner<'a> {
261    pub fn new(config: &'a GraphConfig) -> Self {
262        Self {
263            variables: HashMap::new(),
264            config,
265            fan_out: None,
266            adjacency: None,
267            exists_marks: 0,
268            node_lists: HashMap::new(),
269            comprehensions: 0,
270            calls: 0,
271            path_labels: HashMap::new(),
272            rich_paths: false,
273            seed_source: None,
274        }
275    }
276
277    /// Use `fan_out` to pick where a path that continues earlier rows starts.
278    pub fn with_fan_out(mut self, fan_out: Option<&'a FanOut>) -> Self {
279        self.fan_out = fan_out;
280        self
281    }
282
283    pub(crate) fn with_adjacency(
284        mut self,
285        adjacency: Option<&'a crate::csr_expand::AdjacencyCatalog>,
286    ) -> Self {
287        self.adjacency = adjacency;
288        self
289    }
290
291    fn retain_variable_alternation(&self, patterns: &[GraphPattern]) -> bool {
292        if patterns
293            .iter()
294            .all(|p| matches!(p, GraphPattern::Path(path) if path.variable.is_some()))
295        {
296            return true;
297        }
298        let Some(catalog) = self.adjacency else {
299            return false;
300        };
301        let labels = |n: &NodePattern| -> Vec<String> {
302            n.variable
303                .as_ref()
304                .and_then(|v| self.variables.get(v))
305                .map(|l| vec![l.clone()])
306                .unwrap_or_else(|| n.labels.clone())
307        };
308        patterns.iter().all(|p| {
309            let GraphPattern::Path(path) = p else {
310                return true;
311            };
312            let mut previous = &path.start_node;
313            path.segments.iter().all(|segment| {
314                let start = previous;
315                previous = &segment.end_node;
316                let rel = &segment.relationship;
317                let Some(range) = &rel.length else {
318                    return true;
319                };
320                if rel.types.len() <= 1 {
321                    return true;
322                }
323                if rel.variable.is_some()
324                    || !rel.properties.is_empty()
325                    || rel.direction == RelationshipDirection::Undirected
326                    || (range.min == Some(1) && range.max == Some(1))
327                {
328                    return false;
329                }
330                let (starts, ends) = (labels(start), labels(&segment.end_node));
331                let (Some(start), Some(end)) = (starts.first(), ends.first()) else {
332                    return false;
333                };
334                if starts
335                    .iter()
336                    .chain(&ends)
337                    .any(|l| catalog.nodes(l).is_none())
338                {
339                    return false;
340                }
341                crate::csr_traversal::Traversal::new(
342                    self.config,
343                    catalog,
344                    &rel.types,
345                    &rel.direction,
346                    start,
347                    end,
348                    0,
349                    0,
350                )
351                .is_some()
352            })
353        })
354    }
355
356    /// For a path that continues earlier rows, the path read from the other end when that
357    /// is the better start: always when only the far end is bound (expansion must start
358    /// from a bound node), and, with both ends bound, when the far end's first hop has the
359    /// smaller fan-out (LDBC IC5: from `forum`, about 11 posts each, not `friend`, about
360    /// 100). `None` keeps the written order: start already right, no estimates, or a
361    /// variable-length segment.
362    fn cheaper_orientation(&self, path: &PathPattern) -> Option<PathPattern> {
363        if path.variable.is_some() || path.shortest.is_some() {
364            return None;
365        }
366        let bound = |n: &NodePattern| {
367            n.variable
368                .as_ref()
369                .is_some_and(|v| self.variables.contains_key(v))
370        };
371        let (first, last) = (path.segments.first()?, path.segments.last()?);
372        if !bound(&last.end_node)
373            || path
374                .segments
375                .iter()
376                .any(|s| s.relationship.length.is_some())
377        {
378            return None;
379        }
380        if !bound(&path.start_node) {
381            return Some(Self::reversed_path(path));
382        }
383        let fan_out = self.fan_out?;
384        // Fan-out of a hop expanded from its left end (`forward`) or its right end.
385        let hop = |rel: &RelationshipPattern, forward: bool| -> Option<f64> {
386            let rel_type = rel.types.first()?.to_lowercase();
387            let get = |outgoing: bool| fan_out.get(&(rel_type.clone(), outgoing)).copied();
388            match (&rel.direction, forward) {
389                (RelationshipDirection::Outgoing, true)
390                | (RelationshipDirection::Incoming, false) => get(true),
391                (RelationshipDirection::Incoming, true)
392                | (RelationshipDirection::Outgoing, false) => get(false),
393                (RelationshipDirection::Undirected, _) => Some(get(true)? + get(false)?),
394            }
395        };
396        let from_start = hop(&first.relationship, true)?;
397        let from_end = hop(&last.relationship, false)?;
398        (from_end < from_start).then(|| Self::reversed_path(path))
399    }
400
401    /// The same path read backwards: nodes in reverse order, each relationship flipped.
402    fn reversed_path(path: &PathPattern) -> PathPattern {
403        let mut nodes: Vec<&NodePattern> = std::iter::once(&path.start_node)
404            .chain(path.segments.iter().map(|s| &s.end_node))
405            .collect();
406        nodes.reverse();
407        let segments = path
408            .segments
409            .iter()
410            .rev()
411            .zip(nodes.iter().skip(1))
412            .map(|(segment, end)| PathSegment {
413                relationship: crate::pattern_order::reversed(&segment.relationship),
414                end_node: (*end).clone(),
415            })
416            .collect();
417        PathPattern {
418            start_node: nodes[0].clone(),
419            segments,
420            shortest: path.shortest.clone(),
421            variable: path.variable.clone(),
422        }
423    }
424
425    /// Convert a Cypher AST to a logical plan
426    pub fn plan(&mut self, query: &CypherQuery) -> Result<LogicalOperator> {
427        self.rich_paths = crate::path_value::needs_values(query, self.config);
428        self.plan_query(query, None, false)
429    }
430
431    fn plan_query(
432        &mut self,
433        query: &CypherQuery,
434        input: Option<LogicalOperator>,
435        preserve_entities: bool,
436    ) -> Result<LogicalOperator> {
437        let seed = input.clone();
438        let imports = (
439            self.variables.clone(),
440            self.node_lists.clone(),
441            self.path_labels.clone(),
442        );
443        if preserve_entities {
444            let names = query.call_imports()?;
445            self.variables.retain(|name, _| names.contains(name));
446            self.node_lists.retain(|name, _| names.contains(name));
447            self.path_labels.retain(|name, _| names.contains(name));
448        }
449        // Plan main MATCH clauses; the WHERE clause guides where traversal starts.
450        let where_expr = query.where_clause.as_ref().map(|w| &w.expression);
451        let mut plan = self.plan_reading_clauses(input, &query.reading_clauses, where_expr)?;
452
453        // Apply WHERE clause if present (before WITH)
454        if let Some(where_clause) = &query.where_clause {
455            plan = self.filter(plan, &where_clause.expression)?;
456        }
457
458        // Apply WITH clause if present (intermediate projection/aggregation)
459        if let Some(with_clause) = &query.with_clause {
460            plan = self.plan_with_clause(with_clause, plan)?;
461        }
462
463        // Plan post-WITH MATCH clauses
464        if !query.post_with_reading_clauses.is_empty() {
465            plan = self.plan_reading_clauses(Some(plan), &query.post_with_reading_clauses, None)?;
466        }
467
468        // Apply post-WITH WHERE clause if present
469        if let Some(post_where) = &query.post_with_where_clause {
470            plan = self.filter(plan, &post_where.expression)?;
471        }
472
473        // Later WITH stages chain the same way as the first.
474        for stage in &query.further_with_stages {
475            plan = self.plan_with_clause(&stage.with_clause, plan)?;
476            if !stage.reading_clauses.is_empty() {
477                plan = self.plan_reading_clauses(Some(plan), &stage.reading_clauses, None)?;
478            }
479            if let Some(where_clause) = &stage.where_clause {
480                plan = self.filter(plan, &where_clause.expression)?;
481            }
482        }
483
484        // Apply RETURN clause
485        plan = if preserve_entities {
486            self.plan_with_clause(
487                &WithClause {
488                    distinct: query.return_clause.distinct,
489                    items: query.return_clause.items.clone(),
490                    order_by: None,
491                    limit: None,
492                    where_clause: None,
493                },
494                plan,
495            )?
496        } else {
497            self.plan_return_clause(&query.return_clause, plan)?
498        };
499
500        // Apply ORDER BY if present
501        if let Some(order_by) = &query.order_by {
502            plan = LogicalOperator::Sort {
503                input: Box::new(plan),
504                sort_items: self.sort_items(order_by),
505            };
506        }
507
508        // Apply SKIP/OFFSET if present
509        if let Some(skip) = &query.skip {
510            plan = LogicalOperator::Offset {
511                input: Box::new(plan),
512                offset: skip.value()?,
513            };
514        }
515
516        // Apply LIMIT if present
517        if let Some(limit) = &query.limit {
518            plan = LogicalOperator::Limit {
519                input: Box::new(plan),
520                count: limit.value()?,
521            };
522        }
523
524        for branch in &query.union_branches {
525            let mut planner = LogicalPlanner::new(self.config)
526                .with_fan_out(self.fan_out)
527                .with_adjacency(self.adjacency);
528            planner.calls = self.calls;
529            planner.rich_paths = self.rich_paths;
530            if preserve_entities {
531                planner.variables = imports.0.clone();
532                planner.node_lists = imports.1.clone();
533                planner.path_labels = imports.2.clone();
534            }
535            let right = planner.plan_query(&branch.query, seed.clone(), preserve_entities)?;
536            self.calls = planner.calls;
537            if preserve_entities {
538                let node_bindings = |variables: &HashMap<String, String>| {
539                    variables
540                        .iter()
541                        .filter(|(_, label)| self.config.get_node_mapping(label).is_some())
542                        .map(|(name, label)| (name.clone(), label.clone()))
543                        .collect::<HashMap<_, _>>()
544                };
545                if node_bindings(&self.variables) != node_bindings(&planner.variables)
546                    || self.node_lists != planner.node_lists
547                {
548                    return Err(GraphError::PlanError {
549                        message: "CALL UNION node and node-list outputs must retain the same labels in every branch".into(),
550                        location: snafu::Location::new(file!(), line!(), column!()),
551                    });
552                }
553            }
554            plan = LogicalOperator::QueryUnion {
555                left: Box::new(plan),
556                right: Box::new(right),
557                all: branch.all,
558            };
559        }
560        Ok(plan)
561    }
562
563    fn plan_reading_clauses(
564        &mut self,
565        base_plan: Option<LogicalOperator>,
566        reading_clauses: &[ReadingClause],
567        filter: Option<&BooleanExpression>,
568    ) -> Result<LogicalOperator> {
569        let mut plan = base_plan;
570
571        if reading_clauses.is_empty() && plan.is_none() {
572            plan = Some(LogicalOperator::SingleRow);
573        }
574
575        for clause in reading_clauses {
576            plan = Some(self.plan_reading_clause_with_base(plan, clause, filter)?);
577        }
578
579        plan.ok_or_else(|| GraphError::PlanError {
580            message: "Failed to plan clauses".to_string(),
581            location: snafu::Location::new(file!(), line!(), column!()),
582        })
583    }
584
585    /// Plan a single READING clause, optionally starting from an existing base plan
586    fn plan_reading_clause_with_base(
587        &mut self,
588        base: Option<LogicalOperator>,
589        clause: &ReadingClause,
590        filter: Option<&BooleanExpression>,
591    ) -> Result<LogicalOperator> {
592        match clause {
593            ReadingClause::Call(query) => self.plan_call(base, query),
594            ReadingClause::Match(match_clause) if match_clause.optional => {
595                self.plan_optional_match(base, match_clause, None)
596            }
597            ReadingClause::Match(match_clause) => {
598                let plan = self.plan_match_clause_with_base(base, match_clause, filter)?;
599                Ok(match &match_clause.where_clause {
600                    Some(w) => self.filter(plan, &w.expression)?,
601                    None => plan,
602                })
603            }
604            ReadingClause::Unwind(unwind_clause) => {
605                self.plan_unwind_clause_with_base(base, unwind_clause)
606            }
607        }
608    }
609
610    fn plan_call(
611        &mut self,
612        input: Option<LogicalOperator>,
613        query: &CypherQuery,
614    ) -> Result<LogicalOperator> {
615        let imports = query.call_union_imports()?;
616        self.calls += 1;
617        let id = self.calls;
618        let mut inner = Self::new(self.config)
619            .with_fan_out(self.fan_out)
620            .with_adjacency(self.adjacency);
621        inner.calls = self.calls;
622        inner.rich_paths = self.rich_paths;
623        for name in &imports {
624            if let Some(label) = self.variables.get(name) {
625                inner.variables.insert(name.clone(), label.clone());
626            }
627            if let Some(label) = self.node_lists.get(name) {
628                inner.node_lists.insert(name.clone(), label.clone());
629            }
630            if let Some(label) = self.path_labels.get(name) {
631                inner.path_labels.insert(name.clone(), label.clone());
632            }
633        }
634        let input_labels = inner.variables.clone();
635        let plan = inner.plan_query(
636            query,
637            Some(LogicalOperator::CallInput {
638                labels: input_labels,
639            }),
640            true,
641        )?;
642        self.calls = inner.calls;
643        let labels = inner.variables.clone();
644        self.variables.extend(inner.variables);
645        self.node_lists.extend(inner.node_lists);
646        for item in &query.return_clause.items {
647            if let ValueExpression::Variable(name) = &item.expression {
648                if let Some(label) = inner.path_labels.get(name) {
649                    self.path_labels
650                        .insert(item.alias.as_ref().unwrap_or(name).clone(), label.clone());
651                }
652            }
653        }
654        Ok(LogicalOperator::Apply {
655            input: Box::new(input.unwrap_or(LogicalOperator::SingleRow)),
656            inner: Box::new(plan),
657            imports,
658            labels,
659            id,
660        })
661    }
662
663    /// Plan an UNWIND clause
664    fn plan_unwind_clause_with_base(
665        &mut self,
666        base: Option<LogicalOperator>,
667        unwind_clause: &UnwindClause,
668    ) -> Result<LogicalOperator> {
669        let label = match &unwind_clause.expression {
670            ValueExpression::Variable(list) => self.node_lists.get(list).cloned(),
671            _ => None,
672        };
673        // A list of node ids unwinds to nodes; anything else to plain values.
674        let kind = label.clone().unwrap_or_else(|| "Unwound".to_string());
675        self.variables.insert(unwind_clause.alias.clone(), kind);
676        let mut expression = unwind_clause.expression.clone();
677        self.resolve_value(&mut expression);
678
679        Ok(LogicalOperator::Unwind {
680            input: base.map(Box::new),
681            expression,
682            alias: unwind_clause.alias.clone(),
683            label,
684        })
685    }
686
687    /// Resolve node variables to ids in scalar expressions while retaining path values.
688    /// Path functions and null checks are lowered according to the path representation.
689    /// Bare WITH and RETURN items, which carry whole nodes, are not resolved.
690    fn resolve_value(&self, expr: &mut ValueExpression) {
691        match expr {
692            ValueExpression::Variable(var) => {
693                let property = match self.variables.get(var).map(String::as_str) {
694                    Some("Path") if !self.rich_paths => Some("length".to_string()),
695                    Some("Path") => None,
696                    Some(label) => self
697                        .config
698                        .get_node_mapping(label)
699                        .map(|m| m.id_field.clone()),
700                    None => None,
701                };
702                if let Some(property) = property {
703                    *expr = ValueExpression::Property(PropertyRef {
704                        variable: var.clone(),
705                        property,
706                    });
707                }
708            }
709            ValueExpression::ScalarFunction { name, args }
710                if name.eq_ignore_ascii_case("labels") =>
711            {
712                if let ValueExpression::Variable(var) = &args[0] {
713                    if let Some(mapping) = self
714                        .variables
715                        .get(var)
716                        .and_then(|label| self.config.get_node_mapping(label))
717                    {
718                        *expr = ValueExpression::Case {
719                            whens: vec![(
720                                BooleanExpression::IsNull(ValueExpression::Property(PropertyRef {
721                                    variable: var.clone(),
722                                    property: mapping.id_field.clone(),
723                                })),
724                                ValueExpression::Literal(PropertyValue::Null),
725                            )],
726                            otherwise: Some(Box::new(ValueExpression::ScalarFunction {
727                                name: "__list".to_string(),
728                                args: vec![ValueExpression::Literal(PropertyValue::String(
729                                    mapping.label.clone(),
730                                ))],
731                            })),
732                        };
733                        return;
734                    }
735                }
736                args.iter_mut().for_each(|arg| self.resolve_value(arg));
737            }
738            // keys(node/relationship) binds the entity's property columns, not its ID.
739            ValueExpression::ScalarFunction { name, args }
740                if name.eq_ignore_ascii_case("keys")
741                    && matches!(args.as_slice(), [ValueExpression::Variable(_)]) => {}
742            // Path functions take the path variable itself.
743            ValueExpression::ScalarFunction { name, args }
744                if matches!(
745                    name.to_lowercase().as_str(),
746                    "length" | "nodes" | "relationships" | "__path_node_records"
747                ) => {}
748            ValueExpression::ScalarFunction { args, .. }
749            | ValueExpression::AggregateFunction { args, .. } => {
750                args.iter_mut().for_each(|a| self.resolve_value(a))
751            }
752            ValueExpression::Arithmetic { left, right, .. } => {
753                self.resolve_value(left);
754                self.resolve_value(right);
755            }
756            ValueExpression::Case { whens, otherwise } => {
757                for (condition, then) in whens {
758                    self.resolve_boolean(condition);
759                    self.resolve_value(then);
760                }
761                if let Some(e) = otherwise {
762                    self.resolve_value(e);
763                }
764            }
765            ValueExpression::Reduce {
766                initial,
767                list,
768                expression,
769                ..
770            } => {
771                self.resolve_value(initial);
772                self.resolve_value(list);
773                self.resolve_value(expression);
774            }
775            ValueExpression::ListPredicate {
776                list, predicate, ..
777            } => {
778                self.resolve_value(list);
779                self.resolve_value(predicate);
780            }
781            ValueExpression::Boolean(b) => self.resolve_boolean(b),
782            // A node inside a map keeps its properties (see `bind_value`), not just its id.
783            ValueExpression::Map(entries) => entries
784                .iter_mut()
785                .filter(|(_, v)| !matches!(v, ValueExpression::Variable(_)))
786                .for_each(|(_, v)| self.resolve_value(v)),
787            ValueExpression::Field { base, .. } => self.resolve_value(base),
788            _ => {}
789        }
790    }
791
792    /// [`Self::resolve_value`] for every value in a predicate.
793    fn resolve_boolean(&self, expr: &mut BooleanExpression) {
794        match expr {
795            BooleanExpression::Comparison { left, right, .. } => {
796                self.resolve_value(left);
797                self.resolve_value(right);
798            }
799            BooleanExpression::And(a, b) | BooleanExpression::Or(a, b) => {
800                self.resolve_boolean(a);
801                self.resolve_boolean(b);
802            }
803            BooleanExpression::Not(inner) => self.resolve_boolean(inner),
804            BooleanExpression::In { expression, list } => {
805                self.resolve_value(expression);
806                list.iter_mut().for_each(|v| self.resolve_value(v));
807            }
808            BooleanExpression::Member { expression, list } => {
809                self.resolve_value(expression);
810                self.resolve_value(list);
811            }
812            BooleanExpression::Like { expression, .. }
813            | BooleanExpression::ILike { expression, .. }
814            | BooleanExpression::Contains { expression, .. }
815            | BooleanExpression::StartsWith { expression, .. }
816            | BooleanExpression::EndsWith { expression, .. }
817            | BooleanExpression::IsNull(expression)
818            | BooleanExpression::IsNotNull(expression) => self.resolve_value(expression),
819            BooleanExpression::Exists(_) | BooleanExpression::PatternExists { .. } => {}
820        }
821    }
822
823    /// Sort items with node and path values resolved (see [`Self::resolve_value`]).
824    fn sort_items(&self, order_by: &OrderByClause) -> Vec<SortItem> {
825        order_by
826            .items
827            .iter()
828            .map(|item| {
829                let mut expression = item.expression.clone();
830                self.resolve_value(&mut expression);
831                SortItem {
832                    expression,
833                    direction: item.direction.clone(),
834                }
835            })
836            .collect()
837    }
838
839    /// Plan a single MATCH clause, optionally starting from an existing base plan.
840    ///
841    /// A clause that starts a plan may be re-rooted at its most selective node, using
842    /// `filter` (the query's WHERE clause); see [`crate::pattern_order`]. A clause that
843    /// continues an existing plan is planned as written.
844    fn plan_match_clause_with_base(
845        &mut self,
846        base: Option<LogicalOperator>,
847        match_clause: &MatchClause,
848        filter: Option<&BooleanExpression>,
849    ) -> Result<LogicalOperator> {
850        if match_clause.patterns.is_empty() {
851            return Err(GraphError::PlanError {
852                message: "MATCH clause must have at least one pattern".to_string(),
853                location: snafu::Location::new(file!(), line!(), column!()),
854            });
855        }
856        let inferred = MatchClause {
857            patterns: crate::pattern_alternatives::infer_labels(
858                &match_clause.patterns,
859                self.config,
860                &self.variables,
861            ),
862            ..match_clause.clone()
863        };
864        let match_clause = &inferred;
865        let retain_variable = self.retain_variable_alternation(&match_clause.patterns);
866        if crate::pattern_alternatives::needs_expansion(&match_clause.patterns, retain_variable) {
867            let branches = crate::pattern_alternatives::expand(
868                &match_clause.patterns,
869                self.config,
870                &self.variables,
871                retain_variable,
872            )?;
873            return self.plan_match_branches(base, branches, filter);
874        }
875
876        let reordered = base
877            .is_none()
878            .then(|| {
879                crate::pattern_order::reorder_patterns(&match_clause.patterns, filter, self.config)
880            })
881            .flatten();
882        let oriented: Option<Vec<GraphPattern>> = base.is_some().then(|| {
883            match_clause
884                .patterns
885                .iter()
886                .map(|p| match p {
887                    GraphPattern::Path(path) => self
888                        .cheaper_orientation(path)
889                        .map_or_else(|| p.clone(), GraphPattern::Path),
890                    _ => p.clone(),
891                })
892                .collect()
893        });
894        let patterns = reordered
895            .as_deref()
896            .or(oriented.as_deref())
897            .unwrap_or(&match_clause.patterns);
898
899        let mut plan = base;
900        for pattern in patterns {
901            match pattern {
902                GraphPattern::Node(node) => {
903                    let already_bound = node
904                        .variable
905                        .as_deref()
906                        .is_some_and(|v| self.variables.contains_key(v));
907
908                    match (already_bound, plan.as_ref()) {
909                        (true, _) => { /* no-op */ }
910                        (false, None) => plan = Some(self.plan_node_scan(node)?),
911                        (false, Some(_)) => {
912                            let right = self.plan_node_scan(node)?;
913                            plan = Some(LogicalOperator::Join {
914                                left: Box::new(plan.unwrap()),
915                                right: Box::new(right),
916                                join_type: JoinType::Cross, // TODO: infer better join type based on shared vars
917                            });
918                        }
919                    }
920                }
921                GraphPattern::Path(path) if path.variable.is_some() => {
922                    plan = Some(self.plan_named_path(plan, path)?)
923                }
924                GraphPattern::Path(path) if path.shortest.is_some() => {
925                    plan = Some(self.plan_shortest_path(plan, path)?)
926                }
927                GraphPattern::Path(path)
928                    if self.rich_paths
929                        && path
930                            .segments
931                            .iter()
932                            .any(|s| s.relationship.variable.is_some()) =>
933                {
934                    let mut path = path.clone();
935                    path.variable = Some(format!("_path_value_{}", self.variables.len()));
936                    plan = Some(self.plan_named_path(plan, &path)?);
937                }
938                GraphPattern::Path(path) => plan = Some(self.plan_path(plan, path)?),
939            }
940        }
941
942        plan.ok_or_else(|| GraphError::PlanError {
943            message: "Failed to plan MATCH clause".to_string(),
944            location: snafu::Location::new(file!(), line!(), column!()),
945        })
946    }
947
948    /// `input` filtered by `predicate`, after turning any EXISTS in it into mark columns.
949    fn filter(
950        &mut self,
951        input: LogicalOperator,
952        predicate: &BooleanExpression,
953    ) -> Result<LogicalOperator> {
954        let mut predicate = predicate.clone();
955        let saved = self.seed_source.replace(input.clone());
956        let input = self.rewrite_exists(input, &mut predicate);
957        self.seed_source = saved;
958        let input = input?;
959        self.resolve_boolean(&mut predicate);
960        Ok(LogicalOperator::Filter {
961            input: Box::new(input),
962            predicate,
963        })
964    }
965
966    /// Replace every pattern EXISTS in `expr` with `__exists_<n> IS NOT NULL`, adding to
967    /// `input` the LEFT JOIN that computes that mark: the pattern planned on top of the
968    /// distinct shared variables (as for OPTIONAL MATCH, so it can use the CSR), one row
969    /// per distinct binding of them with `true` as the mark. A row with no match gets a
970    /// null mark, so `NOT EXISTS` is true for it, as Cypher requires.
971    fn rewrite_exists(
972        &mut self,
973        mut input: LogicalOperator,
974        expr: &mut BooleanExpression,
975    ) -> Result<LogicalOperator> {
976        match expr {
977            BooleanExpression::And(a, b) | BooleanExpression::Or(a, b) => {
978                input = self.rewrite_exists(input, a)?;
979                self.rewrite_exists(input, b)
980            }
981            BooleanExpression::Not(inner) => self.rewrite_exists(input, inner),
982            BooleanExpression::Comparison { left, right, .. } => {
983                input = self.rewrite_exists_in_value(input, left)?;
984                self.rewrite_exists_in_value(input, right)
985            }
986            BooleanExpression::PatternExists {
987                patterns,
988                where_clause,
989            } => {
990                self.exists_marks += 1;
991                let mark = format!("__exists_{}", self.exists_marks);
992                let inner = MatchClause {
993                    patterns: patterns.clone(),
994                    optional: true,
995                    where_clause: where_clause.as_ref().map(|w| WhereClause {
996                        expression: (**w).clone(),
997                    }),
998                };
999                // The pattern's own new variables are local to the subquery.
1000                let before = self.variables.clone();
1001                let seed = self.seed_source.clone();
1002                let planned = self.plan_optional_match(Some(input), &inner, seed);
1003                self.variables = before;
1004                let LogicalOperator::OptionalMatch {
1005                    input,
1006                    optional,
1007                    keys,
1008                } = planned?
1009                else {
1010                    unreachable!("plan_optional_match returns an OptionalMatch")
1011                };
1012                let shared: Vec<String> = keys
1013                    .iter()
1014                    .map(|k| k.split("__").next().unwrap_or(k).to_string())
1015                    .collect();
1016                let mut projections: Vec<ProjectionItem> = shared
1017                    .iter()
1018                    .map(|var| ProjectionItem {
1019                        expression: ValueExpression::Variable(var.clone()),
1020                        alias: None,
1021                    })
1022                    .collect();
1023                projections.push(ProjectionItem {
1024                    expression: ValueExpression::Literal(PropertyValue::Boolean(true)),
1025                    alias: Some(mark.clone()),
1026                });
1027                let marked = LogicalOperator::Distinct {
1028                    input: Box::new(LogicalOperator::Project {
1029                        input: optional,
1030                        projections,
1031                    }),
1032                };
1033                *expr = BooleanExpression::IsNotNull(ValueExpression::Variable(mark));
1034                Ok(LogicalOperator::OptionalMatch {
1035                    input,
1036                    optional: Box::new(marked),
1037                    keys,
1038                })
1039            }
1040            _ => Ok(input),
1041        }
1042    }
1043
1044    /// [`Self::rewrite_exists`] for the conditions of a CASE inside a value.
1045    fn rewrite_exists_in_value(
1046        &mut self,
1047        mut input: LogicalOperator,
1048        expr: &mut ValueExpression,
1049    ) -> Result<LogicalOperator> {
1050        match expr {
1051            ValueExpression::ScalarFunction { name, args }
1052                if name.eq_ignore_ascii_case("labels")
1053                    && matches!(args.as_slice(), [ValueExpression::Variable(var)] if self.variables.get(var).is_some_and(|label| label.contains('|'))) =>
1054            {
1055                Err(GraphError::UnsupportedFeature {
1056                    feature: "labels() on a mixed-label binding is not supported".to_string(),
1057                    location: Location::new(file!(), line!(), column!()),
1058                })
1059            }
1060            ValueExpression::Case { whens, otherwise } => {
1061                for (condition, then) in whens.iter_mut() {
1062                    input = self.rewrite_exists(input, condition)?;
1063                    input = self.rewrite_exists_in_value(input, then)?;
1064                }
1065                match otherwise {
1066                    Some(e) => self.rewrite_exists_in_value(input, e),
1067                    None => Ok(input),
1068                }
1069            }
1070            // The unmapped comprehension retains every passing element, including
1071            // nulls and duplicates. Its size needs a filtered count, not ARRAY_AGG.
1072            // Keep mapped comprehensions unchanged so their evaluation/errors remain.
1073            ValueExpression::ScalarFunction { name, args }
1074                if name.eq_ignore_ascii_case("size")
1075                    && matches!(
1076                        args.as_slice(),
1077                        [ValueExpression::ListComprehension { map: None, .. }]
1078                    ) =>
1079            {
1080                let mut lowered = self.rewrite_exists_in_value(input, &mut args[0])?;
1081                let LogicalOperator::ListComprehension { count_only, .. } = &mut lowered else {
1082                    unreachable!("a list comprehension lowers to its own operator")
1083                };
1084                *count_only = true;
1085                *expr = args.remove(0);
1086                Ok(lowered)
1087            }
1088            ValueExpression::ScalarFunction { args, .. }
1089            | ValueExpression::AggregateFunction { args, .. } => {
1090                for arg in args.iter_mut() {
1091                    input = self.rewrite_exists_in_value(input, arg)?;
1092                }
1093                Ok(input)
1094            }
1095            ValueExpression::Arithmetic { left, right, .. } => {
1096                input = self.rewrite_exists_in_value(input, left)?;
1097                self.rewrite_exists_in_value(input, right)
1098            }
1099            ValueExpression::Reduce {
1100                initial,
1101                list,
1102                expression,
1103                ..
1104            } => {
1105                input = self.rewrite_exists_in_value(input, initial)?;
1106                input = self.rewrite_exists_in_value(input, list)?;
1107                self.rewrite_exists_in_value(input, expression)
1108            }
1109            ValueExpression::ListPredicate {
1110                kind,
1111                variable,
1112                list,
1113                predicate,
1114            } if crate::ast::needs_relational_evaluation(predicate) => {
1115                input = self.rewrite_exists_in_value(input, list)?;
1116                let original_list = (**list).clone();
1117                let mut map = (**predicate).clone();
1118                // The relational comprehension supplies this iterator as a column. Restore
1119                // inner lambda bindings after replacing references to the outer iterator.
1120                crate::ast::transform_value(&mut map, &mut |value| match value {
1121                    ValueExpression::LocalVariable(name) if name == variable => {
1122                        *value = ValueExpression::Variable(name.clone());
1123                    }
1124                    ValueExpression::Field { base, field } if matches!(base.as_ref(), ValueExpression::Variable(name) if name == variable) =>
1125                    {
1126                        *value = ValueExpression::Property(PropertyRef::new(
1127                            variable.clone(),
1128                            field.clone(),
1129                        ));
1130                    }
1131                    ValueExpression::Reduce {
1132                        accumulator,
1133                        variable,
1134                        expression,
1135                        ..
1136                    } => {
1137                        crate::ast::mark_locals(expression, &[accumulator, variable]);
1138                    }
1139                    ValueExpression::ListPredicate {
1140                        variable,
1141                        predicate,
1142                        ..
1143                    } => {
1144                        crate::ast::mark_locals(predicate, &[variable]);
1145                    }
1146                    _ => {}
1147                });
1148                let mut comprehension = ValueExpression::ListComprehension {
1149                    variable: variable.clone(),
1150                    list: list.clone(),
1151                    filter: None,
1152                    map: Some(Box::new(map)),
1153                };
1154                input = self.rewrite_exists_in_value(input, &mut comprehension)?;
1155                *expr = ValueExpression::Case {
1156                    whens: vec![(
1157                        BooleanExpression::IsNull(original_list),
1158                        ValueExpression::Literal(PropertyValue::Null),
1159                    )],
1160                    otherwise: Some(Box::new(ValueExpression::ScalarFunction {
1161                        name: format!("__predicate_{kind}"),
1162                        args: vec![comprehension],
1163                    })),
1164                };
1165                Ok(input)
1166            }
1167            ValueExpression::ListPredicate {
1168                list, predicate, ..
1169            } => {
1170                input = self.rewrite_exists_in_value(input, list)?;
1171                self.rewrite_exists_in_value(input, predicate)
1172            }
1173            ValueExpression::Boolean(b) => self.rewrite_exists(input, b),
1174            ValueExpression::Map(entries) => {
1175                for (_, v) in entries.iter_mut() {
1176                    input = self.rewrite_exists_in_value(input, v)?;
1177                }
1178                Ok(input)
1179            }
1180            ValueExpression::Field { base, .. } => self.rewrite_exists_in_value(input, base),
1181            ValueExpression::PatternComprehension {
1182                pattern,
1183                filter,
1184                map,
1185            } => {
1186                // As an EXISTS mark, but collecting `map` per binding of the shared
1187                // variables instead of marking `true`.
1188                self.comprehensions += 1;
1189                let column = format!("__pattern_{}", self.comprehensions);
1190                let inner = MatchClause {
1191                    patterns: vec![GraphPattern::Path((**pattern).clone())],
1192                    optional: true,
1193                    where_clause: filter.as_ref().map(|w| WhereClause {
1194                        expression: (**w).clone(),
1195                    }),
1196                };
1197                let before = self.variables.clone();
1198                let planned = (|| -> Result<(LogicalOperator, ValueExpression)> {
1199                    let seed = self.seed_source.clone();
1200                    let planned = self.plan_optional_match(Some(input), &inner, seed)?;
1201                    let mut value = (**map).clone();
1202                    let LogicalOperator::OptionalMatch {
1203                        input,
1204                        optional,
1205                        keys,
1206                    } = planned
1207                    else {
1208                        unreachable!("plan_optional_match returns an OptionalMatch")
1209                    };
1210                    let saved = self.seed_source.replace((*optional).clone());
1211                    let optional = self.rewrite_exists_in_value(*optional, &mut value);
1212                    self.seed_source = saved;
1213                    let optional = optional?;
1214                    self.resolve_value(&mut value);
1215                    let mut projections: Vec<ProjectionItem> = keys
1216                        .iter()
1217                        .map(|k| ProjectionItem {
1218                            expression: ValueExpression::Variable(
1219                                k.split("__").next().unwrap_or(k).to_string(),
1220                            ),
1221                            alias: None,
1222                        })
1223                        .collect();
1224                    projections.push(ProjectionItem {
1225                        expression: ValueExpression::AggregateFunction {
1226                            name: "collect".to_string(),
1227                            args: vec![value],
1228                            distinct: false,
1229                        },
1230                        alias: Some(column.clone()),
1231                    });
1232                    let collected = LogicalOperator::Project {
1233                        input: Box::new(optional),
1234                        projections,
1235                    };
1236                    let joined = LogicalOperator::OptionalMatch {
1237                        input,
1238                        optional: Box::new(collected),
1239                        keys,
1240                    };
1241                    // No match: the left join gives null, which is an empty list.
1242                    let result = ValueExpression::ScalarFunction {
1243                        name: "coalesce".to_string(),
1244                        args: vec![
1245                            ValueExpression::Variable(column.clone()),
1246                            ValueExpression::ScalarFunction {
1247                                name: "__list".to_string(),
1248                                args: vec![],
1249                            },
1250                        ],
1251                    };
1252                    Ok((joined, result))
1253                })();
1254                self.variables = before;
1255                let (joined, result) = planned?;
1256                *expr = result;
1257                Ok(joined)
1258            }
1259            ValueExpression::ListComprehension {
1260                variable,
1261                list,
1262                filter,
1263                map,
1264            } => {
1265                input = self.rewrite_exists_in_value(input, list)?;
1266                self.resolve_value(list);
1267                if self.rich_paths {
1268                    if let ValueExpression::ScalarFunction { name, .. } = list.as_mut() {
1269                        if name.eq_ignore_ascii_case("nodes") {
1270                            *name = "__path_node_records".into();
1271                        }
1272                    }
1273                }
1274                let label = match list.as_ref() {
1275                    ValueExpression::Variable(l) => self.node_lists.get(l).cloned(),
1276                    ValueExpression::ScalarFunction { name, args }
1277                        if name.eq_ignore_ascii_case("nodes") =>
1278                    {
1279                        match args.as_slice() {
1280                            [ValueExpression::Variable(p)] => self.path_labels.get(p).cloned(),
1281                            _ => None,
1282                        }
1283                    }
1284                    _ => None,
1285                };
1286                self.comprehensions += 1;
1287                let output = format!("__list_{}", self.comprehensions);
1288                // The variable is local: an element of the list, a node when it has a label.
1289                let before = self.variables.clone();
1290                let saved_seed = self.seed_source.clone();
1291                let kind = label.clone().unwrap_or_else(|| "Unwound".to_string());
1292                self.variables.insert(variable.clone(), kind);
1293                let lowered = (|| -> Result<LogicalOperator> {
1294                    let mut element = LogicalOperator::ComprehensionElement;
1295                    self.seed_source = Some(LogicalOperator::ComprehensionElement);
1296                    let mut keep = filter.as_deref().cloned();
1297                    if let Some(k) = &mut keep {
1298                        element = self.rewrite_exists(element, k)?;
1299                        self.resolve_boolean(k);
1300                    }
1301                    let mut value = map
1302                        .as_deref()
1303                        .cloned()
1304                        .unwrap_or_else(|| ValueExpression::Variable(variable.clone()));
1305                    element = self.rewrite_exists_in_value(element, &mut value)?;
1306                    self.resolve_value(&mut value);
1307                    Ok(LogicalOperator::ListComprehension {
1308                        input: Box::new(input),
1309                        list: (**list).clone(),
1310                        variable: variable.clone(),
1311                        label,
1312                        element: Box::new(element),
1313                        keep,
1314                        map: value,
1315                        output: output.clone(),
1316                        count_only: false,
1317                    })
1318                })();
1319                self.variables = before;
1320                self.seed_source = saved_seed;
1321                *expr = ValueExpression::Variable(output);
1322                lowered
1323            }
1324            _ => Ok(input),
1325        }
1326    }
1327
1328    /// A `shortestPath` pattern: end nodes not bound yet are scanned (and cross-joined with
1329    /// the rows so far), then the search runs per row between the two bound ids.
1330    fn plan_shortest_path(
1331        &mut self,
1332        base: Option<LogicalOperator>,
1333        path: &PathPattern,
1334    ) -> Result<LogicalOperator> {
1335        let spec = path.shortest.as_ref().expect("a shortest-path pattern");
1336        let unsupported = |what: &str| GraphError::UnsupportedFeature {
1337            feature: format!("shortestPath {what}"),
1338            location: Location::new(file!(), line!(), column!()),
1339        };
1340        let [segment] = path.segments.as_slice() else {
1341            return Err(unsupported("over more than one relationship pattern"));
1342        };
1343        let rel = &segment.relationship;
1344        let [rel_type] = rel.types.as_slice() else {
1345            return Err(unsupported("needs exactly one relationship type"));
1346        };
1347        if rel.variable.is_some() || !rel.properties.is_empty() {
1348            return Err(unsupported("with a relationship variable or properties"));
1349        }
1350        let mut plan = base;
1351        let mut ends = Vec::with_capacity(2);
1352        for node in [&path.start_node, &segment.end_node] {
1353            let Some(var) = &node.variable else {
1354                return Err(unsupported("with an unnamed end node"));
1355            };
1356            if !self.variables.contains_key(var) {
1357                let scan = self.plan_node_scan(node)?;
1358                plan = Some(match plan {
1359                    None => scan,
1360                    Some(p) => LogicalOperator::Join {
1361                        left: Box::new(p),
1362                        right: Box::new(scan),
1363                        join_type: JoinType::Cross,
1364                    },
1365                });
1366            }
1367            ends.push(var.clone());
1368        }
1369        let path_variable = spec
1370            .variable
1371            .clone()
1372            .unwrap_or_else(|| format!("_path_{}", self.variables.len()));
1373        // Not a node: the marker keeps it in WITH scope and matches no node mapping.
1374        self.variables
1375            .insert(path_variable.clone(), "Path".to_string());
1376        if let Some(label) = self.variables.get(&ends[0]).cloned() {
1377            self.path_labels.insert(path_variable.clone(), label);
1378        }
1379        let length = rel.length.as_ref();
1380        if self.rich_paths {
1381            return Ok(LogicalOperator::PathMatch {
1382                input: Box::new(plan.expect("both ends planned")),
1383                path: path.clone(),
1384            });
1385        }
1386        Ok(LogicalOperator::ShortestPath {
1387            input: Box::new(plan.expect("both end nodes are planned")),
1388            source_variable: ends[0].clone(),
1389            target_variable: ends[1].clone(),
1390            relationship_type: rel_type.clone(),
1391            direction: rel.direction.clone(),
1392            min_hops: length.and_then(|l| l.min).unwrap_or(1),
1393            max_hops: length.and_then(|l| l.max).unwrap_or(u32::MAX),
1394            all: spec.all,
1395            path_variable,
1396        })
1397    }
1398
1399    /// OPTIONAL MATCH: the pattern is planned on top of the distinct values of the node
1400    /// variables it shares with `base` (so expansion starts there, CSR included), filtered
1401    /// by its own WHERE, then left-joined back to `base` on those variables' ids.
1402    // shortcut: `base` is planned and run twice (left side and seed), since DataFusion
1403    // plans are trees; fine for the small inputs OPTIONAL MATCH usually sees.
1404    fn plan_optional_match(
1405        &mut self,
1406        base: Option<LogicalOperator>,
1407        clause: &MatchClause,
1408        seed_source: Option<LogicalOperator>,
1409    ) -> Result<LogicalOperator> {
1410        let unsupported = |what: &str| GraphError::UnsupportedFeature {
1411            feature: format!("OPTIONAL MATCH {what}"),
1412            location: Location::new(file!(), line!(), column!()),
1413        };
1414        let base = base.ok_or_else(|| unsupported("as the first clause"))?;
1415        let mut shared = Vec::new();
1416        for pattern in &clause.patterns {
1417            let nodes: Vec<&NodePattern> = match pattern {
1418                GraphPattern::Node(n) => vec![n],
1419                GraphPattern::Path(p) => std::iter::once(&p.start_node)
1420                    .chain(p.segments.iter().map(|s| &s.end_node))
1421                    .collect(),
1422            };
1423            for var in nodes.into_iter().filter_map(|n| n.variable.as_ref()) {
1424                if self.variables.contains_key(var) && !shared.contains(var) {
1425                    shared.push(var.clone());
1426                }
1427            }
1428        }
1429        let mut keys = Vec::with_capacity(shared.len());
1430        for var in &shared {
1431            let label = &self.variables[var];
1432            let mapping = self.config.get_node_mapping(label).ok_or_else(|| {
1433                unsupported(&format!("from '{var}', whose label '{label}' is ambiguous"))
1434            })?;
1435            keys.push(qualify_column(var, &mapping.id_field));
1436        }
1437        // The seed also carries the earlier scalars its WHERE reads (`friend IN friends`).
1438        let mut carried = shared.clone();
1439        if let Some(w) = &clause.where_clause {
1440            let mut names = std::collections::HashSet::new();
1441            crate::pattern_order::boolean_variables(&w.expression, &mut names);
1442            let local = crate::pattern_order::pattern_variables(&clause.patterns);
1443            let mut scalars: Vec<String> = names
1444                .into_iter()
1445                .filter(|n| !local.contains(*n) && !shared.iter().any(|v| v == *n))
1446                .map(str::to_string)
1447                .collect();
1448            scalars.sort();
1449            for variable in &scalars {
1450                // Binding variables distinguish iterator contexts. Untracked aggregate
1451                // captures can be re-evaluated in a different order by the seed plan.
1452                if !self.variables.contains_key(variable) && !shared.is_empty() {
1453                    continue;
1454                }
1455                keys.push(
1456                    self.variables
1457                        .get(variable)
1458                        .and_then(|label| self.config.get_node_mapping(label))
1459                        .map(|mapping| qualify_column(variable, &mapping.id_field))
1460                        .unwrap_or_else(|| variable.to_lowercase()),
1461                );
1462            }
1463            carried.extend(scalars);
1464        }
1465        if carried.is_empty() {
1466            return Err(unsupported("without a variable bound by an earlier clause"));
1467        }
1468        let seed = LogicalOperator::Distinct {
1469            input: Box::new(LogicalOperator::Project {
1470                input: Box::new(seed_source.unwrap_or_else(|| base.clone())),
1471                projections: carried
1472                    .iter()
1473                    .map(|var| ProjectionItem {
1474                        expression: ValueExpression::Variable(var.clone()),
1475                        alias: None,
1476                    })
1477                    .collect(),
1478            }),
1479        };
1480        let inner = MatchClause {
1481            patterns: clause.patterns.clone(),
1482            optional: false,
1483            where_clause: None,
1484        };
1485        let mut optional = self.plan_match_clause_with_base(Some(seed), &inner, None)?;
1486        if let Some(w) = &clause.where_clause {
1487            optional = self.filter(optional, &w.expression)?;
1488        }
1489        Ok(LogicalOperator::OptionalMatch {
1490            input: Box::new(base),
1491            optional: Box::new(optional),
1492            keys,
1493        })
1494    }
1495
1496    /// Plan each single-type, single-label branch like an ordinary MATCH and UNION ALL them.
1497    // shortcut: a `base` from earlier clauses is planned (and run) once per branch; fine for
1498    // the few branches alternation produces, revisit if bases get expensive.
1499    fn plan_match_branches(
1500        &mut self,
1501        base: Option<LogicalOperator>,
1502        branches: Vec<Vec<GraphPattern>>,
1503        filter: Option<&BooleanExpression>,
1504    ) -> Result<LogicalOperator> {
1505        let before = self.variables.clone();
1506        let mut inputs = Vec::new();
1507        let mut labels: HashMap<String, Vec<String>> = HashMap::new();
1508        for patterns in branches {
1509            self.variables = before.clone();
1510            let clause = MatchClause {
1511                patterns,
1512                optional: false,
1513                where_clause: None,
1514            };
1515            inputs.push(self.plan_match_clause_with_base(base.clone(), &clause, filter)?);
1516            for (var, label) in &self.variables {
1517                let seen = labels.entry(var.clone()).or_default();
1518                if !seen.contains(label) {
1519                    seen.push(label.clone());
1520                }
1521            }
1522        }
1523        // A variable with different labels per branch gets a combined name that matches no
1524        // node mapping, so a later clause expanding from it fails instead of guessing.
1525        self.variables = labels
1526            .into_iter()
1527            .map(|(var, labels)| (var, labels.join("|")))
1528            .collect();
1529        Ok(if inputs.len() == 1 {
1530            inputs.pop().expect("one branch")
1531        } else {
1532            LogicalOperator::Union { inputs }
1533        })
1534    }
1535
1536    /// Plan a node scan (ScanByLabel)
1537    fn plan_node_scan(&mut self, node: &NodePattern) -> Result<LogicalOperator> {
1538        let variable = node
1539            .variable
1540            .clone()
1541            .unwrap_or_else(|| format!("_node_{}", self.variables.len()));
1542
1543        // Validate label consistency if variable already exists
1544        self.validate_variable_label(&variable, &node.labels)?;
1545
1546        // Reuse existing label or derive from AST (default to "Node")
1547        let label = self
1548            .variables
1549            .get(&variable)
1550            .cloned()
1551            .or_else(|| node.labels.first().cloned())
1552            .unwrap_or_else(|| "Node".to_string());
1553
1554        // Register variable with its label
1555        self.variables.insert(variable.clone(), label.clone());
1556
1557        Ok(LogicalOperator::ScanByLabel {
1558            variable,
1559            label,
1560            properties: node.properties.clone(),
1561        })
1562    }
1563
1564    /// Validate that a node's explicit label (if any) matches the already-registered
1565    /// label for this variable. Returns `Ok(())` if the variable is new or if labels
1566    /// are consistent, and an error if they conflict.
1567    fn validate_variable_label(&self, variable: &str, ast_labels: &[String]) -> Result<()> {
1568        if let Some(existing_label) = self.variables.get(variable) {
1569            if let Some(ast_label) = ast_labels.first() {
1570                if ast_label != existing_label {
1571                    return Err(GraphError::PlanError {
1572                        message: format!(
1573                            "Variable '{}' already has label '{}', cannot redefine as '{}'",
1574                            variable, existing_label, ast_label
1575                        ),
1576                        location: snafu::Location::new(file!(), line!(), column!()),
1577                    });
1578                }
1579            }
1580        }
1581        Ok(())
1582    }
1583
1584    /// Plan a full path pattern, respecting the starting variable if provided
1585    fn plan_path(
1586        &mut self,
1587        base: Option<LogicalOperator>,
1588        path: &PathPattern,
1589    ) -> Result<LogicalOperator> {
1590        // Establish a base plan. A path that continues earlier rows from a node they don't
1591        // bind starts with a scan of that node, crossed with those rows.
1592        let start_bound = path
1593            .start_node
1594            .variable
1595            .as_ref()
1596            .is_some_and(|v| self.variables.contains_key(v));
1597        let (mut plan, mut current_src) = match base {
1598            Some(p) if start_bound => {
1599                let var = path
1600                    .start_node
1601                    .variable
1602                    .clone()
1603                    .expect("bound start is named");
1604                (p, var)
1605            }
1606            base => {
1607                let scan = self.plan_node_scan(&path.start_node)?;
1608                let var = self.extract_variable_from_plan(&scan)?;
1609                let plan = match base {
1610                    Some(p) => LogicalOperator::Join {
1611                        left: Box::new(p),
1612                        right: Box::new(scan),
1613                        join_type: JoinType::Cross,
1614                    },
1615                    None => scan,
1616                };
1617                (plan, var)
1618            }
1619        };
1620
1621        // Validate start node label consistency if variable already exists
1622        if let Some(start_var) = &path.start_node.variable {
1623            self.validate_variable_label(start_var, &path.start_node.labels)?;
1624        }
1625
1626        // For each segment, add an expand
1627        for segment in &path.segments {
1628            // Determine / register target variable
1629            let target_variable = segment
1630                .end_node
1631                .variable
1632                .clone()
1633                .unwrap_or_else(|| format!("_node_{}", self.variables.len()));
1634
1635            // Validate label consistency for already-registered variables
1636            self.validate_variable_label(&target_variable, &segment.end_node.labels)?;
1637
1638            // Reuse existing label or derive from AST (default to "Node")
1639            let target_label = self
1640                .variables
1641                .get(&target_variable)
1642                .cloned()
1643                .or_else(|| segment.end_node.labels.first().cloned())
1644                .unwrap_or_else(|| "Node".to_string());
1645
1646            self.variables
1647                .insert(target_variable.clone(), target_label.clone());
1648
1649            // Optimize fixed-length var-length expansions (*1 or *1..1)
1650            let next_plan = match segment.relationship.length.as_ref() {
1651                Some(length_range)
1652                    if length_range.min == Some(1) && length_range.max == Some(1) =>
1653                {
1654                    LogicalOperator::Expand {
1655                        input: Box::new(plan),
1656                        source_variable: current_src.clone(),
1657                        target_variable: target_variable.clone(),
1658                        target_label: target_label.clone(),
1659                        relationship_types: segment.relationship.types.clone(),
1660                        direction: segment.relationship.direction.clone(),
1661                        relationship_variable: segment.relationship.variable.clone(),
1662                        properties: segment.relationship.properties.clone(),
1663                        target_properties: segment.end_node.properties.clone(),
1664                    }
1665                }
1666                Some(length_range) => LogicalOperator::VariableLengthExpand {
1667                    input: Box::new(plan),
1668                    source_variable: current_src.clone(),
1669                    target_variable: target_variable.clone(),
1670                    target_label: target_label.clone(),
1671                    relationship_types: segment.relationship.types.clone(),
1672                    direction: segment.relationship.direction.clone(),
1673                    relationship_variable: segment.relationship.variable.clone(),
1674                    min_length: length_range.min,
1675                    max_length: length_range.max,
1676                    target_properties: segment.end_node.properties.clone(),
1677                },
1678                None => LogicalOperator::Expand {
1679                    input: Box::new(plan),
1680                    source_variable: current_src.clone(),
1681                    target_variable: target_variable.clone(),
1682                    target_label: target_label.clone(),
1683                    relationship_types: segment.relationship.types.clone(),
1684                    direction: segment.relationship.direction.clone(),
1685                    relationship_variable: segment.relationship.variable.clone(),
1686                    properties: segment.relationship.properties.clone(),
1687                    target_properties: segment.end_node.properties.clone(),
1688                },
1689            };
1690
1691            plan = next_plan;
1692            current_src = target_variable;
1693        }
1694
1695        Ok(plan)
1696    }
1697
1698    fn plan_named_path(
1699        &mut self,
1700        base: Option<LogicalOperator>,
1701        path: &PathPattern,
1702    ) -> Result<LogicalOperator> {
1703        let mut path = path.clone();
1704        if path.start_node.variable.is_none() {
1705            path.start_node.variable = Some(format!("_path_node_{}", self.variables.len()));
1706        }
1707        let start = path.start_node.variable.as_ref().unwrap();
1708        let base = if self.variables.contains_key(start) {
1709            base.expect("bound variables have input rows")
1710        } else {
1711            let scan = self.plan_node_scan(&path.start_node)?;
1712            match base {
1713                Some(input) => LogicalOperator::Join {
1714                    left: Box::new(input),
1715                    right: Box::new(scan),
1716                    join_type: JoinType::Cross,
1717                },
1718                None => scan,
1719            }
1720        };
1721        for segment in &mut path.segments {
1722            if segment.end_node.variable.is_none() {
1723                segment.end_node.variable = Some(format!("_path_node_{}", self.variables.len()));
1724            }
1725            let variable = segment.end_node.variable.as_ref().unwrap();
1726            self.validate_variable_label(variable, &segment.end_node.labels)?;
1727            let label = self
1728                .variables
1729                .get(variable)
1730                .cloned()
1731                .or_else(|| segment.end_node.labels.first().cloned())
1732                .ok_or_else(|| GraphError::UnsupportedFeature {
1733                    feature: "named path boundary needs a resolved node label".into(),
1734                    location: Location::new(file!(), line!(), column!()),
1735                })?;
1736            self.variables.insert(variable.clone(), label);
1737        }
1738        self.variables
1739            .insert(path.variable.clone().unwrap(), "Path".into());
1740        Ok(LogicalOperator::PathMatch {
1741            input: Box::new(base),
1742            path,
1743        })
1744    }
1745
1746    /// Extract the main variable from a logical plan (for chaining)
1747    #[allow(clippy::only_used_in_recursion)]
1748    fn extract_variable_from_plan(&self, plan: &LogicalOperator) -> Result<String> {
1749        match plan {
1750            LogicalOperator::Apply { input, .. } => self.extract_variable_from_plan(input),
1751            LogicalOperator::CallInput { .. } | LogicalOperator::SingleRow => {
1752                Err(GraphError::PlanError {
1753                    message: "a singleton row binds no pattern variable".to_string(),
1754                    location: snafu::Location::new(file!(), line!(), column!()),
1755                })
1756            }
1757            LogicalOperator::ScanByLabel { variable, .. } => Ok(variable.clone()),
1758            LogicalOperator::Unwind { alias, .. } => Ok(alias.clone()),
1759            LogicalOperator::Expand {
1760                target_variable, ..
1761            } => Ok(target_variable.clone()),
1762            LogicalOperator::VariableLengthExpand {
1763                target_variable, ..
1764            } => Ok(target_variable.clone()),
1765            LogicalOperator::Filter { input, .. } => self.extract_variable_from_plan(input),
1766            LogicalOperator::Project { input, .. } => self.extract_variable_from_plan(input),
1767            LogicalOperator::Distinct { input } => self.extract_variable_from_plan(input),
1768            LogicalOperator::Sort { input, .. } => self.extract_variable_from_plan(input),
1769            LogicalOperator::Offset { input, .. } => self.extract_variable_from_plan(input),
1770            LogicalOperator::Limit { input, .. } => self.extract_variable_from_plan(input),
1771            LogicalOperator::Join { left, right, .. }
1772            | LogicalOperator::QueryUnion { left, right, .. } => {
1773                // Prefer the right branch's tail variable, else fall back to left
1774                self.extract_variable_from_plan(right)
1775                    .or_else(|_| self.extract_variable_from_plan(left))
1776            }
1777            LogicalOperator::OptionalMatch { optional, .. } => {
1778                self.extract_variable_from_plan(optional)
1779            }
1780            LogicalOperator::ListComprehension { input, .. } => {
1781                self.extract_variable_from_plan(input)
1782            }
1783            LogicalOperator::ComprehensionElement => Err(GraphError::PlanError {
1784                message: "a comprehension's element rows bind no pattern variable".to_string(),
1785                location: snafu::Location::new(file!(), line!(), column!()),
1786            }),
1787            LogicalOperator::PathMatch { path, .. } => Ok(path
1788                .segments
1789                .last()
1790                .unwrap()
1791                .end_node
1792                .variable
1793                .clone()
1794                .unwrap()),
1795            LogicalOperator::ShortestPath {
1796                target_variable, ..
1797            } => Ok(target_variable.clone()),
1798            LogicalOperator::Union { inputs } => match inputs.first() {
1799                Some(first) => self.extract_variable_from_plan(first),
1800                None => Err(GraphError::PlanError {
1801                    message: "empty UNION".to_string(),
1802                    location: snafu::Location::new(file!(), line!(), column!()),
1803                }),
1804            },
1805        }
1806    }
1807
1808    /// Plan RETURN clause (Project)
1809    fn plan_return_clause(
1810        &mut self,
1811        return_clause: &ReturnClause,
1812        input: LogicalOperator,
1813    ) -> Result<LogicalOperator> {
1814        let mut projections: Vec<ProjectionItem> = Vec::new();
1815        let mut items = return_clause.items.clone();
1816        let mut input = input;
1817        let saved = self.seed_source.replace(input.clone());
1818        for item in &mut items {
1819            let rewritten = self.rewrite_exists_in_value(input, &mut item.expression);
1820            input = match rewritten {
1821                Ok(input) => input,
1822                Err(e) => {
1823                    self.seed_source = saved;
1824                    return Err(e);
1825                }
1826            };
1827        }
1828        self.seed_source = saved;
1829
1830        for item in &items {
1831            let alias = &item.alias;
1832            match &item.expression {
1833                ValueExpression::Variable(var) => {
1834                    match self.variables.get(var) {
1835                        // if it is a node variable, expand to all properties
1836                        Some(label) if label != "Unwound" && label != "Path" => {
1837                            let mapping = self.config.get_node_mapping(label).ok_or_else(|| {
1838                                GraphError::PlanError {
1839                                    message: format!("Node label '{}' doesn't exist", label),
1840                                    location: Location::new(file!(), line!(), column!()),
1841                                }
1842                            })?;
1843
1844                            projections.push(ProjectionItem {
1845                                expression: ValueExpression::Property(PropertyRef {
1846                                    variable: var.clone(),
1847                                    property: mapping.id_field.clone(),
1848                                }),
1849                                alias: alias
1850                                    .clone()
1851                                    .map(|name| format!("{}.{}", name, mapping.id_field)),
1852                            });
1853
1854                            for prop in &mapping.property_fields {
1855                                projections.push(ProjectionItem {
1856                                    expression: ValueExpression::Property(PropertyRef {
1857                                        variable: var.clone(),
1858                                        property: prop.clone(),
1859                                    }),
1860                                    alias: alias.clone().map(|name| format!("{}.{}", name, prop)),
1861                                });
1862                            }
1863                        }
1864                        _ => {
1865                            projections.push(ProjectionItem {
1866                                expression: item.expression.clone(),
1867                                alias: alias.clone(),
1868                            });
1869                        }
1870                    }
1871                }
1872                _ => {
1873                    let mut expression = item.expression.clone();
1874                    self.resolve_value(&mut expression);
1875                    projections.push(ProjectionItem {
1876                        expression,
1877                        alias: alias.clone(),
1878                    });
1879                }
1880            }
1881        }
1882
1883        // RETURN aliases shadow input entities in ORDER BY, after every item is bound.
1884        // Whole-node aliases expand to `alias.property`, so keep their entity binding.
1885        self.variables.retain(|variable, _| {
1886            !projections.iter().any(|projection| {
1887                projection
1888                    .alias
1889                    .as_ref()
1890                    .is_some_and(|alias| alias.eq_ignore_ascii_case(variable))
1891            })
1892        });
1893
1894        let mut plan = LogicalOperator::Project {
1895            input: Box::new(input),
1896            projections,
1897        };
1898
1899        // Add DISTINCT if needed
1900        if return_clause.distinct {
1901            plan = LogicalOperator::Distinct {
1902                input: Box::new(plan),
1903            };
1904        }
1905
1906        Ok(plan)
1907    }
1908
1909    /// Plan WITH clause - intermediate projection/aggregation with optional ORDER BY and LIMIT
1910    fn plan_with_clause(
1911        &mut self,
1912        with_clause: &WithClause,
1913        input: LogicalOperator,
1914    ) -> Result<LogicalOperator> {
1915        // WITH creates a projection (like RETURN), but its output must stay addressable by
1916        // later clauses: an unaliased `b.name` keeps the internal name `b__name`, and a
1917        // bare node variable passes all its columns through (see `build_simple_project`).
1918        let mut projections = Vec::with_capacity(with_clause.items.len());
1919        let mut items = with_clause.items.clone();
1920        let mut input = input;
1921        let saved = self.seed_source.replace(input.clone());
1922        for item in &mut items {
1923            let rewritten = self.rewrite_exists_in_value(input, &mut item.expression);
1924            input = match rewritten {
1925                Ok(input) => input,
1926                Err(e) => {
1927                    self.seed_source = saved;
1928                    return Err(e);
1929                }
1930            };
1931        }
1932        self.seed_source = saved;
1933        for item in &items {
1934            let alias = match (&item.alias, &item.expression) {
1935                (Some(alias), ValueExpression::Variable(var)) => {
1936                    // `WITH b AS x` renames a node: x keeps b's label.
1937                    if let Some(label) = self.variables.get(var).cloned() {
1938                        self.variables.insert(alias.clone(), label);
1939                    }
1940                    if let Some(label) = self.path_labels.get(var).cloned() {
1941                        self.path_labels.insert(alias.clone(), label);
1942                    }
1943                    Some(alias.clone())
1944                }
1945                (Some(alias), _) => Some(alias.clone()),
1946                (None, ValueExpression::Property(prop)) => {
1947                    Some(qualify_column(&prop.variable, &prop.property))
1948                }
1949                // A carried scalar (`WITH a, bCount`) keeps its own name, lowercased like every
1950                // alias, so later clauses find it (RETURN's display names keep the written case).
1951                (None, ValueExpression::Variable(var)) => Some(var.clone()),
1952                (None, _) => None,
1953            };
1954            let mut expression = item.expression.clone();
1955            if !matches!(expression, ValueExpression::Variable(_)) {
1956                self.resolve_value(&mut expression);
1957            }
1958            projections.push(ProjectionItem { expression, alias });
1959        }
1960        // Which outputs are lists of node ids: `collect(node)`, or such a list carried on.
1961        let mut node_lists = HashMap::new();
1962        for item in &items {
1963            let label = match &item.expression {
1964                ValueExpression::AggregateFunction { name, args, .. }
1965                    if name.eq_ignore_ascii_case("collect") =>
1966                {
1967                    match args.as_slice() {
1968                        [ValueExpression::Variable(v)] => self
1969                            .variables
1970                            .get(v)
1971                            .filter(|l| self.config.get_node_mapping(l).is_some())
1972                            .cloned(),
1973                        _ => None,
1974                    }
1975                }
1976                ValueExpression::Variable(v) => self.node_lists.get(v).cloned(),
1977                _ => None,
1978            };
1979            let name = match (&item.alias, &item.expression) {
1980                (Some(alias), _) => Some(alias.clone()),
1981                (None, ValueExpression::Variable(v)) => Some(v.clone()),
1982                _ => None,
1983            };
1984            if let (Some(label), Some(name)) = (label, name) {
1985                node_lists.insert(name, label);
1986            }
1987        }
1988        self.node_lists = node_lists;
1989        // WITH ends the scope of everything it does not project: a later MATCH reusing a
1990        // dropped name binds a new variable instead of expecting columns that are gone.
1991        let kept: Vec<&str> = with_clause
1992            .items
1993            .iter()
1994            .filter_map(|item| match (&item.alias, &item.expression) {
1995                (Some(alias), ValueExpression::Variable(_)) => Some(alias.as_str()),
1996                (None, ValueExpression::Variable(var)) => Some(var.as_str()),
1997                _ => None,
1998            })
1999            .collect();
2000        self.variables.retain(|var, _| kept.contains(&var.as_str()));
2001
2002        let mut plan = LogicalOperator::Project {
2003            input: Box::new(input),
2004            projections,
2005        };
2006        if with_clause.distinct {
2007            plan = LogicalOperator::Distinct {
2008                input: Box::new(plan),
2009            };
2010        }
2011
2012        // Apply ORDER BY within WITH if present
2013        if let Some(order_by) = &with_clause.order_by {
2014            plan = LogicalOperator::Sort {
2015                input: Box::new(plan),
2016                sort_items: self.sort_items(order_by),
2017            };
2018        }
2019
2020        // Apply LIMIT within WITH if present
2021        if let Some(limit) = &with_clause.limit {
2022            plan = LogicalOperator::Limit {
2023                input: Box::new(plan),
2024                count: limit.value()?,
2025            };
2026        }
2027
2028        if let Some(where_clause) = &with_clause.where_clause {
2029            plan = self.filter(plan, &where_clause.expression)?;
2030        }
2031
2032        Ok(plan)
2033    }
2034}
2035
2036#[cfg(test)]
2037mod tests {
2038    use super::*;
2039    use crate::{parser::parse_cypher_query, NodeMapping};
2040
2041    #[test]
2042    fn return_alias_shadows_node_only_after_projection() {
2043        let config = GraphConfig::builder()
2044            .node_table("Person", "id")
2045            .build()
2046            .unwrap();
2047        let query =
2048            parse_cypher_query("MATCH (p:Person) RETURN p.name AS P, p.id AS id ORDER BY p")
2049                .unwrap();
2050        let LogicalOperator::Sort { input, sort_items } =
2051            LogicalPlanner::new(&config).plan(&query).unwrap()
2052        else {
2053            panic!("expected sort");
2054        };
2055        assert_eq!(
2056            sort_items[0].expression,
2057            ValueExpression::Variable("p".into())
2058        );
2059        let LogicalOperator::Project { projections, .. } = *input else {
2060            panic!("expected projection");
2061        };
2062        assert_eq!(
2063            projections[1].expression,
2064            ValueExpression::Property(PropertyRef {
2065                variable: "p".into(),
2066                property: "id".into(),
2067            })
2068        );
2069
2070        let query = parse_cypher_query("MATCH (p:Person) RETURN p AS p ORDER BY p").unwrap();
2071        let LogicalOperator::Sort { sort_items, .. } =
2072            LogicalPlanner::new(&config).plan(&query).unwrap()
2073        else {
2074            panic!("expected sort");
2075        };
2076        assert_eq!(sort_items[0].expression, projections[1].expression);
2077    }
2078
2079    #[test]
2080    fn test_relationship_query_logical_plan_structure() {
2081        let query_text = r#"MATCH (p:Person {name: "Alice"})-[:KNOWS]->(f:Person) WHERE f.age > 30 RETURN f.name"#;
2082
2083        // Parse the query
2084        let ast = parse_cypher_query(query_text).unwrap();
2085
2086        // Plan to logical operators
2087        let config = GraphConfig::default();
2088        let mut planner = LogicalPlanner::new(&config);
2089        let logical_plan = planner.plan(&ast).unwrap();
2090
2091        // Verify the overall structure is a projection
2092        match &logical_plan {
2093            LogicalOperator::Project { input, projections } => {
2094                // Verify projection is f.name
2095                assert_eq!(projections.len(), 1);
2096                match &projections[0].expression {
2097                    ValueExpression::Property(prop_ref) => {
2098                        assert_eq!(prop_ref.variable, "f");
2099                        assert_eq!(prop_ref.property, "name");
2100                    }
2101                    _ => panic!("Expected property reference for f.name"),
2102                }
2103
2104                // Verify input is a filter for f.age > 30
2105                match input.as_ref() {
2106                    LogicalOperator::Filter {
2107                        predicate,
2108                        input: filter_input,
2109                    } => {
2110                        // Verify the predicate is f.age > 30
2111                        match predicate {
2112                            BooleanExpression::Comparison {
2113                                left,
2114                                operator,
2115                                right,
2116                            } => {
2117                                match left {
2118                                    ValueExpression::Property(prop_ref) => {
2119                                        assert_eq!(prop_ref.variable, "f");
2120                                        assert_eq!(prop_ref.property, "age");
2121                                    }
2122                                    _ => panic!("Expected property reference for f.age"),
2123                                }
2124                                assert_eq!(*operator, ComparisonOperator::GreaterThan);
2125                                match right {
2126                                    ValueExpression::Literal(PropertyValue::Integer(val)) => {
2127                                        assert_eq!(*val, 30);
2128                                    }
2129                                    _ => panic!("Expected integer literal 30"),
2130                                }
2131                            }
2132                            _ => panic!("Expected comparison expression"),
2133                        }
2134
2135                        // Verify the input to the filter is an expand operation
2136                        match filter_input.as_ref() {
2137                            LogicalOperator::Expand {
2138                                input: expand_input,
2139                                source_variable,
2140                                target_variable,
2141                                relationship_types,
2142                                direction,
2143                                ..
2144                            } => {
2145                                assert_eq!(source_variable, "p");
2146                                assert_eq!(target_variable, "f");
2147                                assert_eq!(relationship_types, &vec!["KNOWS".to_string()]);
2148                                assert_eq!(*direction, RelationshipDirection::Outgoing);
2149
2150                                // Verify the input to expand is a scan with properties for p.name = 'Alice'
2151                                match expand_input.as_ref() {
2152                                    LogicalOperator::ScanByLabel {
2153                                        variable,
2154                                        label,
2155                                        properties,
2156                                    } => {
2157                                        assert_eq!(variable, "p");
2158                                        assert_eq!(label, "Person");
2159
2160                                        // Verify the properties contain name = "Alice"
2161                                        assert_eq!(properties.len(), 1);
2162                                        match properties.get("name") {
2163                                            Some(PropertyValue::String(val)) => {
2164                                                assert_eq!(val, "Alice");
2165                                            }
2166                                            _ => {
2167                                                panic!("Expected name property with value 'Alice'")
2168                                            }
2169                                        }
2170                                    }
2171                                    _ => panic!("Expected ScanByLabel with properties for Person"),
2172                                }
2173                            }
2174                            _ => panic!("Expected Expand operation"),
2175                        }
2176                    }
2177                    _ => panic!("Expected Filter for f.age > 30"),
2178                }
2179            }
2180            _ => panic!("Expected Project at the top level"),
2181        }
2182    }
2183
2184    #[test]
2185    fn test_simple_node_query_logical_plan() {
2186        let query_text = "MATCH (n:Person) RETURN n.name";
2187
2188        let ast = parse_cypher_query(query_text).unwrap();
2189        let config = GraphConfig::default();
2190        let mut planner = LogicalPlanner::new(&config);
2191        let logical_plan = planner.plan(&ast).unwrap();
2192
2193        // Should be: Project { input: ScanByLabel }
2194        match &logical_plan {
2195            LogicalOperator::Project { input, projections } => {
2196                assert_eq!(projections.len(), 1);
2197                match input.as_ref() {
2198                    LogicalOperator::ScanByLabel {
2199                        variable, label, ..
2200                    } => {
2201                        assert_eq!(variable, "n");
2202                        assert_eq!(label, "Person");
2203                    }
2204                    _ => panic!("Expected ScanByLabel"),
2205                }
2206            }
2207            _ => panic!("Expected Project"),
2208        }
2209    }
2210
2211    #[test]
2212    fn test_node_with_properties_logical_plan() {
2213        let query_text = "MATCH (n:Person {age: 25}) RETURN n.name";
2214
2215        let ast = parse_cypher_query(query_text).unwrap();
2216        let config = GraphConfig::default();
2217        let mut planner = LogicalPlanner::new(&config);
2218        let logical_plan = planner.plan(&ast).unwrap();
2219
2220        // Should be: Project { input: ScanByLabel with properties }
2221        // Properties from MATCH clause are pushed down to the scan level
2222        match &logical_plan {
2223            LogicalOperator::Project { input, .. } => {
2224                match input.as_ref() {
2225                    LogicalOperator::ScanByLabel {
2226                        variable,
2227                        label,
2228                        properties,
2229                    } => {
2230                        assert_eq!(variable, "n");
2231                        assert_eq!(label, "Person");
2232
2233                        // Verify the properties are in the scan
2234                        assert_eq!(properties.len(), 1);
2235                        match properties.get("age") {
2236                            Some(PropertyValue::Integer(25)) => {}
2237                            _ => panic!("Expected age property with value 25"),
2238                        }
2239                    }
2240                    _ => panic!("Expected ScanByLabel with properties"),
2241                }
2242            }
2243            _ => panic!("Expected Project"),
2244        }
2245    }
2246
2247    #[test]
2248    fn test_variable_length_path_logical_plan() {
2249        let query_text = "MATCH (a:Person)-[:KNOWS*1..2]->(b:Person) RETURN b.name";
2250
2251        let ast = parse_cypher_query(query_text).unwrap();
2252        let config = GraphConfig::default();
2253        let mut planner = LogicalPlanner::new(&config);
2254        let logical_plan = planner.plan(&ast).unwrap();
2255
2256        // Should be: Project { input: VariableLengthExpand { input: ScanByLabel } }
2257        match &logical_plan {
2258            LogicalOperator::Project { input, .. } => match input.as_ref() {
2259                LogicalOperator::VariableLengthExpand {
2260                    input: expand_input,
2261                    source_variable,
2262                    target_variable,
2263                    relationship_types,
2264                    min_length,
2265                    max_length,
2266                    ..
2267                } => {
2268                    assert_eq!(source_variable, "a");
2269                    assert_eq!(target_variable, "b");
2270                    assert_eq!(relationship_types, &vec!["KNOWS".to_string()]);
2271                    assert_eq!(*min_length, Some(1));
2272                    assert_eq!(*max_length, Some(2));
2273
2274                    match expand_input.as_ref() {
2275                        LogicalOperator::ScanByLabel {
2276                            variable, label, ..
2277                        } => {
2278                            assert_eq!(variable, "a");
2279                            assert_eq!(label, "Person");
2280                        }
2281                        _ => panic!("Expected ScanByLabel"),
2282                    }
2283                }
2284                _ => panic!("Expected VariableLengthExpand"),
2285            },
2286            _ => panic!("Expected Project"),
2287        }
2288    }
2289
2290    #[test]
2291    fn test_where_clause_logical_plan() {
2292        // Note: Current parser only supports simple comparisons, not AND/OR
2293        let query_text = r#"MATCH (n:Person) WHERE n.age > 25 RETURN n.name"#;
2294
2295        let ast = parse_cypher_query(query_text).unwrap();
2296        let config = GraphConfig::default();
2297        let mut planner = LogicalPlanner::new(&config);
2298        let logical_plan = planner.plan(&ast).unwrap();
2299
2300        // Should be: Project { input: Filter { input: ScanByLabel } }
2301        match &logical_plan {
2302            LogicalOperator::Project { input, .. } => {
2303                match input.as_ref() {
2304                    LogicalOperator::Filter {
2305                        predicate,
2306                        input: scan_input,
2307                    } => {
2308                        // Verify it's a simple comparison: n.age > 25
2309                        match predicate {
2310                            BooleanExpression::Comparison {
2311                                left,
2312                                operator,
2313                                right: _,
2314                            } => {
2315                                match left {
2316                                    ValueExpression::Property(prop_ref) => {
2317                                        assert_eq!(prop_ref.variable, "n");
2318                                        assert_eq!(prop_ref.property, "age");
2319                                    }
2320                                    _ => panic!("Expected property reference for age"),
2321                                }
2322                                assert_eq!(*operator, ComparisonOperator::GreaterThan);
2323                            }
2324                            _ => panic!("Expected comparison expression"),
2325                        }
2326
2327                        match scan_input.as_ref() {
2328                            LogicalOperator::ScanByLabel { .. } => {}
2329                            _ => panic!("Expected ScanByLabel"),
2330                        }
2331                    }
2332                    _ => panic!("Expected Filter"),
2333                }
2334            }
2335            _ => panic!("Expected Project"),
2336        }
2337    }
2338
2339    #[test]
2340    fn test_multiple_node_patterns_join_in_match() {
2341        let query_text = "MATCH (a:Person), (b:Company) RETURN a.name, b.name";
2342
2343        let ast = parse_cypher_query(query_text).unwrap();
2344        let config = GraphConfig::default();
2345        let mut planner = LogicalPlanner::new(&config);
2346        let logical_plan = planner.plan(&ast).unwrap();
2347
2348        // Expect: Project { input: Join { left: Scan(a:Person), right: Scan(b:Company) } }
2349        match &logical_plan {
2350            LogicalOperator::Project { input, projections } => {
2351                assert_eq!(projections.len(), 2);
2352                match input.as_ref() {
2353                    LogicalOperator::Join {
2354                        left,
2355                        right,
2356                        join_type,
2357                    } => {
2358                        assert!(matches!(join_type, JoinType::Cross));
2359                        match left.as_ref() {
2360                            LogicalOperator::ScanByLabel {
2361                                variable, label, ..
2362                            } => {
2363                                assert_eq!(variable, "a");
2364                                assert_eq!(label, "Person");
2365                            }
2366                            _ => panic!("Expected left ScanByLabel for a:Person"),
2367                        }
2368                        match right.as_ref() {
2369                            LogicalOperator::ScanByLabel {
2370                                variable, label, ..
2371                            } => {
2372                                assert_eq!(variable, "b");
2373                                assert_eq!(label, "Company");
2374                            }
2375                            _ => panic!("Expected right ScanByLabel for b:Company"),
2376                        }
2377                    }
2378                    _ => panic!("Expected Join after Project"),
2379                }
2380            }
2381            _ => panic!("Expected Project at top level"),
2382        }
2383    }
2384
2385    #[test]
2386    fn test_shared_variable_chained_paths_in_match() {
2387        let query_text =
2388            "MATCH (a:Person)-[:KNOWS]->(b:Person), (b)-[:LIKES]->(c:Thing) RETURN c.name";
2389
2390        let ast = parse_cypher_query(query_text).unwrap();
2391        let config = GraphConfig::default();
2392        let mut planner = LogicalPlanner::new(&config);
2393        let logical_plan = planner.plan(&ast).unwrap();
2394
2395        // Expect: Project { input: Expand (b->c) { input: Expand (a->b) { input: Scan(a) } } }
2396        match &logical_plan {
2397            LogicalOperator::Project { input, .. } => match input.as_ref() {
2398                LogicalOperator::Expand {
2399                    source_variable: src2,
2400                    target_variable: tgt2,
2401                    input: inner2,
2402                    ..
2403                } => {
2404                    assert_eq!(src2, "b");
2405                    assert_eq!(tgt2, "c");
2406                    match inner2.as_ref() {
2407                        LogicalOperator::Expand {
2408                            source_variable: src1,
2409                            target_variable: tgt1,
2410                            input: inner1,
2411                            ..
2412                        } => {
2413                            assert_eq!(src1, "a");
2414                            assert_eq!(tgt1, "b");
2415                            match inner1.as_ref() {
2416                                LogicalOperator::ScanByLabel {
2417                                    variable, label, ..
2418                                } => {
2419                                    assert_eq!(variable, "a");
2420                                    assert_eq!(label, "Person");
2421                                }
2422                                _ => panic!("Expected ScanByLabel for a:Person"),
2423                            }
2424                        }
2425                        _ => panic!("Expected first Expand a->b"),
2426                    }
2427                }
2428                _ => panic!("Expected second Expand b->c at top of input"),
2429            },
2430            _ => panic!("Expected Project at top level"),
2431        }
2432    }
2433
2434    #[test]
2435    fn test_fixed_length_variable_path_is_expand() {
2436        let query_text = "MATCH (a:Person)-[:KNOWS*1..1]->(b:Person) RETURN b.name";
2437
2438        let ast = parse_cypher_query(query_text).unwrap();
2439        let config = GraphConfig::default();
2440        let mut planner = LogicalPlanner::new(&config);
2441        let logical_plan = planner.plan(&ast).unwrap();
2442
2443        match &logical_plan {
2444            LogicalOperator::Project { input, .. } => match input.as_ref() {
2445                LogicalOperator::Expand {
2446                    source_variable,
2447                    target_variable,
2448                    ..
2449                } => {
2450                    assert_eq!(source_variable, "a");
2451                    assert_eq!(target_variable, "b");
2452                }
2453                _ => panic!("Expected Expand for fixed-length *1..1"),
2454            },
2455            _ => panic!("Expected Project at top level"),
2456        }
2457    }
2458
2459    #[test]
2460    fn test_distinct_and_order_limit_wrapping() {
2461        // DISTINCT should wrap Project with Distinct
2462        let q1 = "MATCH (n:Person) RETURN DISTINCT n.name";
2463        let ast1 = parse_cypher_query(q1).unwrap();
2464        let config = GraphConfig::default();
2465        let mut planner = LogicalPlanner::new(&config);
2466        let logical1 = planner.plan(&ast1).unwrap();
2467        match logical1 {
2468            LogicalOperator::Distinct { input } => match *input {
2469                LogicalOperator::Project { .. } => {}
2470                _ => panic!("Expected Project under Distinct"),
2471            },
2472            _ => panic!("Expected Distinct at top level"),
2473        }
2474
2475        // ORDER BY + LIMIT should be Limit(Sort(Project(..)))
2476        let q2 = "MATCH (n:Person) RETURN n.name ORDER BY n.name LIMIT 10";
2477        let ast2 = parse_cypher_query(q2).unwrap();
2478        let mut planner2 = LogicalPlanner::new(&config);
2479        let logical2 = planner2.plan(&ast2).unwrap();
2480        match logical2 {
2481            LogicalOperator::Limit { input, count } => {
2482                assert_eq!(count, 10);
2483                match *input {
2484                    LogicalOperator::Sort { input: inner, .. } => match *inner {
2485                        LogicalOperator::Project { .. } => {}
2486                        _ => panic!("Expected Project under Sort"),
2487                    },
2488                    _ => panic!("Expected Sort under Limit"),
2489                }
2490            }
2491            _ => panic!("Expected Limit at top level"),
2492        }
2493    }
2494
2495    #[test]
2496    fn test_order_skip_limit_wrapping() {
2497        // ORDER BY + SKIP + LIMIT should be Limit(Offset(Sort(Project(..))))
2498        let q = "MATCH (n:Person) RETURN n.name ORDER BY n.name SKIP 5 LIMIT 10";
2499        let ast = parse_cypher_query(q).unwrap();
2500        let config = GraphConfig::default();
2501        let mut planner = LogicalPlanner::new(&config);
2502        let logical = planner.plan(&ast).unwrap();
2503        match logical {
2504            LogicalOperator::Limit { input, count } => {
2505                assert_eq!(count, 10);
2506                match *input {
2507                    LogicalOperator::Offset {
2508                        input: inner,
2509                        offset,
2510                    } => {
2511                        assert_eq!(offset, 5);
2512                        match *inner {
2513                            LogicalOperator::Sort { input: inner2, .. } => match *inner2 {
2514                                LogicalOperator::Project { .. } => {}
2515                                _ => panic!("Expected Project under Sort"),
2516                            },
2517                            _ => panic!("Expected Sort under Offset"),
2518                        }
2519                    }
2520                    _ => panic!("Expected Offset under Limit"),
2521                }
2522            }
2523            _ => panic!("Expected Limit at top level"),
2524        }
2525    }
2526
2527    #[test]
2528    fn test_skip_only_wrapping() {
2529        // SKIP only should be Offset(Project(..))
2530        let q = "MATCH (n:Person) RETURN n.name SKIP 3";
2531        let ast = parse_cypher_query(q).unwrap();
2532        let config = GraphConfig::default();
2533        let mut planner = LogicalPlanner::new(&config);
2534        let logical = planner.plan(&ast).unwrap();
2535        match logical {
2536            LogicalOperator::Offset { input, offset } => {
2537                assert_eq!(offset, 3);
2538                match *input {
2539                    LogicalOperator::Project { .. } => {}
2540                    _ => panic!("Expected Project under Offset"),
2541                }
2542            }
2543            _ => panic!("Expected Offset at top level"),
2544        }
2545    }
2546
2547    #[test]
2548    fn test_relationship_properties_pushed_into_expand() {
2549        let q = "MATCH (a)-[:KNOWS {since: 2020}]->(b) RETURN b.name";
2550        let ast = parse_cypher_query(q).unwrap();
2551        let config = GraphConfig::default();
2552        let mut planner = LogicalPlanner::new(&config);
2553        let logical = planner.plan(&ast).unwrap();
2554        match logical {
2555            LogicalOperator::Project { input, .. } => match *input {
2556                LogicalOperator::Expand { properties, .. } => match properties.get("since") {
2557                    Some(PropertyValue::Integer(2020)) => {}
2558                    _ => panic!("Expected relationship property since=2020 in Expand"),
2559                },
2560                _ => panic!("Expected Expand under Project"),
2561            },
2562            _ => panic!("Expected Project at top level"),
2563        }
2564    }
2565
2566    #[test]
2567    fn test_multiple_match_clauses_cross_join() {
2568        let q = "MATCH (a:Person) MATCH (b:Company) RETURN a.name, b.name";
2569        let ast = parse_cypher_query(q).unwrap();
2570        let config = GraphConfig::default();
2571        let mut planner = LogicalPlanner::new(&config);
2572        let logical = planner.plan(&ast).unwrap();
2573        match logical {
2574            LogicalOperator::Project { input, .. } => match *input {
2575                LogicalOperator::Join {
2576                    left,
2577                    right,
2578                    join_type,
2579                } => {
2580                    assert!(matches!(join_type, JoinType::Cross));
2581                    match (*left, *right) {
2582                        (
2583                            LogicalOperator::ScanByLabel {
2584                                variable: va,
2585                                label: la,
2586                                ..
2587                            },
2588                            LogicalOperator::ScanByLabel {
2589                                variable: vb,
2590                                label: lb,
2591                                ..
2592                            },
2593                        ) => {
2594                            assert_eq!(va, "a");
2595                            assert_eq!(la, "Person");
2596                            assert_eq!(vb, "b");
2597                            assert_eq!(lb, "Company");
2598                        }
2599                        _ => panic!("Expected two scans under Join"),
2600                    }
2601                }
2602                _ => panic!("Expected Join under Project"),
2603            },
2604            _ => panic!("Expected Project at top level"),
2605        }
2606    }
2607
2608    #[test]
2609    fn test_variable_only_node_default_label() {
2610        let q = "MATCH (x) RETURN x.name";
2611        let ast = parse_cypher_query(q).unwrap();
2612        let config = GraphConfig::default();
2613        let mut planner = LogicalPlanner::new(&config);
2614        let logical = planner.plan(&ast).unwrap();
2615        match logical {
2616            LogicalOperator::Project { input, .. } => match *input {
2617                LogicalOperator::ScanByLabel {
2618                    variable, label, ..
2619                } => {
2620                    assert_eq!(variable, "x");
2621                    assert_eq!(label, "Node");
2622                }
2623                _ => panic!("Expected ScanByLabel under Project"),
2624            },
2625            _ => panic!("Expected Project at top level"),
2626        }
2627    }
2628
2629    #[test]
2630    fn test_multi_label_node_is_a_union_of_its_labels() {
2631        // Upstream silently scanned only the first label.
2632        let q = "MATCH (n:Person:Employee) RETURN n.name";
2633        let ast = parse_cypher_query(q).unwrap();
2634        let config = GraphConfig::default();
2635        let mut planner = LogicalPlanner::new(&config);
2636        let logical = planner.plan(&ast).unwrap();
2637        let LogicalOperator::Project { input, .. } = logical else {
2638            panic!("Expected Project at top level");
2639        };
2640        let LogicalOperator::Union { inputs } = *input else {
2641            panic!("Expected Union under Project");
2642        };
2643        let labels: Vec<_> = inputs
2644            .iter()
2645            .map(|op| match op {
2646                LogicalOperator::ScanByLabel { label, .. } => label.as_str(),
2647                other => panic!("Expected ScanByLabel, got {other:?}"),
2648            })
2649            .collect();
2650        assert_eq!(labels, ["Person", "Employee"]);
2651    }
2652
2653    #[test]
2654    fn test_open_ended_and_partial_var_length_ranges() {
2655        // * (unbounded)
2656        let q1 = "MATCH (a)-[:R*]->(b:Node) RETURN b.name";
2657        let ast1 = parse_cypher_query(q1).unwrap();
2658        let config = GraphConfig::default();
2659        let mut planner1 = LogicalPlanner::new(&config);
2660        let plan1 = planner1.plan(&ast1).unwrap();
2661        match plan1 {
2662            LogicalOperator::Project { input, .. } => match *input {
2663                LogicalOperator::VariableLengthExpand {
2664                    min_length,
2665                    max_length,
2666                    ..
2667                } => {
2668                    assert_eq!(min_length, None);
2669                    assert_eq!(max_length, None);
2670                }
2671                _ => panic!("Expected VariableLengthExpand for *"),
2672            },
2673            _ => panic!("Expected Project at top level"),
2674        }
2675
2676        // *2.. (min only)
2677        let q2 = "MATCH (a)-[:R*2..]->(b) RETURN b.name";
2678        let ast2 = parse_cypher_query(q2).unwrap();
2679        let mut planner2 = LogicalPlanner::new(&config);
2680        let plan2 = planner2.plan(&ast2).unwrap();
2681        match plan2 {
2682            LogicalOperator::Project { input, .. } => match *input {
2683                LogicalOperator::VariableLengthExpand {
2684                    min_length,
2685                    max_length,
2686                    ..
2687                } => {
2688                    assert_eq!(min_length, Some(2));
2689                    assert_eq!(max_length, None);
2690                }
2691                _ => panic!("Expected VariableLengthExpand for *2.."),
2692            },
2693            _ => panic!("Expected Project at top level"),
2694        }
2695
2696        // *..3 (max only)
2697        let q3 = "MATCH (a)-[:R*..3]->(b) RETURN b.name";
2698        let ast3 = parse_cypher_query(q3).unwrap();
2699        let mut planner3 = LogicalPlanner::new(&config);
2700        let plan3 = planner3.plan(&ast3).unwrap();
2701        match plan3 {
2702            LogicalOperator::Project { input, .. } => match *input {
2703                LogicalOperator::VariableLengthExpand {
2704                    min_length,
2705                    max_length,
2706                    ..
2707                } => {
2708                    assert_eq!(min_length, None);
2709                    assert_eq!(max_length, Some(3));
2710                }
2711                _ => panic!("Expected VariableLengthExpand for *..3"),
2712            },
2713            _ => panic!("Expected Project at top level"),
2714        }
2715    }
2716
2717    #[test]
2718    fn test_variable_reuse_across_patterns() {
2719        let query_text =
2720            "MATCH (a:Person)-[:KNOWS]->(shared:Person), (shared)-[:KNOWS]->(b:Person) RETURN b.name";
2721
2722        let ast = parse_cypher_query(query_text).unwrap();
2723        let config = GraphConfig::default();
2724        let mut planner = LogicalPlanner::new(&config);
2725        let logical_plan = planner.plan(&ast).unwrap();
2726
2727        // Expect: Project { Expand(shared->b) { Expand(a->shared) { Scan(a) } } }
2728        match &logical_plan {
2729            LogicalOperator::Project { input, .. } => match input.as_ref() {
2730                LogicalOperator::Expand {
2731                    input: inner,
2732                    source_variable,
2733                    target_variable,
2734                    ..
2735                } => {
2736                    assert_eq!(source_variable, "shared");
2737                    assert_eq!(target_variable, "b");
2738
2739                    match inner.as_ref() {
2740                        LogicalOperator::Expand {
2741                            source_variable: first_src,
2742                            target_variable: first_dst,
2743                            ..
2744                        } => {
2745                            assert_eq!(first_src, "a");
2746                            assert_eq!(first_dst, "shared");
2747                        }
2748                        _ => panic!("Expected first Expand (a->shared)"),
2749                    }
2750                }
2751                _ => panic!("Expected second Expand (shared->b)"),
2752            },
2753            _ => panic!("Expected Project at top level"),
2754        }
2755    }
2756
2757    #[test]
2758    fn test_variable_reuse_with_conflicting_labels() {
2759        let query_text =
2760            "MATCH (a:Person)-[:KNOWS]->(shared:Person), (shared:Company)-[:EMPLOYS]->(b:Person) RETURN b.name";
2761
2762        let ast = parse_cypher_query(query_text).unwrap();
2763        let config = GraphConfig::default();
2764        let mut planner = LogicalPlanner::new(&config);
2765        let err = planner.plan(&ast).unwrap_err();
2766        let err_msg = err.to_string();
2767
2768        assert!(
2769            err_msg.contains("already has label 'Person'")
2770                && err_msg.contains("cannot redefine as 'Company'"),
2771            "Expected error about label conflict, got: {}",
2772            err_msg
2773        );
2774    }
2775
2776    #[test]
2777    fn test_return_node_variable() {
2778        let query_text = "MATCH (a:Person) RETURN a";
2779
2780        let ast = parse_cypher_query(query_text).unwrap();
2781        let config = GraphConfig::builder()
2782            .with_node_mapping(NodeMapping {
2783                label: "Person".to_string(),
2784                id_field: "id".to_string(),
2785                property_fields: vec!["name".to_string(), "age".to_string()],
2786                filter_conditions: None,
2787            })
2788            .build()
2789            .unwrap();
2790        let mut planner = LogicalPlanner::new(&config);
2791        let logical_plan = planner.plan(&ast).unwrap();
2792
2793        match &logical_plan {
2794            LogicalOperator::Project { projections, .. } => {
2795                assert_eq!(projections.len(), 3);
2796                match &projections[0].expression {
2797                    ValueExpression::Property(prop_ref) => {
2798                        assert_eq!(prop_ref.variable, "a");
2799                        assert_eq!(prop_ref.property, "id");
2800                    }
2801                    _ => panic!("Expected property reference for a.id"),
2802                }
2803                match &projections[1].expression {
2804                    ValueExpression::Property(prop_ref) => {
2805                        assert_eq!(prop_ref.variable, "a");
2806                        assert_eq!(prop_ref.property, "name");
2807                    }
2808                    _ => panic!("Expected property reference for a.name"),
2809                }
2810                match &projections[2].expression {
2811                    ValueExpression::Property(prop_ref) => {
2812                        assert_eq!(prop_ref.variable, "a");
2813                        assert_eq!(prop_ref.property, "age");
2814                    }
2815                    _ => panic!("Expected property reference for a.age"),
2816                }
2817            }
2818            _ => panic!("Expected Project at the top level"),
2819        }
2820    }
2821
2822    #[test]
2823    fn test_return_node_variable_with_alias() {
2824        let query_text = "MATCH (a:Person) RETURN a AS b";
2825
2826        let ast = parse_cypher_query(query_text).unwrap();
2827        let config = GraphConfig::builder()
2828            .node_table("Person", "id")
2829            .build()
2830            .unwrap();
2831        let mut planner = LogicalPlanner::new(&config);
2832        let logical_plan = planner.plan(&ast).unwrap();
2833
2834        match &logical_plan {
2835            LogicalOperator::Project { projections, .. } => {
2836                assert_eq!(projections.len(), 1);
2837                match &projections[0].expression {
2838                    ValueExpression::Property(prop_ref) => {
2839                        assert_eq!(prop_ref.variable, "a");
2840                        assert_eq!(prop_ref.property, "id");
2841                    }
2842                    _ => panic!("Expected property reference for a.id"),
2843                }
2844                match &projections[0].alias {
2845                    Some(alias) => assert_eq!(alias, "b.id"),
2846                    None => panic!("Expected alias for a.id as b.id"),
2847                }
2848            }
2849            _ => panic!("Expected Project at the top level"),
2850        }
2851    }
2852
2853    #[test]
2854    fn test_return_node_variable_no_label() {
2855        let query_text = "MATCH (a:Person) RETURN a";
2856
2857        let ast = parse_cypher_query(query_text).unwrap();
2858        let config = GraphConfig::default();
2859        let mut planner = LogicalPlanner::new(&config);
2860        let err = planner.plan(&ast).unwrap_err();
2861        let err_msg = err.to_string();
2862
2863        assert!(
2864            err_msg.contains("Node label 'Person' doesn't exist"),
2865            "Expected error about missing label 'Person', got: {}",
2866            err_msg
2867        );
2868    }
2869
2870    /// Relationship types of the Expands under `op`, innermost (first executed) first.
2871    fn expand_order(op: &LogicalOperator, out: &mut Vec<String>) {
2872        match op {
2873            LogicalOperator::Expand {
2874                input,
2875                relationship_types,
2876                ..
2877            } => {
2878                expand_order(input, out);
2879                out.push(relationship_types[0].clone());
2880            }
2881            LogicalOperator::Filter { input, .. }
2882            | LogicalOperator::Project { input, .. }
2883            | LogicalOperator::Distinct { input }
2884            | LogicalOperator::Sort { input, .. }
2885            | LogicalOperator::Limit { input, .. }
2886            | LogicalOperator::Offset { input, .. } => expand_order(input, out),
2887            LogicalOperator::OptionalMatch { optional, .. } => expand_order(optional, out),
2888            _ => {}
2889        }
2890    }
2891
2892    #[test]
2893    fn continued_path_starts_from_the_bound_end_with_smaller_fan_out() {
2894        let config = GraphConfig::builder()
2895            .node_table("Person", "id")
2896            .node_table("Post", "id")
2897            .node_table("Forum", "id")
2898            .rel_table("postHasCreator", "Post", "Person", "src", "dst")
2899            .rel_table("containerOf", "Forum", "Post", "src", "dst")
2900            .build()
2901            .unwrap();
2902        let q = "MATCH (friend:Person), (forum:Forum) WITH friend, forum \
2903                 OPTIONAL MATCH (friend)<-[:postHasCreator]-(post:Post)<-[:containerOf]-(forum) \
2904                 RETURN count(post.id) AS n";
2905        let ast = parse_cypher_query(q).unwrap();
2906        let first_hop = |creator_in: f64, container_out: f64| {
2907            let fan_out: FanOut = [
2908                (("posthascreator".to_string(), false), creator_in),
2909                (("posthascreator".to_string(), true), 1.0),
2910                (("containerof".to_string(), true), container_out),
2911                (("containerof".to_string(), false), 1.0),
2912            ]
2913            .into_iter()
2914            .collect();
2915            let plan = LogicalPlanner::new(&config)
2916                .with_fan_out(Some(&fan_out))
2917                .plan(&ast)
2918                .unwrap();
2919            let mut order = Vec::new();
2920            expand_order(&plan, &mut order);
2921            order[0].clone()
2922        };
2923        // Forums hold ~11 posts, people create ~100: start from the forum.
2924        assert_eq!(first_hop(100.0, 11.0), "containerOf");
2925        // The other way round keeps the written order.
2926        assert_eq!(first_hop(11.0, 100.0), "postHasCreator");
2927    }
2928
2929    #[test]
2930    fn continued_path_bound_only_at_its_far_end_starts_there() {
2931        // After `WITH DISTINCT b`, `a` is a new variable: expansion must start from `b`.
2932        let config = GraphConfig::builder()
2933            .node_table("Person", "id")
2934            .rel_table("KNOWS", "Person", "Person", "src", "dst")
2935            .build()
2936            .unwrap();
2937        let q = "MATCH (a:Person)-[:KNOWS]->(b:Person) WITH DISTINCT b \
2938                 MATCH (a:Person)-[:KNOWS]->(b) RETURN a.id, b.id";
2939        let plan = LogicalPlanner::new(&config)
2940            .plan(&parse_cypher_query(q).unwrap())
2941            .unwrap();
2942        let text = format!("{plan:?}");
2943        let last_expand = text.rfind("Expand {").unwrap();
2944        assert!(
2945            text[last_expand..].contains("source_variable: \"b\""),
2946            "{plan:#?}"
2947        );
2948    }
2949}