Skip to main content

cypherlite_query/planner/
mod.rs

1// Query planner: rule-based logical plan + physical plan conversion
2/// Rule-based optimizer that rewrites logical plans for better performance.
3pub mod optimize;
4
5use crate::parser::ast::*;
6use cypherlite_core::LabelRegistry;
7
8/// A logical plan node representing a query execution strategy.
9#[derive(Debug, Clone, PartialEq)]
10pub enum LogicalPlan {
11    /// Scan all nodes, optionally filtered by label ID.
12    /// If `limit` is Some, stop after that many nodes (for LIMIT pushdown optimization).
13    NodeScan {
14        /// Variable name to bind matched nodes.
15        variable: String,
16        /// Optional label ID filter.
17        label_id: Option<u32>,
18        /// Optional row limit (pushdown optimization).
19        limit: Option<usize>,
20    },
21    /// Expand from a source variable along edges of given type.
22    Expand {
23        /// Input plan.
24        source: Box<LogicalPlan>,
25        /// Source node variable.
26        src_var: String,
27        /// Optional relationship variable binding.
28        rel_var: Option<String>,
29        /// Target node variable.
30        target_var: String,
31        /// Optional relationship type ID filter.
32        rel_type_id: Option<u32>,
33        /// Edge traversal direction.
34        direction: RelDirection,
35        /// Optional temporal validity filter for edges.
36        temporal_filter: Option<TemporalFilterPlan>,
37    },
38    /// Filter rows by a predicate expression.
39    Filter {
40        /// Input plan.
41        source: Box<LogicalPlan>,
42        /// Boolean predicate expression.
43        predicate: Expression,
44    },
45    /// Project specific expressions (RETURN clause).
46    Project {
47        /// Input plan.
48        source: Box<LogicalPlan>,
49        /// Expressions to project.
50        items: Vec<ReturnItem>,
51        /// Whether DISTINCT was specified.
52        distinct: bool,
53    },
54    /// Sort rows (ORDER BY).
55    Sort {
56        /// Input plan.
57        source: Box<LogicalPlan>,
58        /// Sort keys and directions.
59        items: Vec<OrderItem>,
60    },
61    /// Skip N rows.
62    Skip {
63        /// Input plan.
64        source: Box<LogicalPlan>,
65        /// Number of rows to skip.
66        count: Expression,
67    },
68    /// Limit to N rows.
69    Limit {
70        /// Input plan.
71        source: Box<LogicalPlan>,
72        /// Maximum number of rows to emit.
73        count: Expression,
74    },
75    /// Aggregate (GROUP BY equivalent via function calls like count).
76    Aggregate {
77        /// Input plan.
78        source: Box<LogicalPlan>,
79        /// Grouping key expressions.
80        group_keys: Vec<Expression>,
81        /// Aggregate functions with output column names.
82        aggregates: Vec<(String, AggregateFunc)>,
83    },
84    /// Create nodes/edges.
85    CreateOp {
86        /// Optional input plan (None for standalone CREATE).
87        source: Option<Box<LogicalPlan>>,
88        /// Pattern describing entities to create.
89        pattern: Pattern,
90    },
91    /// Delete nodes/edges.
92    DeleteOp {
93        /// Input plan.
94        source: Box<LogicalPlan>,
95        /// Expressions identifying entities to delete.
96        exprs: Vec<Expression>,
97        /// Whether DETACH DELETE (also removes relationships).
98        detach: bool,
99    },
100    /// Set properties.
101    SetOp {
102        /// Input plan.
103        source: Box<LogicalPlan>,
104        /// Property assignments.
105        items: Vec<SetItem>,
106    },
107    /// Remove properties/labels.
108    RemoveOp {
109        /// Input plan.
110        source: Box<LogicalPlan>,
111        /// Items to remove.
112        items: Vec<RemoveItem>,
113    },
114    /// WITH clause: intermediate projection (scope reset).
115    With {
116        /// Input plan.
117        source: Box<LogicalPlan>,
118        /// Projected items.
119        items: Vec<ReturnItem>,
120        /// Optional WHERE filter.
121        where_clause: Option<Expression>,
122        /// Whether DISTINCT was specified.
123        distinct: bool,
124    },
125    /// UNWIND clause: flatten a list into rows.
126    Unwind {
127        /// Input plan.
128        source: Box<LogicalPlan>,
129        /// List expression to unwind.
130        expr: Expression,
131        /// Variable name bound to each element.
132        variable: String,
133    },
134    /// OPTIONAL MATCH expand: left join semantics.
135    /// If no matching edges found, emit one record with NULL for new variables.
136    OptionalExpand {
137        /// Input plan.
138        source: Box<LogicalPlan>,
139        /// Source node variable.
140        src_var: String,
141        /// Optional relationship variable binding.
142        rel_var: Option<String>,
143        /// Target node variable.
144        target_var: String,
145        /// Optional relationship type ID filter.
146        rel_type_id: Option<u32>,
147        /// Edge traversal direction.
148        direction: RelDirection,
149    },
150    /// MERGE: match-or-create pattern with optional ON MATCH/ON CREATE SET.
151    MergeOp {
152        /// Optional input plan (None for standalone MERGE).
153        source: Option<Box<LogicalPlan>>,
154        /// Pattern to match or create.
155        pattern: Pattern,
156        /// SET items for ON MATCH.
157        on_match: Vec<SetItem>,
158        /// SET items for ON CREATE.
159        on_create: Vec<SetItem>,
160    },
161    /// Empty source (produces one empty row).
162    EmptySource,
163    /// CREATE INDEX DDL operation (node label index).
164    CreateIndex {
165        /// Optional index name.
166        name: Option<String>,
167        /// Target label name.
168        label: String,
169        /// Target property name.
170        property: String,
171    },
172    /// CREATE EDGE INDEX DDL operation (relationship type index).
173    CreateEdgeIndex {
174        /// Optional index name.
175        name: Option<String>,
176        /// Target relationship type name.
177        rel_type: String,
178        /// Target property name.
179        property: String,
180    },
181    /// DROP INDEX DDL operation.
182    DropIndex {
183        /// Index name to drop.
184        name: String,
185    },
186    /// Variable-length path expansion (BFS/DFS traversal with depth bounds).
187    VarLengthExpand {
188        /// Input plan.
189        source: Box<LogicalPlan>,
190        /// Source node variable.
191        src_var: String,
192        /// Optional relationship variable binding.
193        rel_var: Option<String>,
194        /// Target node variable.
195        target_var: String,
196        /// Optional relationship type ID filter.
197        rel_type_id: Option<u32>,
198        /// Edge traversal direction.
199        direction: RelDirection,
200        /// Minimum traversal depth.
201        min_hops: u32,
202        /// Maximum traversal depth.
203        max_hops: u32,
204        /// Optional temporal validity filter for edges.
205        temporal_filter: Option<TemporalFilterPlan>,
206    },
207    /// Index-based scan: look up nodes by label + property value using an index.
208    /// The executor checks at runtime whether an index actually exists.
209    /// If no index is available, falls back to label scan + filter.
210    IndexScan {
211        /// Variable name to bind matched nodes.
212        variable: String,
213        /// Label ID for the index lookup.
214        label_id: u32,
215        /// Property key name.
216        prop_key: String,
217        /// Value to look up in the index.
218        lookup_value: Expression,
219    },
220    /// AT TIME query: find node/edge versions at a specific point in time.
221    AsOfScan {
222        /// Input plan.
223        source: Box<LogicalPlan>,
224        /// Timestamp expression to evaluate.
225        timestamp_expr: Expression,
226    },
227    /// BETWEEN TIME query: find all versions within a time range.
228    TemporalRangeScan {
229        /// Input plan.
230        source: Box<LogicalPlan>,
231        /// Start of the time range.
232        start_expr: Expression,
233        /// End of the time range.
234        end_expr: Expression,
235    },
236    /// Scan all subgraph entities. Used when MATCH pattern has label "Subgraph".
237    #[cfg(feature = "subgraph")]
238    SubgraphScan {
239        /// Variable name to bind matched subgraphs.
240        variable: String,
241    },
242    /// Scan all hyperedge entities.
243    #[cfg(feature = "hypergraph")]
244    HyperEdgeScan {
245        /// Variable name to bind matched hyperedges.
246        variable: String,
247    },
248    /// Create a hyperedge connecting multiple sources to multiple targets.
249    #[cfg(feature = "hypergraph")]
250    CreateHyperedgeOp {
251        /// Optional input plan.
252        source: Option<Box<LogicalPlan>>,
253        /// Optional variable binding for the new hyperedge.
254        variable: Option<String>,
255        /// Labels for the hyperedge.
256        labels: Vec<String>,
257        /// Source participant expressions.
258        sources: Vec<Expression>,
259        /// Target participant expressions.
260        targets: Vec<Expression>,
261    },
262    /// CREATE SNAPSHOT: execute a sub-query and materialize results into a subgraph.
263    #[cfg(feature = "subgraph")]
264    CreateSnapshotOp {
265        /// Optional variable binding for the new subgraph.
266        variable: Option<String>,
267        /// Labels for the subgraph.
268        labels: Vec<String>,
269        /// Properties to set on the subgraph.
270        properties: Option<MapLiteral>,
271        /// Optional temporal anchor expression.
272        temporal_anchor: Option<Expression>,
273        /// Inner query plan to execute.
274        sub_plan: Box<LogicalPlan>,
275        /// Variable names to collect from the inner query.
276        return_vars: Vec<String>,
277    },
278}
279
280/// Temporal filter plan for edge validity during AT TIME / BETWEEN TIME queries.
281/// Expressions are evaluated at execution time to produce concrete timestamps.
282#[derive(Debug, Clone, PartialEq)]
283pub enum TemporalFilterPlan {
284    /// Filter edges valid at a specific timestamp.
285    AsOf(Expression),
286    /// Filter edges with validity overlapping [start, end].
287    Between(Expression, Expression),
288}
289
290/// Supported aggregate functions.
291#[derive(Debug, Clone, PartialEq)]
292pub enum AggregateFunc {
293    /// `count(expr)` or `count(DISTINCT expr)`.
294    Count {
295        /// Whether DISTINCT was specified.
296        distinct: bool,
297    },
298    /// `count(*)`.
299    CountStar,
300}
301
302/// Error type for plan construction failures.
303#[derive(Debug, Clone, PartialEq)]
304pub struct PlanError {
305    /// Human-readable error description.
306    pub message: String,
307}
308
309impl std::fmt::Display for PlanError {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        write!(f, "Plan error: {}", self.message)
312    }
313}
314
315impl std::error::Error for PlanError {}
316
317/// Default maximum hops for unbounded variable-length paths.
318pub const DEFAULT_MAX_HOPS: u32 = 10;
319
320/// Walk a logical plan tree and set temporal_filter on Expand/VarLengthExpand nodes.
321/// This is called when a MATCH clause has a temporal predicate (AT TIME / BETWEEN TIME)
322/// so that edge traversal also filters edges by temporal validity.
323fn annotate_temporal_filter(plan: &mut LogicalPlan, tfp: &TemporalFilterPlan) {
324    match plan {
325        LogicalPlan::Expand {
326            source,
327            temporal_filter,
328            ..
329        } => {
330            *temporal_filter = Some(tfp.clone());
331            annotate_temporal_filter(source, tfp);
332        }
333        LogicalPlan::VarLengthExpand {
334            source,
335            temporal_filter,
336            ..
337        } => {
338            *temporal_filter = Some(tfp.clone());
339            annotate_temporal_filter(source, tfp);
340        }
341        LogicalPlan::Filter { source, .. }
342        | LogicalPlan::Project { source, .. }
343        | LogicalPlan::Sort { source, .. }
344        | LogicalPlan::Skip { source, .. }
345        | LogicalPlan::Limit { source, .. }
346        | LogicalPlan::Aggregate { source, .. }
347        | LogicalPlan::SetOp { source, .. }
348        | LogicalPlan::RemoveOp { source, .. }
349        | LogicalPlan::With { source, .. }
350        | LogicalPlan::Unwind { source, .. }
351        | LogicalPlan::DeleteOp { source, .. }
352        | LogicalPlan::OptionalExpand { source, .. }
353        | LogicalPlan::AsOfScan { source, .. }
354        | LogicalPlan::TemporalRangeScan { source, .. } => {
355            annotate_temporal_filter(source, tfp);
356        }
357        LogicalPlan::CreateOp { source, .. } | LogicalPlan::MergeOp { source, .. } => {
358            if let Some(s) = source {
359                annotate_temporal_filter(s, tfp);
360            }
361        }
362        // Leaf nodes: nothing to annotate
363        LogicalPlan::NodeScan { .. }
364        | LogicalPlan::IndexScan { .. }
365        | LogicalPlan::EmptySource
366        | LogicalPlan::CreateIndex { .. }
367        | LogicalPlan::CreateEdgeIndex { .. }
368        | LogicalPlan::DropIndex { .. } => {}
369        #[cfg(feature = "subgraph")]
370        LogicalPlan::SubgraphScan { .. } => {}
371        #[cfg(feature = "subgraph")]
372        LogicalPlan::CreateSnapshotOp { .. } => {}
373        #[cfg(feature = "hypergraph")]
374        LogicalPlan::HyperEdgeScan { .. } => {}
375        #[cfg(feature = "hypergraph")]
376        LogicalPlan::CreateHyperedgeOp { .. } => {}
377    }
378}
379
380/// Logical planner that converts a parsed Query AST into a LogicalPlan tree.
381pub struct LogicalPlanner<'a> {
382    registry: &'a mut dyn LabelRegistry,
383}
384
385impl<'a> LogicalPlanner<'a> {
386    /// Create a new planner backed by the given label/type registry.
387    pub fn new(registry: &'a mut dyn LabelRegistry) -> Self {
388        Self { registry }
389    }
390
391    /// Convert a parsed Query into a LogicalPlan.
392    pub fn plan(&mut self, query: &Query) -> Result<LogicalPlan, PlanError> {
393        let mut current: Option<LogicalPlan> = None;
394
395        for clause in &query.clauses {
396            current = Some(self.plan_clause(clause, current)?);
397        }
398
399        current.ok_or_else(|| PlanError {
400            message: "empty query produces no plan".to_string(),
401        })
402    }
403
404    fn plan_clause(
405        &mut self,
406        clause: &Clause,
407        current: Option<LogicalPlan>,
408    ) -> Result<LogicalPlan, PlanError> {
409        match clause {
410            Clause::Match(mc) => self.plan_match(mc, current),
411            Clause::Return(rc) => self.plan_return(rc, current),
412            Clause::Create(cc) => Ok(self.plan_create(cc, current)),
413            Clause::Set(sc) => self.plan_set(sc, current),
414            Clause::Delete(dc) => self.plan_delete(dc, current),
415            Clause::Remove(rc) => self.plan_remove(rc, current),
416            Clause::With(wc) => self.plan_with(wc, current),
417            Clause::Unwind(uc) => self.plan_unwind(uc, current),
418            Clause::Merge(mc) => Ok(self.plan_merge(mc, current)),
419            Clause::CreateIndex(ci) => match &ci.target {
420                crate::parser::ast::IndexTarget::NodeLabel(label) => Ok(LogicalPlan::CreateIndex {
421                    name: ci.name.clone(),
422                    label: label.clone(),
423                    property: ci.property.clone(),
424                }),
425                crate::parser::ast::IndexTarget::RelationshipType(rel_type) => {
426                    Ok(LogicalPlan::CreateEdgeIndex {
427                        name: ci.name.clone(),
428                        rel_type: rel_type.clone(),
429                        property: ci.property.clone(),
430                    })
431                }
432            },
433            Clause::DropIndex(di) => Ok(LogicalPlan::DropIndex {
434                name: di.name.clone(),
435            }),
436            #[cfg(feature = "subgraph")]
437            Clause::CreateSnapshot(sc) => self.plan_create_snapshot(sc),
438            #[cfg(feature = "hypergraph")]
439            Clause::CreateHyperedge(hc) => Ok(self.plan_create_hyperedge(hc, current)),
440            #[cfg(feature = "hypergraph")]
441            Clause::MatchHyperedge(mhc) => Ok(self.plan_match_hyperedge(mhc)),
442        }
443    }
444
445    fn plan_match(
446        &mut self,
447        mc: &MatchClause,
448        current: Option<LogicalPlan>,
449    ) -> Result<LogicalPlan, PlanError> {
450        if mc.optional {
451            return self.plan_optional_match(mc, current);
452        }
453
454        // Build plan from pattern chains.
455        // For now, handle the first chain only (single path pattern).
456        let chain = mc.pattern.chains.first().ok_or_else(|| PlanError {
457            message: "MATCH clause has no pattern chains".to_string(),
458        })?;
459
460        let mut plan = self.plan_pattern_chain(chain)?;
461
462        // If there was an existing plan, this is a subsequent MATCH.
463        // For simplicity, we replace with the new scan.
464        // A full implementation would do a cross product or join.
465        if let Some(prev) = current {
466            // For chained MATCH clauses, use previous plan as context.
467            // Simple approach: wrap previous in the new scan chain.
468            // For now, just use the new plan (covers most test cases).
469            let _ = prev;
470        }
471
472        // Apply temporal predicate if present.
473        if let Some(ref tp) = mc.temporal_predicate {
474            // DD-T4: Annotate Expand/VarLengthExpand nodes with temporal filter
475            // so edges are also filtered temporally during traversal.
476            let tfp = match tp {
477                crate::parser::ast::TemporalPredicate::AsOf(expr) => {
478                    TemporalFilterPlan::AsOf(expr.clone())
479                }
480                crate::parser::ast::TemporalPredicate::Between(start, end) => {
481                    TemporalFilterPlan::Between(start.clone(), end.clone())
482                }
483            };
484            annotate_temporal_filter(&mut plan, &tfp);
485
486            match tp {
487                crate::parser::ast::TemporalPredicate::AsOf(expr) => {
488                    plan = LogicalPlan::AsOfScan {
489                        source: Box::new(plan),
490                        timestamp_expr: expr.clone(),
491                    };
492                }
493                crate::parser::ast::TemporalPredicate::Between(start, end) => {
494                    plan = LogicalPlan::TemporalRangeScan {
495                        source: Box::new(plan),
496                        start_expr: start.clone(),
497                        end_expr: end.clone(),
498                    };
499                }
500            }
501        }
502
503        // Apply WHERE predicate as Filter.
504        if let Some(ref predicate) = mc.where_clause {
505            plan = LogicalPlan::Filter {
506                source: Box::new(plan),
507                predicate: predicate.clone(),
508            };
509        }
510
511        Ok(plan)
512    }
513
514    /// Plan an OPTIONAL MATCH clause. Produces OptionalExpand nodes with left join
515    /// semantics: if no match found, new variables are padded with NULL.
516    fn plan_optional_match(
517        &mut self,
518        mc: &MatchClause,
519        current: Option<LogicalPlan>,
520    ) -> Result<LogicalPlan, PlanError> {
521        let source = current.ok_or_else(|| PlanError {
522            message: "OPTIONAL MATCH requires a preceding MATCH clause".to_string(),
523        })?;
524
525        let chain = mc.pattern.chains.first().ok_or_else(|| PlanError {
526            message: "OPTIONAL MATCH clause has no pattern chains".to_string(),
527        })?;
528
529        let mut plan = source;
530
531        // The first element should be a node (anchor from previous MATCH).
532        let mut elements = chain.elements.iter();
533        let first_node = match elements.next() {
534            Some(PatternElement::Node(np)) => np,
535            _ => {
536                return Err(PlanError {
537                    message: "OPTIONAL MATCH pattern must start with a node".to_string(),
538                })
539            }
540        };
541
542        // The anchor variable binds to records from the source plan.
543        let _anchor_var = first_node.variable.clone().unwrap_or_default();
544
545        // Process relationship + target node pairs as OptionalExpand.
546        while let Some(rel_elem) = elements.next() {
547            let rel = match rel_elem {
548                PatternElement::Relationship(rp) => rp,
549                _ => {
550                    return Err(PlanError {
551                        message: "expected relationship after node in pattern".to_string(),
552                    })
553                }
554            };
555
556            let target_node = match elements.next() {
557                Some(PatternElement::Node(np)) => np,
558                _ => {
559                    return Err(PlanError {
560                        message: "expected node after relationship in pattern".to_string(),
561                    })
562                }
563            };
564
565            let src_var = Self::extract_src_var(&plan);
566            let rel_var = rel.variable.clone();
567            let target_var = target_node.variable.clone().unwrap_or_default();
568
569            let rel_type_id = rel
570                .rel_types
571                .first()
572                .map(|name| self.registry.get_or_create_rel_type(name));
573
574            plan = LogicalPlan::OptionalExpand {
575                source: Box::new(plan),
576                src_var,
577                rel_var,
578                target_var,
579                rel_type_id,
580                direction: rel.direction,
581            };
582        }
583
584        // Apply WHERE predicate as Filter.
585        if let Some(ref predicate) = mc.where_clause {
586            plan = LogicalPlan::Filter {
587                source: Box::new(plan),
588                predicate: predicate.clone(),
589            };
590        }
591
592        Ok(plan)
593    }
594
595    /// Build a combined equality predicate from inline property filters.
596    ///
597    /// Given `{name: 'Alice', age: 30}`, produces:
598    /// `variable.name = 'Alice' AND variable.age = 30`
599    ///
600    /// Returns `None` for an empty property list (or `None` input).
601    fn build_inline_property_predicate(
602        variable: &str,
603        properties: &[(String, Expression)],
604    ) -> Option<Expression> {
605        properties
606            .iter()
607            .map(|(key, val_expr)| {
608                Expression::BinaryOp(
609                    BinaryOp::Eq,
610                    Box::new(Expression::Property(
611                        Box::new(Expression::Variable(variable.to_string())),
612                        key.clone(),
613                    )),
614                    Box::new(val_expr.clone()),
615                )
616            })
617            .reduce(|acc, p| Expression::BinaryOp(BinaryOp::And, Box::new(acc), Box::new(p)))
618    }
619
620    fn plan_pattern_chain(&mut self, chain: &PatternChain) -> Result<LogicalPlan, PlanError> {
621        let mut elements = chain.elements.iter();
622
623        // First element must be a node.
624        let first_node = match elements.next() {
625            Some(PatternElement::Node(np)) => np,
626            _ => {
627                return Err(PlanError {
628                    message: "pattern chain must start with a node".to_string(),
629                })
630            }
631        };
632
633        let variable = first_node.variable.clone().unwrap_or_default();
634
635        // Check if the label is "Subgraph" -- route to SubgraphScan instead of NodeScan.
636        #[cfg(feature = "subgraph")]
637        let is_subgraph_label = first_node
638            .labels
639            .first()
640            .map(|l| l == "Subgraph")
641            .unwrap_or(false);
642
643        #[cfg(feature = "subgraph")]
644        if is_subgraph_label {
645            let mut plan = LogicalPlan::SubgraphScan {
646                variable: variable.clone(),
647            };
648
649            // Apply inline property filters as a Filter node.
650            if let Some(ref props) = first_node.properties {
651                if let Some(pred) = Self::build_inline_property_predicate(&variable, props) {
652                    plan = LogicalPlan::Filter {
653                        source: Box::new(plan),
654                        predicate: pred,
655                    };
656                }
657            }
658
659            // Process remaining relationship + node pairs (e.g., -[:CONTAINS]->(n)).
660            while let Some(rel_elem) = elements.next() {
661                let rel = match rel_elem {
662                    PatternElement::Relationship(rp) => rp,
663                    _ => {
664                        return Err(PlanError {
665                            message: "expected relationship after node in pattern".to_string(),
666                        })
667                    }
668                };
669
670                let target_node = match elements.next() {
671                    Some(PatternElement::Node(np)) => np,
672                    _ => {
673                        return Err(PlanError {
674                            message: "expected node after relationship in pattern".to_string(),
675                        })
676                    }
677                };
678
679                let src_var = Self::extract_src_var(&plan);
680                let target_var = target_node.variable.clone().unwrap_or_default();
681
682                let rel_type_id = rel
683                    .rel_types
684                    .first()
685                    .map(|name| self.registry.get_or_create_rel_type(name));
686
687                // Assign internal variable for anonymous relationships with properties.
688                let has_rel_props = rel.properties.as_ref().is_some_and(|p| !p.is_empty());
689                let rel_var = if rel.variable.is_some() {
690                    rel.variable.clone()
691                } else if has_rel_props {
692                    Some("_anon_rel".to_string())
693                } else {
694                    None
695                };
696
697                plan = LogicalPlan::Expand {
698                    source: Box::new(plan),
699                    src_var,
700                    rel_var: rel_var.clone(),
701                    target_var: target_var.clone(),
702                    rel_type_id,
703                    direction: rel.direction,
704                    temporal_filter: None,
705                };
706
707                // Apply inline property filter on the relationship.
708                if let Some(ref props) = rel.properties {
709                    if let Some(ref rv) = rel_var {
710                        if let Some(pred) = Self::build_inline_property_predicate(rv, props) {
711                            plan = LogicalPlan::Filter {
712                                source: Box::new(plan),
713                                predicate: pred,
714                            };
715                        }
716                    }
717                }
718
719                // Apply inline property filter on the target node.
720                if let Some(ref props) = target_node.properties {
721                    if let Some(pred) = Self::build_inline_property_predicate(&target_var, props) {
722                        plan = LogicalPlan::Filter {
723                            source: Box::new(plan),
724                            predicate: pred,
725                        };
726                    }
727                }
728            }
729
730            return Ok(plan);
731        }
732
733        let label_id = first_node
734            .labels
735            .first()
736            .map(|name| self.registry.get_or_create_label(name));
737
738        let mut plan = LogicalPlan::NodeScan {
739            variable: variable.clone(),
740            label_id,
741            limit: None,
742        };
743
744        // Apply inline property filters as a Filter node (e.g., {name: 'Alice'}).
745        if let Some(ref props) = first_node.properties {
746            if let Some(pred) = Self::build_inline_property_predicate(&variable, props) {
747                plan = LogicalPlan::Filter {
748                    source: Box::new(plan),
749                    predicate: pred,
750                };
751            }
752        }
753
754        // Process remaining relationship + node pairs.
755        while let Some(rel_elem) = elements.next() {
756            let rel = match rel_elem {
757                PatternElement::Relationship(rp) => rp,
758                _ => {
759                    return Err(PlanError {
760                        message: "expected relationship after node in pattern".to_string(),
761                    })
762                }
763            };
764
765            let target_node = match elements.next() {
766                Some(PatternElement::Node(np)) => np,
767                _ => {
768                    return Err(PlanError {
769                        message: "expected node after relationship in pattern".to_string(),
770                    })
771                }
772            };
773
774            let src_var = Self::extract_src_var(&plan);
775            let target_var = target_node.variable.clone().unwrap_or_default();
776
777            let rel_type_id = rel
778                .rel_types
779                .first()
780                .map(|name| self.registry.get_or_create_rel_type(name));
781
782            // If the relationship has inline properties but no explicit variable,
783            // assign an internal variable so the edge is bound for predicate filtering.
784            let has_rel_props = rel.properties.as_ref().is_some_and(|p| !p.is_empty());
785            let rel_var = if rel.variable.is_some() {
786                rel.variable.clone()
787            } else if has_rel_props {
788                Some("_anon_rel".to_string())
789            } else {
790                None
791            };
792
793            if rel.min_hops.is_some() {
794                // Variable-length path: use VarLengthExpand
795                let min = rel.min_hops.unwrap_or(1);
796                let max = rel.max_hops.unwrap_or(DEFAULT_MAX_HOPS);
797                plan = LogicalPlan::VarLengthExpand {
798                    source: Box::new(plan),
799                    src_var,
800                    rel_var: rel_var.clone(),
801                    target_var: target_var.clone(),
802                    rel_type_id,
803                    direction: rel.direction,
804                    min_hops: min,
805                    max_hops: max,
806                    temporal_filter: None,
807                };
808            } else {
809                plan = LogicalPlan::Expand {
810                    source: Box::new(plan),
811                    src_var,
812                    rel_var: rel_var.clone(),
813                    target_var: target_var.clone(),
814                    rel_type_id,
815                    direction: rel.direction,
816                    temporal_filter: None,
817                };
818            }
819
820            // Apply inline property filter on the relationship (e.g., {since: 2020}).
821            if let Some(ref props) = rel.properties {
822                if let Some(ref rv) = rel_var {
823                    if let Some(pred) = Self::build_inline_property_predicate(rv, props) {
824                        plan = LogicalPlan::Filter {
825                            source: Box::new(plan),
826                            predicate: pred,
827                        };
828                    }
829                }
830            }
831
832            // Apply inline property filter on the target node (e.g., (b:Person {name: 'Bob'})).
833            if let Some(ref props) = target_node.properties {
834                if let Some(pred) = Self::build_inline_property_predicate(&target_var, props) {
835                    plan = LogicalPlan::Filter {
836                        source: Box::new(plan),
837                        predicate: pred,
838                    };
839                }
840            }
841        }
842
843        Ok(plan)
844    }
845
846    /// Extract the "output variable" from a plan node (used as src_var for Expand).
847    fn extract_src_var(plan: &LogicalPlan) -> String {
848        match plan {
849            LogicalPlan::NodeScan { variable, .. } => variable.clone(),
850            LogicalPlan::Expand { target_var, .. } => target_var.clone(),
851            LogicalPlan::VarLengthExpand { target_var, .. } => target_var.clone(),
852            LogicalPlan::OptionalExpand { target_var, .. } => target_var.clone(),
853            LogicalPlan::Filter { source, .. } => Self::extract_src_var(source),
854            LogicalPlan::AsOfScan { source, .. } => Self::extract_src_var(source),
855            LogicalPlan::TemporalRangeScan { source, .. } => Self::extract_src_var(source),
856            #[cfg(feature = "subgraph")]
857            LogicalPlan::SubgraphScan { variable, .. } => variable.clone(),
858            #[cfg(feature = "hypergraph")]
859            LogicalPlan::HyperEdgeScan { variable, .. } => variable.clone(),
860            _ => String::new(),
861        }
862    }
863
864    fn plan_return(
865        &self,
866        rc: &ReturnClause,
867        current: Option<LogicalPlan>,
868    ) -> Result<LogicalPlan, PlanError> {
869        let source = current.ok_or_else(|| PlanError {
870            message: "RETURN clause requires a preceding data source".to_string(),
871        })?;
872
873        // Detect aggregate functions in RETURN items.
874        // If any item contains an aggregate, split into group_keys + aggregates.
875        let has_aggregate = rc
876            .items
877            .iter()
878            .any(|item| Self::is_aggregate_expr(&item.expr));
879
880        let mut plan = if has_aggregate {
881            let mut group_keys = Vec::new();
882            let mut aggregates = Vec::new();
883
884            for item in &rc.items {
885                if Self::is_aggregate_expr(&item.expr) {
886                    let alias = item
887                        .alias
888                        .clone()
889                        .unwrap_or_else(|| Self::default_agg_name(&item.expr));
890                    let func = Self::extract_aggregate_func(&item.expr)?;
891                    aggregates.push((alias, func));
892                } else {
893                    group_keys.push(item.expr.clone());
894                }
895            }
896
897            LogicalPlan::Aggregate {
898                source: Box::new(source),
899                group_keys,
900                aggregates,
901            }
902        } else {
903            LogicalPlan::Project {
904                source: Box::new(source),
905                items: rc.items.clone(),
906                distinct: rc.distinct,
907            }
908        };
909
910        // ORDER BY
911        if let Some(ref order_items) = rc.order_by {
912            plan = LogicalPlan::Sort {
913                source: Box::new(plan),
914                items: order_items.clone(),
915            };
916        }
917
918        // SKIP
919        if let Some(ref skip_expr) = rc.skip {
920            plan = LogicalPlan::Skip {
921                source: Box::new(plan),
922                count: skip_expr.clone(),
923            };
924        }
925
926        // LIMIT
927        if let Some(ref limit_expr) = rc.limit {
928            plan = LogicalPlan::Limit {
929                source: Box::new(plan),
930                count: limit_expr.clone(),
931            };
932        }
933
934        Ok(plan)
935    }
936
937    fn plan_create(&self, cc: &CreateClause, current: Option<LogicalPlan>) -> LogicalPlan {
938        LogicalPlan::CreateOp {
939            source: current.map(Box::new),
940            pattern: cc.pattern.clone(),
941        }
942    }
943
944    fn plan_merge(&self, mc: &MergeClause, current: Option<LogicalPlan>) -> LogicalPlan {
945        LogicalPlan::MergeOp {
946            source: current.map(Box::new),
947            pattern: mc.pattern.clone(),
948            on_match: mc.on_match.clone(),
949            on_create: mc.on_create.clone(),
950        }
951    }
952
953    fn plan_set(
954        &self,
955        sc: &SetClause,
956        current: Option<LogicalPlan>,
957    ) -> Result<LogicalPlan, PlanError> {
958        let source = current.ok_or_else(|| PlanError {
959            message: "SET clause requires a preceding data source".to_string(),
960        })?;
961
962        Ok(LogicalPlan::SetOp {
963            source: Box::new(source),
964            items: sc.items.clone(),
965        })
966    }
967
968    fn plan_delete(
969        &self,
970        dc: &DeleteClause,
971        current: Option<LogicalPlan>,
972    ) -> Result<LogicalPlan, PlanError> {
973        let source = current.ok_or_else(|| PlanError {
974            message: "DELETE clause requires a preceding data source".to_string(),
975        })?;
976
977        Ok(LogicalPlan::DeleteOp {
978            source: Box::new(source),
979            exprs: dc.exprs.clone(),
980            detach: dc.detach,
981        })
982    }
983
984    fn plan_with(
985        &self,
986        wc: &WithClause,
987        current: Option<LogicalPlan>,
988    ) -> Result<LogicalPlan, PlanError> {
989        let source = current.ok_or_else(|| PlanError {
990            message: "WITH clause requires a preceding data source".to_string(),
991        })?;
992
993        // Detect aggregate functions in WITH items.
994        // If any item contains an aggregate, split into group_keys + aggregates.
995        let has_aggregate = wc
996            .items
997            .iter()
998            .any(|item| Self::is_aggregate_expr(&item.expr));
999
1000        if has_aggregate {
1001            let mut group_keys = Vec::new();
1002            let mut aggregates = Vec::new();
1003
1004            for item in &wc.items {
1005                if Self::is_aggregate_expr(&item.expr) {
1006                    let alias = item
1007                        .alias
1008                        .clone()
1009                        .unwrap_or_else(|| Self::default_agg_name(&item.expr));
1010                    let func = Self::extract_aggregate_func(&item.expr)?;
1011                    aggregates.push((alias, func));
1012                } else {
1013                    group_keys.push(item.expr.clone());
1014                }
1015            }
1016
1017            let mut plan = LogicalPlan::Aggregate {
1018                source: Box::new(source),
1019                group_keys,
1020                aggregates,
1021            };
1022
1023            // Apply WITH WHERE after aggregation
1024            if let Some(ref predicate) = wc.where_clause {
1025                plan = LogicalPlan::Filter {
1026                    source: Box::new(plan),
1027                    predicate: predicate.clone(),
1028                };
1029            }
1030
1031            Ok(plan)
1032        } else {
1033            Ok(LogicalPlan::With {
1034                source: Box::new(source),
1035                items: wc.items.clone(),
1036                where_clause: wc.where_clause.clone(),
1037                distinct: wc.distinct,
1038            })
1039        }
1040    }
1041
1042    /// Check if an expression is an aggregate function.
1043    fn is_aggregate_expr(expr: &Expression) -> bool {
1044        match expr {
1045            Expression::CountStar => true,
1046            Expression::FunctionCall { name, .. } => {
1047                matches!(
1048                    name.to_lowercase().as_str(),
1049                    "count" | "sum" | "avg" | "min" | "max" | "collect"
1050                )
1051            }
1052            _ => false,
1053        }
1054    }
1055
1056    /// Extract an AggregateFunc from an aggregate expression.
1057    fn extract_aggregate_func(expr: &Expression) -> Result<AggregateFunc, PlanError> {
1058        match expr {
1059            Expression::CountStar => Ok(AggregateFunc::CountStar),
1060            Expression::FunctionCall { name, distinct, .. } => match name.to_lowercase().as_str() {
1061                "count" => Ok(AggregateFunc::Count {
1062                    distinct: *distinct,
1063                }),
1064                other => Err(PlanError {
1065                    message: format!("unsupported aggregate function: {}", other),
1066                }),
1067            },
1068            _ => Err(PlanError {
1069                message: "not an aggregate expression".to_string(),
1070            }),
1071        }
1072    }
1073
1074    /// Generate a default display name for an aggregate expression.
1075    fn default_agg_name(expr: &Expression) -> String {
1076        match expr {
1077            Expression::CountStar => "count(*)".to_string(),
1078            Expression::FunctionCall { name, .. } => format!("{}(..)", name),
1079            _ => "agg".to_string(),
1080        }
1081    }
1082
1083    fn plan_unwind(
1084        &self,
1085        uc: &UnwindClause,
1086        current: Option<LogicalPlan>,
1087    ) -> Result<LogicalPlan, PlanError> {
1088        let source = current.unwrap_or(LogicalPlan::EmptySource);
1089        Ok(LogicalPlan::Unwind {
1090            source: Box::new(source),
1091            expr: uc.expr.clone(),
1092            variable: uc.variable.clone(),
1093        })
1094    }
1095
1096    fn plan_remove(
1097        &self,
1098        rc: &RemoveClause,
1099        current: Option<LogicalPlan>,
1100    ) -> Result<LogicalPlan, PlanError> {
1101        let source = current.ok_or_else(|| PlanError {
1102            message: "REMOVE clause requires a preceding data source".to_string(),
1103        })?;
1104
1105        Ok(LogicalPlan::RemoveOp {
1106            source: Box::new(source),
1107            items: rc.items.clone(),
1108        })
1109    }
1110
1111    /// Plan a CREATE SNAPSHOT clause.
1112    /// Builds a sub-plan from the FROM MATCH + RETURN clauses, then wraps in CreateSnapshotOp.
1113    #[cfg(feature = "subgraph")]
1114    /// Plan a CREATE HYPEREDGE clause.
1115    #[cfg(feature = "hypergraph")]
1116    fn plan_create_hyperedge(
1117        &mut self,
1118        hc: &crate::parser::ast::CreateHyperedgeClause,
1119        current: Option<LogicalPlan>,
1120    ) -> LogicalPlan {
1121        LogicalPlan::CreateHyperedgeOp {
1122            source: current.map(Box::new),
1123            variable: hc.variable.clone(),
1124            labels: hc.labels.clone(),
1125            sources: hc.sources.clone(),
1126            targets: hc.targets.clone(),
1127        }
1128    }
1129
1130    /// Plan a MATCH HYPEREDGE clause.
1131    #[cfg(feature = "hypergraph")]
1132    fn plan_match_hyperedge(
1133        &mut self,
1134        mhc: &crate::parser::ast::MatchHyperedgeClause,
1135    ) -> LogicalPlan {
1136        let variable = mhc.variable.clone().unwrap_or_default();
1137        let mut plan = LogicalPlan::HyperEdgeScan {
1138            variable: variable.clone(),
1139        };
1140
1141        // If labels are specified, add a filter for rel_type_id
1142        if let Some(label) = mhc.labels.first() {
1143            let _rel_type_id = self.registry.get_or_create_rel_type(label);
1144            // We filter at execution time by comparing the hyperedge rel_type_id
1145            // For now, add a Filter that compares type(h) == label
1146            // Actually, we'll handle the label filtering at execution time via HyperEdgeScan
1147            // For simplicity, store the label in the plan via a Filter
1148            let _ = plan;
1149            plan = LogicalPlan::HyperEdgeScan {
1150                variable: variable.clone(),
1151            };
1152        }
1153
1154        plan
1155    }
1156
1157    #[cfg(feature = "subgraph")]
1158    fn plan_create_snapshot(
1159        &mut self,
1160        sc: &crate::parser::ast::CreateSnapshotClause,
1161    ) -> Result<LogicalPlan, PlanError> {
1162        // Build sub-plan from the FROM MATCH clause.
1163        let chain = sc
1164            .from_match
1165            .pattern
1166            .chains
1167            .first()
1168            .ok_or_else(|| PlanError {
1169                message: "CREATE SNAPSHOT FROM MATCH clause has no pattern chains".to_string(),
1170            })?;
1171        let mut sub_plan = self.plan_pattern_chain(chain)?;
1172
1173        // Apply WHERE predicate if present.
1174        if let Some(ref predicate) = sc.from_match.where_clause {
1175            sub_plan = LogicalPlan::Filter {
1176                source: Box::new(sub_plan),
1177                predicate: predicate.clone(),
1178            };
1179        }
1180
1181        // Project with the RETURN items.
1182        sub_plan = LogicalPlan::Project {
1183            source: Box::new(sub_plan),
1184            items: sc.from_return.clone(),
1185            distinct: false,
1186        };
1187
1188        // Collect variable names from RETURN items.
1189        let return_vars: Vec<String> = sc
1190            .from_return
1191            .iter()
1192            .map(|item| {
1193                if let Some(ref alias) = item.alias {
1194                    alias.clone()
1195                } else if let Expression::Variable(name) = &item.expr {
1196                    name.clone()
1197                } else {
1198                    String::new()
1199                }
1200            })
1201            .collect();
1202
1203        Ok(LogicalPlan::CreateSnapshotOp {
1204            variable: sc.variable.clone(),
1205            labels: sc.labels.clone(),
1206            properties: sc.properties.clone(),
1207            temporal_anchor: sc.temporal_anchor.clone(),
1208            sub_plan: Box::new(sub_plan),
1209            return_vars,
1210        })
1211    }
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216    use super::*;
1217    use crate::parser::parse_query;
1218    use cypherlite_storage::catalog::Catalog;
1219
1220    // Helper: parse + plan a query using a fresh Catalog.
1221    fn plan_query(input: &str) -> LogicalPlan {
1222        let query = parse_query(input).expect("should parse");
1223        let mut catalog = Catalog::default();
1224        let mut planner = LogicalPlanner::new(&mut catalog);
1225        planner.plan(&query).expect("should plan")
1226    }
1227
1228    // Helper: parse + plan, returning catalog for ID inspection.
1229    fn plan_query_with_catalog(input: &str) -> (LogicalPlan, Catalog) {
1230        let query = parse_query(input).expect("should parse");
1231        let mut catalog = Catalog::default();
1232        let plan = {
1233            let mut planner = LogicalPlanner::new(&mut catalog);
1234            planner.plan(&query).expect("should plan")
1235        };
1236        (plan, catalog)
1237    }
1238
1239    // ======================================================================
1240    // TASK-043: Planner unit tests
1241    // ======================================================================
1242
1243    /// MATCH (n:Person) RETURN n -> NodeScan + Project
1244    #[test]
1245    fn test_plan_single_node_match_return() {
1246        let (plan, catalog) = plan_query_with_catalog("MATCH (n:Person) RETURN n");
1247        let person_id = catalog.label_id("Person").expect("Person label exists");
1248
1249        // Outermost should be Project wrapping NodeScan.
1250        match &plan {
1251            LogicalPlan::Project {
1252                source, distinct, ..
1253            } => {
1254                assert!(!distinct);
1255                match source.as_ref() {
1256                    LogicalPlan::NodeScan {
1257                        variable, label_id, ..
1258                    } => {
1259                        assert_eq!(variable, "n");
1260                        assert_eq!(*label_id, Some(person_id));
1261                    }
1262                    other => panic!("expected NodeScan, got {:?}", other),
1263                }
1264            }
1265            other => panic!("expected Project, got {:?}", other),
1266        }
1267    }
1268
1269    /// MATCH (a)-[:KNOWS]->(b)-[:KNOWS]->(c) RETURN c
1270    /// -> NodeScan(a) + Expand(KNOWS, b) + Expand(KNOWS, c) + Project
1271    #[test]
1272    fn test_plan_2hop_match() {
1273        let (plan, catalog) =
1274            plan_query_with_catalog("MATCH (a)-[:KNOWS]->(b)-[:KNOWS]->(c) RETURN c");
1275        let knows_id = catalog.rel_type_id("KNOWS").expect("KNOWS rel type exists");
1276
1277        // Outermost: Project
1278        let project_source = match &plan {
1279            LogicalPlan::Project { source, .. } => source.as_ref(),
1280            other => panic!("expected Project, got {:?}", other),
1281        };
1282
1283        // Second Expand (b -> c)
1284        let expand1_source = match project_source {
1285            LogicalPlan::Expand {
1286                src_var,
1287                target_var,
1288                rel_type_id,
1289                direction,
1290                source,
1291                ..
1292            } => {
1293                assert_eq!(src_var, "b");
1294                assert_eq!(target_var, "c");
1295                assert_eq!(*rel_type_id, Some(knows_id));
1296                assert_eq!(*direction, RelDirection::Outgoing);
1297                source.as_ref()
1298            }
1299            other => panic!("expected Expand, got {:?}", other),
1300        };
1301
1302        // First Expand (a -> b)
1303        let scan = match expand1_source {
1304            LogicalPlan::Expand {
1305                src_var,
1306                target_var,
1307                rel_type_id,
1308                direction,
1309                source,
1310                ..
1311            } => {
1312                assert_eq!(src_var, "a");
1313                assert_eq!(target_var, "b");
1314                assert_eq!(*rel_type_id, Some(knows_id));
1315                assert_eq!(*direction, RelDirection::Outgoing);
1316                source.as_ref()
1317            }
1318            other => panic!("expected Expand, got {:?}", other),
1319        };
1320
1321        // NodeScan(a)
1322        match scan {
1323            LogicalPlan::NodeScan {
1324                variable, label_id, ..
1325            } => {
1326                assert_eq!(variable, "a");
1327                assert_eq!(*label_id, None); // no label on (a)
1328            }
1329            other => panic!("expected NodeScan, got {:?}", other),
1330        }
1331    }
1332
1333    /// MATCH (n:Person) WHERE n.age > 30 RETURN n -> NodeScan + Filter + Project
1334    #[test]
1335    fn test_plan_match_where_return() {
1336        let plan = plan_query("MATCH (n:Person) WHERE n.age > 30 RETURN n");
1337
1338        // Outermost: Project
1339        let project_source = match &plan {
1340            LogicalPlan::Project { source, .. } => source.as_ref(),
1341            other => panic!("expected Project, got {:?}", other),
1342        };
1343
1344        // Filter
1345        let filter_source = match project_source {
1346            LogicalPlan::Filter {
1347                source, predicate, ..
1348            } => {
1349                // Verify predicate is n.age > 30
1350                match predicate {
1351                    Expression::BinaryOp(BinaryOp::Gt, lhs, rhs) => {
1352                        assert_eq!(
1353                            **lhs,
1354                            Expression::Property(
1355                                Box::new(Expression::Variable("n".to_string())),
1356                                "age".to_string()
1357                            )
1358                        );
1359                        assert_eq!(**rhs, Expression::Literal(Literal::Integer(30)));
1360                    }
1361                    other => panic!("expected BinaryOp Gt, got {:?}", other),
1362                }
1363                source.as_ref()
1364            }
1365            other => panic!("expected Filter, got {:?}", other),
1366        };
1367
1368        // NodeScan
1369        match filter_source {
1370            LogicalPlan::NodeScan {
1371                variable, label_id, ..
1372            } => {
1373                assert_eq!(variable, "n");
1374                assert!(label_id.is_some());
1375            }
1376            other => panic!("expected NodeScan, got {:?}", other),
1377        }
1378    }
1379
1380    /// MATCH (n) CREATE (m:Person {name: "Alice"}) -> NodeScan + CreateOp
1381    #[test]
1382    fn test_plan_match_create() {
1383        let plan = plan_query("MATCH (n) CREATE (m:Person {name: 'Alice'})");
1384
1385        match &plan {
1386            LogicalPlan::CreateOp {
1387                source, pattern, ..
1388            } => {
1389                // Source should be NodeScan(n)
1390                let src = source.as_ref().expect("should have source");
1391                match src.as_ref() {
1392                    LogicalPlan::NodeScan {
1393                        variable, label_id, ..
1394                    } => {
1395                        assert_eq!(variable, "n");
1396                        assert_eq!(*label_id, None);
1397                    }
1398                    other => panic!("expected NodeScan, got {:?}", other),
1399                }
1400                // Pattern should have the Person node
1401                assert!(!pattern.chains.is_empty());
1402            }
1403            other => panic!("expected CreateOp, got {:?}", other),
1404        }
1405    }
1406
1407    /// CREATE (n:Person) -> CreateOp with no source
1408    #[test]
1409    fn test_plan_create_only() {
1410        let plan = plan_query("CREATE (n:Person)");
1411
1412        match &plan {
1413            LogicalPlan::CreateOp { source, pattern } => {
1414                assert!(source.is_none());
1415                assert!(!pattern.chains.is_empty());
1416            }
1417            other => panic!("expected CreateOp, got {:?}", other),
1418        }
1419    }
1420
1421    /// MATCH (n:Person) SET n.name = "Bob" RETURN n
1422    /// -> NodeScan + SetOp + Project
1423    #[test]
1424    fn test_plan_match_set_return() {
1425        let plan = plan_query("MATCH (n:Person) SET n.name = 'Bob' RETURN n");
1426
1427        // Outermost: Project
1428        let project_source = match &plan {
1429            LogicalPlan::Project { source, .. } => source.as_ref(),
1430            other => panic!("expected Project, got {:?}", other),
1431        };
1432
1433        // SetOp
1434        let set_source = match project_source {
1435            LogicalPlan::SetOp { source, items } => {
1436                assert_eq!(items.len(), 1);
1437                source.as_ref()
1438            }
1439            other => panic!("expected SetOp, got {:?}", other),
1440        };
1441
1442        // NodeScan
1443        match set_source {
1444            LogicalPlan::NodeScan { variable, .. } => {
1445                assert_eq!(variable, "n");
1446            }
1447            other => panic!("expected NodeScan, got {:?}", other),
1448        }
1449    }
1450
1451    /// MATCH (n) DELETE n -> NodeScan + DeleteOp
1452    #[test]
1453    fn test_plan_match_delete() {
1454        let plan = plan_query("MATCH (n) DELETE n");
1455
1456        match &plan {
1457            LogicalPlan::DeleteOp {
1458                source,
1459                exprs,
1460                detach,
1461            } => {
1462                assert!(!detach);
1463                assert_eq!(exprs.len(), 1);
1464                assert_eq!(exprs[0], Expression::Variable("n".to_string()));
1465                match source.as_ref() {
1466                    LogicalPlan::NodeScan { variable, .. } => {
1467                        assert_eq!(variable, "n");
1468                    }
1469                    other => panic!("expected NodeScan, got {:?}", other),
1470                }
1471            }
1472            other => panic!("expected DeleteOp, got {:?}", other),
1473        }
1474    }
1475
1476    /// MATCH (n) RETURN n ORDER BY n.name SKIP 5 LIMIT 10
1477    /// -> NodeScan + Project + Sort + Skip + Limit
1478    #[test]
1479    fn test_plan_return_with_order_skip_limit() {
1480        let plan = plan_query("MATCH (n) RETURN n ORDER BY n.name SKIP 5 LIMIT 10");
1481
1482        // Outermost: Limit
1483        let limit_source = match &plan {
1484            LogicalPlan::Limit { source, count } => {
1485                assert_eq!(*count, Expression::Literal(Literal::Integer(10)));
1486                source.as_ref()
1487            }
1488            other => panic!("expected Limit, got {:?}", other),
1489        };
1490
1491        // Skip
1492        let skip_source = match limit_source {
1493            LogicalPlan::Skip { source, count } => {
1494                assert_eq!(*count, Expression::Literal(Literal::Integer(5)));
1495                source.as_ref()
1496            }
1497            other => panic!("expected Skip, got {:?}", other),
1498        };
1499
1500        // Sort
1501        let sort_source = match skip_source {
1502            LogicalPlan::Sort { source, items } => {
1503                assert_eq!(items.len(), 1);
1504                source.as_ref()
1505            }
1506            other => panic!("expected Sort, got {:?}", other),
1507        };
1508
1509        // Project
1510        match sort_source {
1511            LogicalPlan::Project { source, .. } => match source.as_ref() {
1512                LogicalPlan::NodeScan { variable, .. } => {
1513                    assert_eq!(variable, "n");
1514                }
1515                other => panic!("expected NodeScan, got {:?}", other),
1516            },
1517            other => panic!("expected Project, got {:?}", other),
1518        }
1519    }
1520
1521    /// MATCH (n:Person) REMOVE n.email, n:Temp
1522    /// -> NodeScan + RemoveOp
1523    #[test]
1524    fn test_plan_match_remove() {
1525        let plan = plan_query("MATCH (n:Person) REMOVE n.email, n:Temp");
1526
1527        match &plan {
1528            LogicalPlan::RemoveOp { source, items } => {
1529                assert_eq!(items.len(), 2);
1530                match source.as_ref() {
1531                    LogicalPlan::NodeScan { variable, .. } => {
1532                        assert_eq!(variable, "n");
1533                    }
1534                    other => panic!("expected NodeScan, got {:?}", other),
1535                }
1536            }
1537            other => panic!("expected RemoveOp, got {:?}", other),
1538        }
1539    }
1540
1541    /// PlanError display formatting.
1542    #[test]
1543    fn test_plan_error_display() {
1544        let err = PlanError {
1545            message: "test error".to_string(),
1546        };
1547        assert_eq!(err.to_string(), "Plan error: test error");
1548    }
1549
1550    /// RETURN without MATCH should fail.
1551    #[test]
1552    fn test_plan_return_without_source_fails() {
1553        let query = parse_query("MATCH (n) RETURN n").expect("should parse");
1554        // Manually construct a RETURN-only query to test error.
1555        let return_only = Query {
1556            clauses: vec![query.clauses.into_iter().nth(1).expect("has RETURN")],
1557        };
1558        let mut catalog = Catalog::default();
1559        let mut planner = LogicalPlanner::new(&mut catalog);
1560        let result = planner.plan(&return_only);
1561        assert!(result.is_err());
1562        assert!(result
1563            .expect_err("should fail")
1564            .message
1565            .contains("requires a preceding data source"));
1566    }
1567
1568    // ======================================================================
1569    // TASK-061: Planner WITH clause tests
1570    // ======================================================================
1571
1572    /// MATCH (n:Person) WITH n RETURN n -> NodeScan + With + Project
1573    #[test]
1574    fn test_plan_with_simple() {
1575        let plan = plan_query("MATCH (n:Person) WITH n RETURN n");
1576
1577        // Outermost: Project
1578        let project_source = match &plan {
1579            LogicalPlan::Project { source, .. } => source.as_ref(),
1580            other => panic!("expected Project, got {:?}", other),
1581        };
1582
1583        // With
1584        let with_source = match project_source {
1585            LogicalPlan::With {
1586                source,
1587                items,
1588                where_clause,
1589                distinct,
1590            } => {
1591                assert_eq!(items.len(), 1);
1592                assert!(where_clause.is_none());
1593                assert!(!distinct);
1594                source.as_ref()
1595            }
1596            other => panic!("expected With, got {:?}", other),
1597        };
1598
1599        // NodeScan
1600        match with_source {
1601            LogicalPlan::NodeScan { variable, .. } => {
1602                assert_eq!(variable, "n");
1603            }
1604            other => panic!("expected NodeScan, got {:?}", other),
1605        }
1606    }
1607
1608    /// MATCH (n:Person) WITH n WHERE n.age > 30 RETURN n
1609    #[test]
1610    fn test_plan_with_where() {
1611        let plan = plan_query("MATCH (n:Person) WITH n WHERE n.age > 30 RETURN n");
1612
1613        let project_source = match &plan {
1614            LogicalPlan::Project { source, .. } => source.as_ref(),
1615            other => panic!("expected Project, got {:?}", other),
1616        };
1617
1618        match project_source {
1619            LogicalPlan::With {
1620                where_clause,
1621                items,
1622                ..
1623            } => {
1624                assert_eq!(items.len(), 1);
1625                assert!(where_clause.is_some());
1626            }
1627            other => panic!("expected With, got {:?}", other),
1628        }
1629    }
1630
1631    /// WITH without source should fail
1632    #[test]
1633    fn test_plan_with_without_source_fails() {
1634        let query = parse_query("MATCH (n) WITH n RETURN n").expect("should parse");
1635        // Build a WITH-only query
1636        let with_only = Query {
1637            clauses: vec![query.clauses.into_iter().nth(1).expect("has WITH")],
1638        };
1639        let mut catalog = Catalog::default();
1640        let mut planner = LogicalPlanner::new(&mut catalog);
1641        let result = planner.plan(&with_only);
1642        assert!(result.is_err());
1643        assert!(result
1644            .expect_err("should fail")
1645            .message
1646            .contains("requires a preceding data source"));
1647    }
1648
1649    // ======================================================================
1650    // TASK-064: WITH DISTINCT planner test
1651    // ======================================================================
1652
1653    /// MATCH (n:Person) WITH DISTINCT n.name AS name RETURN name
1654    #[test]
1655    fn test_plan_with_distinct() {
1656        let plan = plan_query("MATCH (n:Person) WITH DISTINCT n.name AS name RETURN name");
1657
1658        let project_source = match &plan {
1659            LogicalPlan::Project { source, .. } => source.as_ref(),
1660            other => panic!("expected Project, got {:?}", other),
1661        };
1662
1663        match project_source {
1664            LogicalPlan::With {
1665                distinct, items, ..
1666            } => {
1667                assert!(distinct);
1668                assert_eq!(items.len(), 1);
1669                assert_eq!(items[0].alias, Some("name".to_string()));
1670            }
1671            other => panic!("expected With, got {:?}", other),
1672        }
1673    }
1674
1675    // ======================================================================
1676    // TASK-063: WITH + aggregation planner tests
1677    // ======================================================================
1678
1679    /// MATCH (n:Person) WITH n, count(*) AS cnt RETURN n, cnt
1680    /// -> NodeScan + Aggregate(group_keys=[n], aggs=[count(*) AS cnt]) + Project
1681    #[test]
1682    fn test_plan_with_count_star_aggregation() {
1683        let plan = plan_query("MATCH (n:Person) WITH n, count(*) AS cnt RETURN n, cnt");
1684
1685        // Outermost: Project
1686        let project_source = match &plan {
1687            LogicalPlan::Project { source, .. } => source.as_ref(),
1688            other => panic!("expected Project, got {:?}", other),
1689        };
1690
1691        // Should be Aggregate (not With), because count(*) was detected
1692        match project_source {
1693            LogicalPlan::Aggregate {
1694                group_keys,
1695                aggregates,
1696                source,
1697                ..
1698            } => {
1699                // group key: n
1700                assert_eq!(group_keys.len(), 1);
1701                assert_eq!(group_keys[0], Expression::Variable("n".to_string()));
1702                // aggregate: count(*) AS cnt
1703                assert_eq!(aggregates.len(), 1);
1704                assert_eq!(aggregates[0].0, "cnt");
1705                assert_eq!(aggregates[0].1, AggregateFunc::CountStar);
1706                // source: NodeScan
1707                match source.as_ref() {
1708                    LogicalPlan::NodeScan { variable, .. } => {
1709                        assert_eq!(variable, "n");
1710                    }
1711                    other => panic!("expected NodeScan, got {:?}", other),
1712                }
1713            }
1714            other => panic!("expected Aggregate, got {:?}", other),
1715        }
1716    }
1717
1718    /// MATCH (n:Person) WITH count(*) AS total RETURN total
1719    /// -> NodeScan + Aggregate(group_keys=[], aggs=[count(*) AS total]) + Project
1720    #[test]
1721    fn test_plan_with_count_star_no_group_key() {
1722        let plan = plan_query("MATCH (n:Person) WITH count(*) AS total RETURN total");
1723
1724        let project_source = match &plan {
1725            LogicalPlan::Project { source, .. } => source.as_ref(),
1726            other => panic!("expected Project, got {:?}", other),
1727        };
1728
1729        match project_source {
1730            LogicalPlan::Aggregate {
1731                group_keys,
1732                aggregates,
1733                ..
1734            } => {
1735                assert!(group_keys.is_empty());
1736                assert_eq!(aggregates.len(), 1);
1737                assert_eq!(aggregates[0].0, "total");
1738                assert_eq!(aggregates[0].1, AggregateFunc::CountStar);
1739            }
1740            other => panic!("expected Aggregate, got {:?}", other),
1741        }
1742    }
1743
1744    /// Optimizer pass-through test.
1745    #[test]
1746    fn test_optimizer_passthrough() {
1747        let plan = plan_query("MATCH (n:Person) WHERE n.age > 30 RETURN n");
1748        let optimized = optimize::optimize(plan.clone());
1749        assert_eq!(plan, optimized);
1750    }
1751
1752    // ======================================================================
1753    // TASK-070: Planner UNWIND clause tests
1754    // ======================================================================
1755
1756    /// UNWIND [1,2,3] AS x RETURN x -> EmptySource + Unwind + Project
1757    #[test]
1758    fn test_plan_unwind_list_literal() {
1759        let plan = plan_query("UNWIND [1, 2, 3] AS x RETURN x");
1760
1761        // Outermost: Project
1762        let project_source = match &plan {
1763            LogicalPlan::Project { source, .. } => source.as_ref(),
1764            other => panic!("expected Project, got {:?}", other),
1765        };
1766
1767        // Unwind
1768        match project_source {
1769            LogicalPlan::Unwind {
1770                source,
1771                expr,
1772                variable,
1773            } => {
1774                assert_eq!(variable, "x");
1775                assert!(matches!(expr, Expression::ListLiteral(_)));
1776                // Source should be EmptySource
1777                match source.as_ref() {
1778                    LogicalPlan::EmptySource => {}
1779                    other => panic!("expected EmptySource, got {:?}", other),
1780                }
1781            }
1782            other => panic!("expected Unwind, got {:?}", other),
1783        }
1784    }
1785
1786    /// MATCH (n:Person) UNWIND n.hobbies AS h RETURN h
1787    /// -> NodeScan + Unwind + Project
1788    #[test]
1789    fn test_plan_match_unwind() {
1790        let plan = plan_query("MATCH (n:Person) UNWIND n.hobbies AS h RETURN h");
1791
1792        let project_source = match &plan {
1793            LogicalPlan::Project { source, .. } => source.as_ref(),
1794            other => panic!("expected Project, got {:?}", other),
1795        };
1796
1797        match project_source {
1798            LogicalPlan::Unwind {
1799                source,
1800                variable,
1801                expr,
1802            } => {
1803                assert_eq!(variable, "h");
1804                assert_eq!(
1805                    *expr,
1806                    Expression::Property(
1807                        Box::new(Expression::Variable("n".to_string())),
1808                        "hobbies".to_string(),
1809                    )
1810                );
1811                match source.as_ref() {
1812                    LogicalPlan::NodeScan { variable, .. } => {
1813                        assert_eq!(variable, "n");
1814                    }
1815                    other => panic!("expected NodeScan, got {:?}", other),
1816                }
1817            }
1818            other => panic!("expected Unwind, got {:?}", other),
1819        }
1820    }
1821
1822    // ======================================================================
1823    // TASK-075: Planner OPTIONAL MATCH tests
1824    // ======================================================================
1825
1826    /// MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b) RETURN a, b
1827    /// -> NodeScan(a) + OptionalExpand(KNOWS, b) + Project
1828    #[test]
1829    fn test_plan_optional_match_basic() {
1830        let (plan, catalog) = plan_query_with_catalog(
1831            "MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b) RETURN a, b",
1832        );
1833        let knows_id = catalog.rel_type_id("KNOWS").expect("KNOWS rel type");
1834
1835        // Outermost: Project
1836        let project_source = match &plan {
1837            LogicalPlan::Project { source, .. } => source.as_ref(),
1838            other => panic!("expected Project, got {:?}", other),
1839        };
1840
1841        // OptionalExpand
1842        let opt_source = match project_source {
1843            LogicalPlan::OptionalExpand {
1844                src_var,
1845                rel_var,
1846                target_var,
1847                rel_type_id,
1848                direction,
1849                source,
1850            } => {
1851                assert_eq!(src_var, "a");
1852                assert!(rel_var.is_none());
1853                assert_eq!(target_var, "b");
1854                assert_eq!(*rel_type_id, Some(knows_id));
1855                assert_eq!(*direction, RelDirection::Outgoing);
1856                source.as_ref()
1857            }
1858            other => panic!("expected OptionalExpand, got {:?}", other),
1859        };
1860
1861        // NodeScan(a:Person)
1862        match opt_source {
1863            LogicalPlan::NodeScan {
1864                variable, label_id, ..
1865            } => {
1866                assert_eq!(variable, "a");
1867                assert!(label_id.is_some());
1868            }
1869            other => panic!("expected NodeScan, got {:?}", other),
1870        }
1871    }
1872
1873    /// OPTIONAL MATCH without preceding MATCH should fail
1874    #[test]
1875    fn test_plan_optional_match_without_source_fails() {
1876        let query =
1877            parse_query("OPTIONAL MATCH (a)-[:KNOWS]->(b) RETURN a, b").expect("should parse");
1878        let mut catalog = Catalog::default();
1879        let mut planner = LogicalPlanner::new(&mut catalog);
1880        let result = planner.plan(&query);
1881        assert!(result.is_err());
1882        assert!(result
1883            .expect_err("should fail")
1884            .message
1885            .contains("requires a preceding MATCH"));
1886    }
1887
1888    /// MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b) WHERE b.age > 20 RETURN a, b
1889    /// -> NodeScan + OptionalExpand + Filter + Project
1890    #[test]
1891    fn test_plan_optional_match_with_where() {
1892        let plan = plan_query(
1893            "MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b) WHERE b.age > 20 RETURN a, b",
1894        );
1895
1896        // Outermost: Project
1897        let project_source = match &plan {
1898            LogicalPlan::Project { source, .. } => source.as_ref(),
1899            other => panic!("expected Project, got {:?}", other),
1900        };
1901
1902        // Filter from OPTIONAL MATCH WHERE
1903        let filter_source = match project_source {
1904            LogicalPlan::Filter { source, .. } => source.as_ref(),
1905            other => panic!("expected Filter, got {:?}", other),
1906        };
1907
1908        // OptionalExpand
1909        match filter_source {
1910            LogicalPlan::OptionalExpand { target_var, .. } => {
1911                assert_eq!(target_var, "b");
1912            }
1913            other => panic!("expected OptionalExpand, got {:?}", other),
1914        }
1915    }
1916
1917    /// MATCH (a:Person) OPTIONAL MATCH (a)-[r:KNOWS]->(b) RETURN a, r, b
1918    /// -> OptionalExpand with rel_var
1919    #[test]
1920    fn test_plan_optional_match_with_rel_var() {
1921        let plan = plan_query("MATCH (a:Person) OPTIONAL MATCH (a)-[r:KNOWS]->(b) RETURN a, r, b");
1922
1923        let project_source = match &plan {
1924            LogicalPlan::Project { source, .. } => source.as_ref(),
1925            other => panic!("expected Project, got {:?}", other),
1926        };
1927
1928        match project_source {
1929            LogicalPlan::OptionalExpand {
1930                rel_var,
1931                target_var,
1932                ..
1933            } => {
1934                assert_eq!(*rel_var, Some("r".to_string()));
1935                assert_eq!(target_var, "b");
1936            }
1937            other => panic!("expected OptionalExpand, got {:?}", other),
1938        }
1939    }
1940
1941    // -- TASK-104/105: VarLengthExpand planner tests --
1942
1943    #[test]
1944    fn test_plan_var_length_bounded() {
1945        let plan = plan_query("MATCH (a)-[*1..3]->(b) RETURN b");
1946        // Outermost: Project wrapping VarLengthExpand
1947        match &plan {
1948            LogicalPlan::Project { source, .. } => match source.as_ref() {
1949                LogicalPlan::VarLengthExpand {
1950                    src_var,
1951                    target_var,
1952                    min_hops,
1953                    max_hops,
1954                    ..
1955                } => {
1956                    assert_eq!(src_var, "a");
1957                    assert_eq!(target_var, "b");
1958                    assert_eq!(*min_hops, 1);
1959                    assert_eq!(*max_hops, 3);
1960                }
1961                other => panic!("expected VarLengthExpand, got {:?}", other),
1962            },
1963            other => panic!("expected Project, got {:?}", other),
1964        }
1965    }
1966
1967    #[test]
1968    fn test_plan_var_length_unbounded_gets_default_max() {
1969        let plan = plan_query("MATCH (a)-[*]->(b) RETURN b");
1970        match &plan {
1971            LogicalPlan::Project { source, .. } => match source.as_ref() {
1972                LogicalPlan::VarLengthExpand {
1973                    min_hops, max_hops, ..
1974                } => {
1975                    assert_eq!(*min_hops, 1);
1976                    assert_eq!(*max_hops, DEFAULT_MAX_HOPS);
1977                }
1978                other => panic!("expected VarLengthExpand, got {:?}", other),
1979            },
1980            other => panic!("expected Project, got {:?}", other),
1981        }
1982    }
1983
1984    #[test]
1985    fn test_plan_var_length_typed() {
1986        let (plan, catalog) = plan_query_with_catalog("MATCH (a)-[:KNOWS*2..4]->(b) RETURN b");
1987        let knows_id = catalog.rel_type_id("KNOWS").expect("KNOWS exists");
1988        match &plan {
1989            LogicalPlan::Project { source, .. } => match source.as_ref() {
1990                LogicalPlan::VarLengthExpand {
1991                    rel_type_id,
1992                    min_hops,
1993                    max_hops,
1994                    ..
1995                } => {
1996                    assert_eq!(*rel_type_id, Some(knows_id));
1997                    assert_eq!(*min_hops, 2);
1998                    assert_eq!(*max_hops, 4);
1999                }
2000                other => panic!("expected VarLengthExpand, got {:?}", other),
2001            },
2002            other => panic!("expected Project, got {:?}", other),
2003        }
2004    }
2005
2006    #[test]
2007    fn test_plan_regular_expand_unchanged() {
2008        let plan = plan_query("MATCH (a)-[:KNOWS]->(b) RETURN b");
2009        match &plan {
2010            LogicalPlan::Project { source, .. } => match source.as_ref() {
2011                LogicalPlan::Expand { .. } => {} // Regular expand, not VarLengthExpand
2012                other => panic!("expected Expand, got {:?}", other),
2013            },
2014            other => panic!("expected Project, got {:?}", other),
2015        }
2016    }
2017
2018    #[test]
2019    fn test_plan_var_length_exact_hop() {
2020        let plan = plan_query("MATCH (a)-[*2]->(b) RETURN b");
2021        match &plan {
2022            LogicalPlan::Project { source, .. } => match source.as_ref() {
2023                LogicalPlan::VarLengthExpand {
2024                    min_hops, max_hops, ..
2025                } => {
2026                    assert_eq!(*min_hops, 2);
2027                    assert_eq!(*max_hops, 2);
2028                }
2029                other => panic!("expected VarLengthExpand, got {:?}", other),
2030            },
2031            other => panic!("expected Project, got {:?}", other),
2032        }
2033    }
2034
2035    #[test]
2036    fn test_plan_var_length_open_end_gets_default() {
2037        let plan = plan_query("MATCH (a)-[*3..]->(b) RETURN b");
2038        match &plan {
2039            LogicalPlan::Project { source, .. } => match source.as_ref() {
2040                LogicalPlan::VarLengthExpand {
2041                    min_hops, max_hops, ..
2042                } => {
2043                    assert_eq!(*min_hops, 3);
2044                    assert_eq!(*max_hops, DEFAULT_MAX_HOPS);
2045                }
2046                other => panic!("expected VarLengthExpand, got {:?}", other),
2047            },
2048            other => panic!("expected Project, got {:?}", other),
2049        }
2050    }
2051
2052    #[test]
2053    fn test_plan_var_length_with_variable() {
2054        let plan = plan_query("MATCH (a)-[r:KNOWS*1..2]->(b) RETURN b");
2055        match &plan {
2056            LogicalPlan::Project { source, .. } => match source.as_ref() {
2057                LogicalPlan::VarLengthExpand {
2058                    rel_var,
2059                    min_hops,
2060                    max_hops,
2061                    ..
2062                } => {
2063                    assert_eq!(*rel_var, Some("r".to_string()));
2064                    assert_eq!(*min_hops, 1);
2065                    assert_eq!(*max_hops, 2);
2066                }
2067                other => panic!("expected VarLengthExpand, got {:?}", other),
2068            },
2069            other => panic!("expected Project, got {:?}", other),
2070        }
2071    }
2072
2073    // ======================================================================
2074    // MM-001: Planner hyperedge tests (cfg-gated)
2075    // ======================================================================
2076
2077    #[cfg(feature = "hypergraph")]
2078    mod hypergraph_planner_tests {
2079        use super::*;
2080
2081        // MM-001: CREATE HYPEREDGE produces CreateHyperedgeOp
2082        #[test]
2083        fn plan_create_hyperedge_basic() {
2084            let plan = plan_query("CREATE HYPEREDGE (h:GroupMigration) FROM (a, b) TO (c)");
2085            match plan {
2086                LogicalPlan::CreateHyperedgeOp {
2087                    source,
2088                    variable,
2089                    labels,
2090                    sources,
2091                    targets,
2092                } => {
2093                    assert!(
2094                        source.is_none(),
2095                        "standalone CREATE HYPEREDGE has no source"
2096                    );
2097                    assert_eq!(variable, Some("h".to_string()));
2098                    assert_eq!(labels, vec!["GroupMigration".to_string()]);
2099                    assert_eq!(sources.len(), 2);
2100                    assert_eq!(targets.len(), 1);
2101                }
2102                other => panic!("expected CreateHyperedgeOp, got {:?}", other),
2103            }
2104        }
2105
2106        // MM-003: MATCH HYPEREDGE produces HyperEdgeScan
2107        #[test]
2108        fn plan_match_hyperedge_basic() {
2109            let plan = plan_query("MATCH HYPEREDGE (h:GroupMigration) RETURN h");
2110            // Should be Project -> HyperEdgeScan
2111            match plan {
2112                LogicalPlan::Project { source, .. } => match *source {
2113                    LogicalPlan::HyperEdgeScan { variable } => {
2114                        assert_eq!(variable, "h");
2115                    }
2116                    other => panic!("expected HyperEdgeScan, got {:?}", other),
2117                },
2118                other => panic!("expected Project, got {:?}", other),
2119            }
2120        }
2121    }
2122}