Skip to main content

linkrs_query/planning/statements/dql/
path_planner.rs

1//! PATH Query Planner
2//! Planning for handling Nebula PATH query requests
3//!
4//! ## Explanation of the improvements
5//!
6//! Implementing shortest path planning
7//! Implement all path planning functions.
8//! Support for the shortest path with weights
9//! Improve the logic for path filtering.
10
11use crate::binder::BoundStatement;
12use crate::parser::ast::Stmt;
13use crate::planning::plan::core::next_node_id;
14use crate::planning::plan::core::nodes::traversal::path_algorithms::PathUniqueness;
15use crate::planning::plan::core::nodes::traversal::{AllPathsNode, ShortestPathNode};
16use crate::planning::plan::core::PlanNode;
17use crate::planning::plan::logical::logical_nodes::access::LogicalStartNode;
18use crate::planning::plan::logical::logical_nodes::algorithm::{
19    LogicalAllPathsNode, LogicalShortestPathNode,
20};
21use crate::planning::plan::logical::LogicalNodeEnum;
22use crate::planning::plan::SubPlan;
23use crate::planning::planner::{Planner, PlannerError, ValidatedStatement};
24use crate::planning::statements::plan_combiner::wrap_logical_filter;
25use crate::QueryContext;
26use linkrs_core::types::VertexId;
27use linkrs_core::Value;
28use std::sync::Arc;
29
30pub use crate::planning::plan::core::nodes::{
31    ArgumentNode, DedupNode, ExpandAllNode, FilterNode, GetNeighborsNode, ProjectNode, StartNode,
32};
33pub use crate::planning::plan::core::PlanNodeEnum;
34
35/// PATH Query Planner
36/// Responsible for converting PATH queries into execution plans.
37#[derive(Debug, Clone)]
38pub struct PathPlanner {}
39
40impl PathPlanner {
41    /// Create a new PATH planner.
42    pub fn new() -> Self {
43        Self {}
44    }
45}
46
47impl Planner for PathPlanner {
48    fn transform(
49        &mut self,
50        validated: &ValidatedStatement,
51        qctx: Arc<QueryContext>,
52    ) -> Result<SubPlan, PlannerError> {
53        let find_path_stmt = match validated.stmt() {
54            Stmt::FindPath(find_path_stmt) => find_path_stmt,
55            _ => {
56                return Err(PlannerError::InvalidOperation(
57                    "PathPlanner requires the FindPath statement.".to_string(),
58                ));
59            }
60        };
61
62        let space_id = qctx.space_id().ok_or_else(|| {
63            PlannerError::InvalidOperation(
64                "No graph space selected, please execute USE <space> first".to_string(),
65            )
66        })?;
67
68        // FIND PATH syntax carries no vertex label: path vertices are
69        // materialized at execution from the edge type schema (or from tagged
70        // vertex values), so the plan leaves the vertex label empty instead
71        // of guessing one.
72        let start_node = StartNode::new();
73        let start_node_enum = PlanNodeEnum::Start(start_node);
74
75        let edge_types = self.get_edge_types_from_stmt(find_path_stmt);
76        let max_steps = self.get_max_steps_from_stmt(find_path_stmt);
77
78        let start_vertex_ids = self.extract_vertex_ids_from_exprs(&find_path_stmt.from.vertices);
79        let end_vertex_ids = self.extract_vertex_ids_from_expr(&find_path_stmt.to);
80
81        let (mut root_node, mut logical_root) = if self.is_shortest_path_stmt(find_path_stmt) {
82            // A cycle can never produce a shorter path, so WITH LOOP /
83            // WITH TRAIL / WITH WALK are no-ops on this branch; report the
84            // ignored modifiers instead of folding them into the plan.
85            if find_path_stmt.with_loop || find_path_stmt.with_trail || find_path_stmt.with_walk {
86                log::debug!(
87                    "FIND SHORTEST PATH ignores WITH LOOP/TRAIL/WALK: \
88                     revisiting a vertex or edge cannot yield a shorter path"
89                );
90            }
91            let root = self.build_shortest_path_plan(PathPlanParams {
92                left_input: start_node_enum.clone(),
93                space_id,
94                edge_types: edge_types.clone(),
95                max_steps,
96                start_vertex_ids: start_vertex_ids.clone(),
97                end_vertex_ids: end_vertex_ids.clone(),
98                weight_expression: find_path_stmt.weight_expression.clone(),
99            })?;
100            let logical = Self::shortest_path_logical(
101                space_id,
102                edge_types,
103                max_steps,
104                start_vertex_ids,
105                end_vertex_ids,
106                find_path_stmt.weight_expression.clone(),
107            );
108            (root, logical)
109        } else {
110            let root = self.build_all_paths_plan(
111                PathPlanParams {
112                    left_input: start_node_enum.clone(),
113                    space_id,
114                    edge_types: edge_types.clone(),
115                    max_steps,
116                    start_vertex_ids: start_vertex_ids.clone(),
117                    end_vertex_ids: end_vertex_ids.clone(),
118                    // Rejected at bind time; WEIGHT never reaches ALL PATH.
119                    weight_expression: None,
120                },
121                find_path_stmt,
122            )?;
123            let logical = Self::all_paths_logical(
124                space_id,
125                edge_types,
126                max_steps,
127                find_path_stmt,
128                start_vertex_ids,
129                end_vertex_ids,
130            );
131            (root, logical)
132        };
133
134        // Apply the WHERE clause on top of the path result.
135        if let Some(where_expr) = find_path_stmt.where_clause.clone() {
136            let filter_node =
137                FilterNode::new(root_node.clone(), where_expr.clone()).map_err(|e| {
138                    PlannerError::PlanGenerationFailed(format!(
139                        "Failed to create FilterNode: {}",
140                        e
141                    ))
142                })?;
143            let col_names = filter_node.col_names().to_vec();
144            logical_root = wrap_logical_filter(logical_root, where_expr.clone(), col_names);
145            root_node = PlanNodeEnum::Filter(filter_node);
146        }
147
148        let sub_plan = SubPlan {
149            root: Some(root_node),
150            tail: Some(start_node_enum),
151            logical_root: Some(logical_root),
152        };
153
154        Ok(sub_plan)
155    }
156
157    fn plan_bound(
158        &mut self,
159        ctx: &crate::planning::context::PlanContext<'_>,
160    ) -> Result<SubPlan, PlannerError> {
161        let bound = ctx.bound;
162        let qctx = ctx.qctx.clone();
163        let metadata = ctx.metadata;
164        let validated = ctx.validated;
165        let _ = (&bound, &qctx, &metadata, &validated);
166        let find_path = match bound {
167            BoundStatement::FindPath(fp) => fp,
168            _ => {
169                return Err(PlannerError::InvalidOperation(
170                    "PathPlanner requires the FindPath statement.".to_string(),
171                ));
172            }
173        };
174
175        let space_id = qctx.space_id().ok_or_else(|| {
176            PlannerError::InvalidOperation(
177                "No graph space selected, please execute USE <space> first".to_string(),
178            )
179        })?;
180
181        // FIND PATH syntax carries no vertex label: path vertices are
182        // materialized at execution from the edge type schema (or from tagged
183        // vertex values), so the plan leaves the vertex label empty instead
184        // of guessing one.
185        let start_node = StartNode::new();
186        let start_node_enum = PlanNodeEnum::Start(start_node);
187
188        let edge_types = find_path
189            .over
190            .as_ref()
191            .map(|(types, _)| types.clone())
192            .unwrap_or_default();
193
194        let max_steps = find_path.max_steps.unwrap_or(10);
195
196        let start_vertex_ids: Vec<Value> = find_path
197            .from
198            .iter()
199            .filter_map(|expr| match expr {
200                crate::binder::bound::BoundExpression::Literal(v, _) => Some(v.clone()),
201                _ => None,
202            })
203            .collect();
204
205        let end_vertex_ids: Vec<Value> = match &find_path.to {
206            crate::binder::bound::BoundExpression::Literal(v, _) => vec![v.clone()],
207            _ => vec![],
208        };
209
210        let (mut root_node, mut logical_root) = if find_path.shortest {
211            // A cycle can never produce a shorter path, so WITH LOOP /
212            // WITH TRAIL / WITH WALK are no-ops here; report the ignored
213            // modifiers instead of folding them into the plan.
214            if find_path.with_loop || find_path.with_trail || find_path.with_walk {
215                log::debug!(
216                    "FIND SHORTEST PATH ignores WITH LOOP/TRAIL/WALK: \
217                     revisiting a vertex or edge cannot yield a shorter path"
218                );
219            }
220            let root = self.build_shortest_path_plan(PathPlanParams {
221                left_input: start_node_enum.clone(),
222                space_id,
223                edge_types: edge_types.clone(),
224                max_steps,
225                start_vertex_ids: start_vertex_ids.clone(),
226                end_vertex_ids: end_vertex_ids.clone(),
227                weight_expression: find_path.weight_expression.clone(),
228            })?;
229            let logical = Self::shortest_path_logical(
230                space_id,
231                edge_types,
232                max_steps,
233                start_vertex_ids,
234                end_vertex_ids,
235                find_path.weight_expression.clone(),
236            );
237            (root, logical)
238        } else {
239            let direction = find_path
240                .over
241                .as_ref()
242                .map(|(_, dir)| *dir)
243                .unwrap_or(linkrs_core::EdgeDirection::Out);
244
245            // Default paths do not repeat vertices; WITH LOOP / WITH TRAIL /
246            // WITH WALK each relax a distinct part of that constraint (see
247            // PathUniqueness).
248            let path_uniqueness = PathUniqueness::from_flags(
249                find_path.with_loop,
250                find_path.with_trail,
251                find_path.with_walk,
252            );
253
254            let right_node = StartNode::new();
255            let right_node_enum = PlanNodeEnum::Start(right_node);
256
257            let mut all_paths_node = AllPathsNode::new(
258                start_node_enum.clone(),
259                right_node_enum,
260                space_id,
261                max_steps,
262                edge_types.clone(),
263                1,
264                max_steps,
265                path_uniqueness,
266            );
267            all_paths_node.set_direction(direction);
268            let start_vids: Vec<VertexId> = start_vertex_ids
269                .iter()
270                .filter_map(|v| VertexId::try_from(v).ok())
271                .collect();
272            let end_vids: Vec<VertexId> = end_vertex_ids
273                .iter()
274                .filter_map(|v| VertexId::try_from(v).ok())
275                .collect();
276            all_paths_node.set_start_vertex_ids(start_vids.clone());
277            all_paths_node.set_end_vertex_ids(end_vids.clone());
278
279            let logical = Self::all_paths_logical_from_parts(
280                space_id,
281                edge_types,
282                max_steps,
283                path_uniqueness,
284                direction,
285                start_vids,
286                end_vids,
287            );
288            (all_paths_node.into_enum(), logical)
289        };
290
291        // Apply the bound WHERE clause on top of the path result.
292        if let Some(where_clause) = &find_path.where_clause {
293            let expr_ctx = Arc::new(
294                linkrs_core::types::expr::expression_context::ExpressionAnalysisContext::new(),
295            );
296            let condition = crate::binder::expr_converter::bound_expr_to_contextual(
297                &where_clause.condition,
298                &expr_ctx,
299            )
300            .map_err(PlannerError::PlanGenerationFailed)?;
301            let filter_node =
302                FilterNode::new(root_node.clone(), condition.clone()).map_err(|e| {
303                    PlannerError::PlanGenerationFailed(format!(
304                        "Failed to create FilterNode: {}",
305                        e
306                    ))
307                })?;
308            let col_names = filter_node.col_names().to_vec();
309            logical_root = wrap_logical_filter(logical_root, condition, col_names);
310            root_node = PlanNodeEnum::Filter(filter_node);
311        }
312
313        let sub_plan = SubPlan {
314            root: Some(root_node),
315            tail: Some(start_node_enum),
316            logical_root: Some(logical_root),
317        };
318
319        Ok(sub_plan)
320    }
321
322    fn match_planner(&self, stmt: &Stmt) -> bool {
323        matches!(stmt, Stmt::FindPath(_))
324    }
325}
326
327/// Shared inputs for building a path plan.
328struct PathPlanParams {
329    left_input: PlanNodeEnum,
330    space_id: u64,
331    edge_types: Vec<String>,
332    max_steps: usize,
333    start_vertex_ids: Vec<Value>,
334    end_vertex_ids: Vec<Value>,
335    weight_expression: Option<String>,
336}
337
338impl PathPlanner {
339    fn build_shortest_path_plan(
340        &self,
341        params: PathPlanParams,
342    ) -> Result<PlanNodeEnum, PlannerError> {
343        let right_node = StartNode::new();
344        let right_node_enum = PlanNodeEnum::Start(right_node);
345
346        let mut shortest_path_node = ShortestPathNode::new(
347            params.left_input,
348            right_node_enum,
349            params.space_id,
350            params.edge_types,
351            params.max_steps,
352        );
353        shortest_path_node.set_start_vertex_ids(params.start_vertex_ids);
354        shortest_path_node.set_end_vertex_ids(params.end_vertex_ids);
355        if let Some(weight) = params.weight_expression {
356            shortest_path_node.set_weight_expression(weight);
357        }
358
359        Ok(shortest_path_node.into_enum())
360    }
361
362    fn build_all_paths_plan(
363        &self,
364        params: PathPlanParams,
365        stmt: &crate::parser::ast::FindPathStmt,
366    ) -> Result<PlanNodeEnum, PlannerError> {
367        let right_node = StartNode::new();
368        let right_node_enum = PlanNodeEnum::Start(right_node);
369
370        // Default paths do not repeat vertices; WITH LOOP / WITH TRAIL /
371        // WITH WALK each relax a distinct part of that constraint (see
372        // PathUniqueness).
373        let path_uniqueness =
374            crate::planning::plan::core::nodes::traversal::path_algorithms::PathUniqueness::from_flags(
375                stmt.with_loop, stmt.with_trail, stmt.with_walk,
376            );
377        let direction = stmt
378            .over
379            .as_ref()
380            .map(|over| over.direction)
381            .unwrap_or(linkrs_core::EdgeDirection::Out);
382
383        let mut all_paths_node = AllPathsNode::new(
384            params.left_input,
385            right_node_enum,
386            params.space_id,
387            params.max_steps,
388            params.edge_types,
389            1,
390            params.max_steps,
391            path_uniqueness,
392        );
393        all_paths_node.set_direction(direction);
394        let start_vids: Vec<VertexId> = params
395            .start_vertex_ids
396            .iter()
397            .filter_map(|v| VertexId::try_from(v).ok())
398            .collect();
399        let end_vids: Vec<VertexId> = params
400            .end_vertex_ids
401            .iter()
402            .filter_map(|v| VertexId::try_from(v).ok())
403            .collect();
404        all_paths_node.set_start_vertex_ids(start_vids);
405        all_paths_node.set_end_vertex_ids(end_vids);
406
407        Ok(all_paths_node.into_enum())
408    }
409
410    fn logical_start_leaf() -> LogicalNodeEnum {
411        LogicalNodeEnum::Start(LogicalStartNode::new())
412    }
413
414    fn shortest_path_logical(
415        space_id: u64,
416        edge_types: Vec<String>,
417        max_steps: usize,
418        start_vertex_ids: Vec<Value>,
419        end_vertex_ids: Vec<Value>,
420        weight_expression: Option<String>,
421    ) -> LogicalNodeEnum {
422        let left = Self::logical_start_leaf();
423        let right = Self::logical_start_leaf();
424        LogicalNodeEnum::ShortestPath(LogicalShortestPathNode {
425            id: next_node_id(),
426            left: Box::new(left),
427            right: Box::new(right),
428            space_id,
429            edge_types,
430            max_step: max_steps,
431            weight_expression,
432            heuristic_expression: None,
433            no_reverse: false,
434            start_vertex_ids,
435            end_vertex_ids,
436            output_var: None,
437            col_names: vec!["path".to_string()],
438            column_types: vec![],
439        })
440    }
441
442    fn all_paths_logical(
443        space_id: u64,
444        edge_types: Vec<String>,
445        max_steps: usize,
446        stmt: &crate::parser::ast::FindPathStmt,
447        start_vertex_ids: Vec<Value>,
448        end_vertex_ids: Vec<Value>,
449    ) -> LogicalNodeEnum {
450        let path_uniqueness =
451            crate::planning::plan::core::nodes::traversal::path_algorithms::PathUniqueness::from_flags(
452                stmt.with_loop, stmt.with_trail, stmt.with_walk,
453            );
454        let direction = stmt
455            .over
456            .as_ref()
457            .map(|over| over.direction)
458            .unwrap_or(linkrs_core::EdgeDirection::Out);
459        let start_vids: Vec<VertexId> = start_vertex_ids
460            .iter()
461            .filter_map(|v| VertexId::try_from(v).ok())
462            .collect();
463        let end_vids: Vec<VertexId> = end_vertex_ids
464            .iter()
465            .filter_map(|v| VertexId::try_from(v).ok())
466            .collect();
467        Self::all_paths_logical_from_parts(
468            space_id,
469            edge_types,
470            max_steps,
471            path_uniqueness,
472            direction,
473            start_vids,
474            end_vids,
475        )
476    }
477
478    fn all_paths_logical_from_parts(
479        space_id: u64,
480        edge_types: Vec<String>,
481        max_steps: usize,
482        path_uniqueness: crate::planning::plan::core::nodes::traversal::path_algorithms::PathUniqueness,
483        direction: linkrs_core::EdgeDirection,
484        start_vertex_ids: Vec<VertexId>,
485        end_vertex_ids: Vec<VertexId>,
486    ) -> LogicalNodeEnum {
487        let left = Self::logical_start_leaf();
488        let right = Self::logical_start_leaf();
489        LogicalNodeEnum::AllPaths(LogicalAllPathsNode {
490            id: next_node_id(),
491            left: Box::new(left),
492            right: Box::new(right),
493            space_id,
494            steps: max_steps,
495            edge_types,
496            min_hop: 1,
497            max_hop: max_steps,
498            path_uniqueness,
499            direction,
500            has_step_limit: true,
501            limit: -1,
502            offset: 0,
503            filter: None,
504            start_vertex_ids,
505            end_vertex_ids,
506            output_var: None,
507            col_names: vec!["path".to_string()],
508            column_types: vec![],
509        })
510    }
511
512    fn is_shortest_path_stmt(&self, stmt: &crate::parser::ast::FindPathStmt) -> bool {
513        stmt.shortest
514    }
515
516    fn get_edge_types_from_stmt(&self, stmt: &crate::parser::ast::FindPathStmt) -> Vec<String> {
517        stmt.over
518            .as_ref()
519            .map(|over| over.edge_types.clone())
520            .unwrap_or_default()
521    }
522
523    fn get_max_steps_from_stmt(&self, stmt: &crate::parser::ast::FindPathStmt) -> usize {
524        stmt.max_steps.unwrap_or(10)
525    }
526
527    fn extract_vertex_ids_from_exprs(
528        &self,
529        exprs: &[linkrs_core::types::ContextualExpression],
530    ) -> Vec<Value> {
531        let mut ids = Vec::new();
532        for expr in exprs {
533            if let Some(meta) = expr.expression() {
534                if let Some(value) = meta.as_literal() {
535                    ids.push(value.clone());
536                }
537            }
538        }
539        ids
540    }
541
542    fn extract_vertex_ids_from_expr(
543        &self,
544        expr: &linkrs_core::types::ContextualExpression,
545    ) -> Vec<Value> {
546        self.extract_vertex_ids_from_exprs(std::slice::from_ref(expr))
547    }
548}
549
550impl Default for PathPlanner {
551    fn default() -> Self {
552        Self::new()
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::binder::validation::{ValidatedStatement, ValidationInfo};
560    use crate::parser::ast::stmt::Ast;
561    use crate::parser::ast::{FindPathStmt, FromClause, OverClause, Span, Stmt};
562    use linkrs_core::types::expr::contextual::ContextualExpression;
563    use linkrs_core::types::expr::expression_context::ExpressionAnalysisContext;
564    use linkrs_core::types::expr::{Expression, ExpressionMeta};
565
566    fn test_query_context() -> Arc<QueryContext> {
567        let rctx = Arc::new(crate::QueryRequestContext::new("FIND PATH".to_string()));
568        let space_info = linkrs_core::types::SpaceInfo {
569            space_id: 1,
570            space_name: "test".to_string(),
571            ..Default::default()
572        };
573        Arc::new(
574            crate::QueryContext::builder(rctx)
575                .with_space_info(space_info)
576                .build(),
577        )
578    }
579
580    fn find_path_stmt(shortest: bool) -> FindPathStmt {
581        find_path_stmt_with_modifiers(shortest, false, false, false, None)
582    }
583
584    fn find_path_stmt_with_modifiers(
585        shortest: bool,
586        with_loop: bool,
587        with_trail: bool,
588        with_walk: bool,
589        weight_expression: Option<&str>,
590    ) -> FindPathStmt {
591        let ctx = Arc::new(ExpressionAnalysisContext::new());
592        let id =
593            ctx.register_expression(ExpressionMeta::new(Expression::Variable("dst".to_string())));
594        FindPathStmt {
595            span: Span::default(),
596            from: FromClause {
597                span: Span::default(),
598                vertices: vec![],
599            },
600            to: ContextualExpression::new(id, ctx.clone()),
601            over: Some(OverClause {
602                span: Span::default(),
603                edge_types: vec!["knows".to_string()],
604                direction: linkrs_core::EdgeDirection::Out,
605            }),
606            where_clause: None,
607            shortest,
608            max_steps: Some(3),
609            limit: None,
610            skip: None,
611            yield_clause: None,
612            weight_expression: weight_expression.map(str::to_string),
613            heuristic_expression: None,
614            with_loop,
615            with_trail,
616            with_walk,
617        }
618    }
619
620    fn validated(stmt: Stmt) -> ValidatedStatement {
621        let ctx = Arc::new(ExpressionAnalysisContext::new());
622        ValidatedStatement::new(Arc::new(Ast::new(stmt, ctx)), ValidationInfo::new())
623    }
624
625    #[test]
626    fn shortest_path_plan_carries_native_logical() {
627        let stmt = Stmt::FindPath(find_path_stmt(true));
628        let plan = PathPlanner::new()
629            .transform(&validated(stmt), test_query_context())
630            .expect("label-less FIND PATH plans without a vertex label");
631        assert!(
632            plan.logical_root.is_some(),
633            "shortest path plan must carry its native logical root"
634        );
635    }
636
637    #[test]
638    fn all_paths_plan_carries_native_logical_with_direction() {
639        let stmt = Stmt::FindPath(find_path_stmt(false));
640        let plan = PathPlanner::new()
641            .transform(&validated(stmt), test_query_context())
642            .expect("label-less FIND PATH plans without a vertex label");
643        assert!(
644            plan.logical_root.is_some(),
645            "all-paths plan must carry its native logical root"
646        );
647    }
648
649    #[test]
650    fn native_logical_converts_back_to_same_physical_shape() {
651        let stmt = Stmt::FindPath(find_path_stmt(false));
652        let plan = PathPlanner::new()
653            .transform(&validated(stmt), test_query_context())
654            .expect("label-less FIND PATH plans without a vertex label");
655        assert!(
656            plan.root.is_some() && plan.logical_root.is_some(),
657            "plan must carry both physical and logical shapes"
658        );
659    }
660
661    #[test]
662    fn shortest_path_plan_carries_weight_expression() {
663        let stmt = Stmt::FindPath(find_path_stmt_with_modifiers(
664            true,
665            false,
666            false,
667            false,
668            Some("cost"),
669        ));
670        let plan = PathPlanner::new()
671            .transform(&validated(stmt), test_query_context())
672            .expect("weighted shortest path plans");
673        match plan.root.as_ref().expect("physical root") {
674            PlanNodeEnum::ShortestPath(node) => {
675                assert_eq!(node.weight_expression().as_deref(), Some("cost"));
676            }
677            other => panic!("expected ShortestPath root, got {:?}", other),
678        }
679        match plan.logical_root.as_ref().expect("logical root") {
680            LogicalNodeEnum::ShortestPath(node) => {
681                assert_eq!(node.weight_expression.as_deref(), Some("cost"));
682            }
683            other => panic!("expected ShortestPath logical root, got {:?}", other),
684        }
685    }
686
687    #[test]
688    fn shortest_path_plan_defaults_weight_to_none() {
689        let stmt = Stmt::FindPath(find_path_stmt(true));
690        let plan = PathPlanner::new()
691            .transform(&validated(stmt), test_query_context())
692            .expect("plain shortest path plans");
693        match plan.root.as_ref().expect("physical root") {
694            PlanNodeEnum::ShortestPath(node) => {
695                assert!(node.weight_expression().is_none());
696            }
697            other => panic!("expected ShortestPath root, got {:?}", other),
698        }
699    }
700
701    #[test]
702    fn all_paths_plan_maps_repeat_flags_to_uniqueness() {
703        let cases = [
704            (false, false, false, PathUniqueness::VertexUnique),
705            (true, false, false, PathUniqueness::AllowSelfLoop),
706            (false, true, false, PathUniqueness::EdgeUnique),
707            (false, false, true, PathUniqueness::Free),
708            // A looser modifier dominates the tighter ones.
709            (true, true, false, PathUniqueness::EdgeUnique),
710            (true, false, true, PathUniqueness::Free),
711        ];
712        for (with_loop, with_trail, with_walk, expected) in cases {
713            let stmt = Stmt::FindPath(find_path_stmt_with_modifiers(
714                false, with_loop, with_trail, with_walk, None,
715            ));
716            let plan = PathPlanner::new()
717                .transform(&validated(stmt), test_query_context())
718                .expect("all path plans");
719            match plan.root.as_ref().expect("physical root") {
720                PlanNodeEnum::AllPaths(node) => {
721                    assert_eq!(
722                        node.path_uniqueness(),
723                        expected,
724                        "loop={with_loop} trail={with_trail} walk={with_walk} physical policy mismatch"
725                    );
726                }
727                other => panic!("expected AllPaths root, got {:?}", other),
728            }
729            match plan.logical_root.as_ref().expect("logical root") {
730                LogicalNodeEnum::AllPaths(node) => {
731                    assert_eq!(
732                        node.path_uniqueness, expected,
733                        "loop={with_loop} trail={with_trail} walk={with_walk} logical policy mismatch"
734                    );
735                }
736                other => panic!("expected AllPaths logical root, got {:?}", other),
737            }
738        }
739    }
740
741    #[test]
742    fn shortest_path_ignores_repeat_flags() {
743        // Cycles can never shorten a path, so the shortest-path plan stays a
744        // plain ShortestPath node regardless of the modifiers.
745        let stmt = Stmt::FindPath(find_path_stmt_with_modifiers(true, true, true, true, None));
746        let plan = PathPlanner::new()
747            .transform(&validated(stmt), test_query_context())
748            .expect("modifiers do not break shortest path planning");
749        assert!(
750            matches!(plan.root.as_ref(), Some(PlanNodeEnum::ShortestPath(_))),
751            "expected ShortestPath root, got {:?}",
752            plan.root
753        );
754    }
755}